From c1a381c58975e74b045ce942e363c783c76efe6d Mon Sep 17 00:00:00 2001 From: yvmou Date: Mon, 21 Sep 2026 16:16:59 +0800 Subject: [PATCH] =?UTF-8?q?godot=20ai=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addons/godot_ai/LICENSE | 21 + addons/godot_ai/README.md | 54 + addons/godot_ai/client_configurator.gd | 2067 ++++++++++++ addons/godot_ai/client_configurator.gd.uid | 1 + addons/godot_ai/clients/_atomic_write.gd | 251 ++ addons/godot_ai/clients/_atomic_write.gd.uid | 1 + addons/godot_ai/clients/_base.gd | 578 ++++ addons/godot_ai/clients/_base.gd.uid | 1 + addons/godot_ai/clients/_cli_exec.gd | 219 ++ 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 | 466 +++ addons/godot_ai/clients/_cli_strategy.gd.uid | 1 + addons/godot_ai/clients/_dsh_strategy.gd | 763 +++++ addons/godot_ai/clients/_dsh_strategy.gd.uid | 1 + addons/godot_ai/clients/_json_strategy.gd | 1114 +++++++ addons/godot_ai/clients/_json_strategy.gd.uid | 1 + addons/godot_ai/clients/_manual_command.gd | 353 ++ .../godot_ai/clients/_manual_command.gd.uid | 1 + addons/godot_ai/clients/_path_template.gd | 242 ++ addons/godot_ai/clients/_path_template.gd.uid | 1 + addons/godot_ai/clients/_registry.gd | 156 + addons/godot_ai/clients/_registry.gd.uid | 1 + addons/godot_ai/clients/_toml_strategy.gd | 754 +++++ addons/godot_ai/clients/_toml_strategy.gd.uid | 1 + addons/godot_ai/clients/_yaml_strategy.gd | 553 ++++ addons/godot_ai/clients/_yaml_strategy.gd.uid | 1 + addons/godot_ai/clients/antigravity.gd | 38 + addons/godot_ai/clients/antigravity.gd.uid | 1 + addons/godot_ai/clients/claude_code.gd | 81 + 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 | 42 + addons/godot_ai/clients/cline.gd.uid | 1 + addons/godot_ai/clients/codebuddy.gd | 25 + addons/godot_ai/clients/codebuddy.gd.uid | 1 + addons/godot_ai/clients/codex.gd | 44 + addons/godot_ai/clients/codex.gd.uid | 1 + addons/godot_ai/clients/cursor.gd | 20 + addons/godot_ai/clients/cursor.gd.uid | 1 + addons/godot_ai/clients/deepseek_harness.gd | 44 + .../godot_ai/clients/deepseek_harness.gd.uid | 1 + addons/godot_ai/clients/gemini_cli.gd | 24 + addons/godot_ai/clients/gemini_cli.gd.uid | 1 + addons/godot_ai/clients/grok.gd | 34 + addons/godot_ai/clients/grok.gd.uid | 1 + addons/godot_ai/clients/hermes.gd | 45 + addons/godot_ai/clients/hermes.gd.uid | 1 + addons/godot_ai/clients/kilo_code.gd | 37 + addons/godot_ai/clients/kilo_code.gd.uid | 1 + addons/godot_ai/clients/kimi_code.gd | 33 + addons/godot_ai/clients/kimi_code.gd.uid | 1 + addons/godot_ai/clients/kiro.gd | 22 + addons/godot_ai/clients/kiro.gd.uid | 1 + addons/godot_ai/clients/opencode.gd | 54 + addons/godot_ai/clients/opencode.gd.uid | 1 + addons/godot_ai/clients/pi.gd | 61 + addons/godot_ai/clients/pi.gd.uid | 1 + addons/godot_ai/clients/qwen_code.gd | 25 + addons/godot_ai/clients/qwen_code.gd.uid | 1 + addons/godot_ai/clients/roo_code.gd | 41 + addons/godot_ai/clients/roo_code.gd.uid | 1 + addons/godot_ai/clients/trae.gd | 22 + addons/godot_ai/clients/trae.gd.uid | 1 + addons/godot_ai/clients/vscode.gd | 29 + addons/godot_ai/clients/vscode.gd.uid | 1 + addons/godot_ai/clients/vscode_insiders.gd | 22 + .../godot_ai/clients/vscode_insiders.gd.uid | 1 + addons/godot_ai/clients/windsurf.gd | 28 + addons/godot_ai/clients/windsurf.gd.uid | 1 + addons/godot_ai/clients/zed.gd | 35 + addons/godot_ai/clients/zed.gd.uid | 1 + addons/godot_ai/clients/zoo_code.gd | 35 + addons/godot_ai/clients/zoo_code.gd.uid | 1 + addons/godot_ai/connection.gd | 1321 ++++++++ addons/godot_ai/connection.gd.uid | 1 + .../custom_tools/custom_tool_wrapper.gd | 80 + .../custom_tools/custom_tool_wrapper.gd.uid | 1 + .../godot_ai/custom_tools/mcp_call_context.gd | 44 + .../custom_tools/mcp_call_context.gd.uid | 1 + .../custom_tools/mcp_custom_tool_spec.gd | 66 + .../custom_tools/mcp_custom_tool_spec.gd.uid | 1 + .../custom_tools/mcp_service_locator.gd | 16 + .../custom_tools/mcp_service_locator.gd.uid | 1 + .../custom_tools/mcp_tool_registry.gd | 277 ++ .../custom_tools/mcp_tool_registry.gd.uid | 1 + .../godot_ai/debugger/mcp_debugger_plugin.gd | 2088 ++++++++++++ .../debugger/mcp_debugger_plugin.gd.uid | 1 + addons/godot_ai/dispatcher.gd | 529 +++ addons/godot_ai/dispatcher.gd.uid | 1 + addons/godot_ai/dock_panels/log_viewer.gd | 86 + addons/godot_ai/dock_panels/log_viewer.gd.uid | 1 + .../godot_ai/dock_panels/port_picker_panel.gd | 106 + .../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 | 446 +++ .../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 | 363 +++ 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 | 237 ++ addons/godot_ai/handlers/batch_handler.gd.uid | 1 + addons/godot_ai/handlers/camera_handler.gd | 1131 +++++++ .../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 | 58 + .../godot_ai/handlers/client_handler.gd.uid | 1 + addons/godot_ai/handlers/command_handler.gd | 13 + .../godot_ai/handlers/command_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 | 1069 +++++++ .../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 | 320 ++ .../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 | 1418 ++++++++ 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 | 772 +++++ .../handlers/physics_shape_handler.gd.uid | 1 + addons/godot_ai/handlers/project_handler.gd | 669 ++++ .../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 | 429 +++ addons/godot_ai/handlers/scene_handler.gd.uid | 1 + addons/godot_ai/handlers/script_handler.gd | 572 ++++ .../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 | 373 +++ addons/godot_ai/handlers/test_handler.gd.uid | 1 + addons/godot_ai/handlers/texture_handler.gd | 198 ++ .../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 | 2849 +++++++++++++++++ addons/godot_ai/mcp_dock.gd.uid | 1 + addons/godot_ai/migration_bridge.gd | 212 +- addons/godot_ai/migration_coordinator.gd | 199 +- addons/godot_ai/migration_fallback.gd | 212 +- .../migration_payload/godot-ai-v3-plugin.zip | Bin 1890142 -> 0 bytes .../godot-ai-v4-plugin.manifest.json | 1 - .../godot-ai-v4-plugin.manifest.sig | Bin 512 -> 0 bytes .../migration_payload/godot-ai-v4-plugin.zip | Bin 1971643 -> 0 bytes addons/godot_ai/plugin.cfg | 4 +- addons/godot_ai/plugin.gd | 1923 ++++++++++- addons/godot_ai/plugin.gd.uid | 2 +- addons/godot_ai/runtime/draw_recipe.gd | 86 + addons/godot_ai/runtime/draw_recipe.gd.uid | 1 + addons/godot_ai/runtime/editor_logger.gd | 139 + addons/godot_ai/runtime/editor_logger.gd.uid | 1 + addons/godot_ai/runtime/game_helper.gd | 1352 ++++++++ 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 | 224 ++ 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 | 302 ++ addons/godot_ai/testing/test_suite.gd.uid | 1 + addons/godot_ai/tool_catalog.gd | 110 + addons/godot_ai/tool_catalog.gd.uid | 1 + addons/godot_ai/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/client_job_owner.gd | 966 ++++++ addons/godot_ai/utils/client_job_owner.gd.uid | 1 + addons/godot_ai/utils/client_mutation_lock.gd | 131 + .../utils/client_mutation_lock.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 | 164 + 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 | 108 + 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 | 8 + .../godot_ai/utils/mcp_adoption_label.gd.uid | 1 + .../utils/mcp_client_refresh_state.gd | 101 + .../utils/mcp_client_refresh_state.gd.uid | 1 + addons/godot_ai/utils/mcp_server_state.gd | 57 + addons/godot_ai/utils/mcp_server_state.gd.uid | 1 + addons/godot_ai/utils/mcp_startup_path.gd | 15 + addons/godot_ai/utils/mcp_startup_path.gd.uid | 1 + addons/godot_ai/utils/path_validator.gd | 216 ++ addons/godot_ai/utils/path_validator.gd.uid | 1 + addons/godot_ai/utils/plugin_reload.gd | 125 + addons/godot_ai/utils/plugin_reload.gd.uid | 1 + addons/godot_ai/utils/resource_io.gd | 286 ++ 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/script_work.gd | 29 + addons/godot_ai/utils/script_work.gd.uid | 1 + addons/godot_ai/utils/server_authority.gd | 171 + addons/godot_ai/utils/server_authority.gd.uid | 1 + addons/godot_ai/utils/server_lifecycle.gd | 1721 ++++++++++ addons/godot_ai/utils/server_lifecycle.gd.uid | 1 + addons/godot_ai/utils/server_version_check.gd | 64 + .../utils/server_version_check.gd.uid | 1 + addons/godot_ai/utils/settings.gd | 135 + addons/godot_ai/utils/settings.gd.uid | 1 + addons/godot_ai/utils/structured_log_ring.gd | 156 + .../godot_ai/utils/structured_log_ring.gd.uid | 1 + .../godot_ai/utils/surfaced_error_tracker.gd | 642 ++++ .../utils/surfaced_error_tracker.gd.uid | 1 + addons/godot_ai/utils/transport_capability.gd | 305 ++ .../utils/transport_capability.gd.uid | 1 + addons/godot_ai/utils/update_manager.gd | 533 +++ addons/godot_ai/utils/update_manager.gd.uid | 1 + addons/godot_ai/utils/update_mixed_state.gd | 141 + .../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/uv_resolution_policy.gd | 97 + .../utils/uv_resolution_policy.gd.uid | 1 + addons/godot_ai/utils/variant_serializer.gd | 102 + .../godot_ai/utils/variant_serializer.gd.uid | 1 + addons/godot_ai/vision_routing.gd | 1090 +++++++ addons/godot_ai/vision_routing.gd.uid | 1 + 284 files changed, 45866 insertions(+), 776 deletions(-) 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/_dsh_strategy.gd create mode 100644 addons/godot_ai/clients/_dsh_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/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/codebuddy.gd create mode 100644 addons/godot_ai/clients/codebuddy.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/deepseek_harness.gd create mode 100644 addons/godot_ai/clients/deepseek_harness.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/pi.gd create mode 100644 addons/godot_ai/clients/pi.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/custom_tools/custom_tool_wrapper.gd create mode 100644 addons/godot_ai/custom_tools/custom_tool_wrapper.gd.uid create mode 100644 addons/godot_ai/custom_tools/mcp_call_context.gd create mode 100644 addons/godot_ai/custom_tools/mcp_call_context.gd.uid create mode 100644 addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd create mode 100644 addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd.uid create mode 100644 addons/godot_ai/custom_tools/mcp_service_locator.gd create mode 100644 addons/godot_ai/custom_tools/mcp_service_locator.gd.uid create mode 100644 addons/godot_ai/custom_tools/mcp_tool_registry.gd create mode 100644 addons/godot_ai/custom_tools/mcp_tool_registry.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/command_handler.gd create mode 100644 addons/godot_ai/handlers/command_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 delete mode 100644 addons/godot_ai/migration_payload/godot-ai-v3-plugin.zip delete mode 100644 addons/godot_ai/migration_payload/godot-ai-v4-plugin.manifest.json delete mode 100644 addons/godot_ai/migration_payload/godot-ai-v4-plugin.manifest.sig delete mode 100644 addons/godot_ai/migration_payload/godot-ai-v4-plugin.zip 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/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/client_job_owner.gd create mode 100644 addons/godot_ai/utils/client_job_owner.gd.uid create mode 100644 addons/godot_ai/utils/client_mutation_lock.gd create mode 100644 addons/godot_ai/utils/client_mutation_lock.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/plugin_reload.gd create mode 100644 addons/godot_ai/utils/plugin_reload.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/script_work.gd create mode 100644 addons/godot_ai/utils/script_work.gd.uid create mode 100644 addons/godot_ai/utils/server_authority.gd create mode 100644 addons/godot_ai/utils/server_authority.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/transport_capability.gd create mode 100644 addons/godot_ai/utils/transport_capability.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/uv_resolution_policy.gd create mode 100644 addons/godot_ai/utils/uv_resolution_policy.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/vision_routing.gd create mode 100644 addons/godot_ai/vision_routing.gd.uid 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..88b524f --- /dev/null +++ b/addons/godot_ai/README.md @@ -0,0 +1,54 @@ +# Godot AI + +Connect AI assistants to a live Godot editor via the [Model Context Protocol](https://modelcontextprotocol.io/introduction) (MCP). + +Godot AI bridges Claude Code, Codex, Antigravity, DeepSeek Harness, and other MCP clients with your editor — inspect scenes, create nodes, modify properties, run tests, search project files, and more, all from a prompt. + +## Quick Start + +1. For a fresh install, verify and extract the signed v4 exact-tree archive; + for v3 migration, follow `docs/v4-migration.md` in the repository +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.7+ within the 4.x line +- [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..bd3980f --- /dev/null +++ b/addons/godot_ai/client_configurator.gd @@ -0,0 +1,2067 @@ +@tool +class_name McpClientConfigurator +extends RefCounted + +## Public facade for the MCP client configuration system. +## +## Per-client logic lives in clients/*.gd (one descriptor per client) and is +## dispatched through clients/_registry.gd. This file: +## - owns server-side identifiers (SERVER_NAME, HTTP/WS port helpers) +## - registers the EditorSettings port overrides and resolves the live +## port/URL via `http_port()` / `ws_port()` / `http_url()` +## - keeps server-launch discovery (.venv → uvx → system godot-ai) +## - exposes string-id wrappers around configure / check_status / remove / +## manual_command so callers don't need to touch the registry directly +## +## To add a new client: drop a file in clients/, then preload it in +## clients/_registry.gd. No edits required here. + +const Client := preload("res://addons/godot_ai/clients/_base.gd") +const ClientRegistry := preload("res://addons/godot_ai/clients/_registry.gd") +const JsonStrategy := preload("res://addons/godot_ai/clients/_json_strategy.gd") +const TomlStrategy := preload("res://addons/godot_ai/clients/_toml_strategy.gd") +const YamlStrategy := preload("res://addons/godot_ai/clients/_yaml_strategy.gd") +const DshStrategy := preload("res://addons/godot_ai/clients/_dsh_strategy.gd") +const CliStrategy := preload("res://addons/godot_ai/clients/_cli_strategy.gd") +const ManualCommand := preload("res://addons/godot_ai/clients/_manual_command.gd") +const CliFinder := preload("res://addons/godot_ai/clients/_cli_finder.gd") +const MutationLock := preload("res://addons/godot_ai/utils/client_mutation_lock.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 UvResolution := preload("res://addons/godot_ai/utils/uv_resolution_policy.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_V4_ENDPOINT_PORTS := "godot_ai/v4_endpoint_ports" +const SETTING_STARTUP_TRACE := "godot_ai/log_startup_timing" +const SETTING_KEEP_SERVER_ON_EXIT := "godot_ai/keep_server_on_exit" +## External cwd the user can set when an MCP client (Pi, code-server, …) reads +## project-tier config files from a directory this editor can't see. The +## merge-tier strategies include it as an extra `_project_candidate_paths` root +## so project overrides there are detected instead of silently shadowed by a +## global-tier write. Codex-review finding F1. +const SETTING_EXTERNAL_CLIENT_CWD := "godot_ai/external_client_cwd" +const _DISCOVERY_TIMEOUT_MS := 3000 +## Codex launches Windows console-subsystem MCP commands in a visible terminal. +## A GUI-subsystem Python keeps the bridge attached to Codex's redirected MCP +## pipes without allocating a console, then starts console launchers such as +## uvx with CREATE_NO_WINDOW. Keep stdin/stdout/stderr explicit: pythonw can use +## its own inherited pipes, but subprocess defaults do not reliably forward +## them to a child when no console exists. +## This string is a wire format written verbatim into user config `args`. +## Whitespace or formatting changes make every existing Windows entry report +## CONFIGURED_MISMATCH, so changing it is a deliberate migration decision, not +## a refactor. The inline `-c` script is required because the uvx and system +## tiers resolve a system interpreter where `godot_ai` is not importable. +const _WINDOWS_STDIO_BOOTSTRAP := ( + "import subprocess,sys; " + + "raise SystemExit(subprocess.call(sys.argv[1:], stdin=sys.stdin, stdout=sys.stdout, " + + "stderr=sys.stderr, creationflags=0x08000000))" +) + + +## Active HTTP port: user override (if in range) or `DEFAULT_HTTP_PORT`. +static func http_port() -> int: + var override := v4_endpoint_ports_status() + if bool(override.present): + return int(override.get("http_port", 0)) + 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: + var override := v4_endpoint_ports_status() + if bool(override.present): + return int(override.get("ws_port", 0)) + return _read_port_setting(SETTING_WS_PORT, DEFAULT_WS_PORT) + + +## An absent override preserves historical settings. A malformed present +## override grants no endpoint; callers can surface its error before startup. +static func v4_endpoint_ports_status() -> Dictionary: + var es := EditorInterface.get_editor_settings() + if es == null or not es.has_setting(SETTING_V4_ENDPOINT_PORTS): + return {"ok": true, "present": false} + var pair: Variant = es.get_setting(SETTING_V4_ENDPOINT_PORTS) + var invalid := {"ok": false, "present": true, + "error": "Invalid %s: set distinct integer http_port and ws_port values between %d and %d, or remove the override." % [SETTING_V4_ENDPOINT_PORTS, MIN_PORT, MAX_PORT]} + if not (pair is Dictionary) or pair.size() != 2: + return invalid + for key in ["http_port", "ws_port"]: + var value: Variant = pair.get(key) + if not (value is int or value is float) or not is_finite(float(value)): + return invalid + if float(value) < MIN_PORT or float(value) > MAX_PORT or float(value) != float(int(value)): + return invalid + if int(pair.http_port) == int(pair.ws_port): + return invalid + return {"ok": true, "present": true, "http_port": int(pair.http_port), "ws_port": int(pair.ws_port)} + + +## Call only after a verified successful upgrade and the activation barrier. +## This selects independent ports, not authority over the legacy backend. +static func prepare_major_upgrade_endpoints(from_version: String, to_version: String) -> Dictionary: + var previous := McpServerVersionCheck.version_tuple(from_version) + var installed := McpServerVersionCheck.version_tuple(to_version) + if previous.is_empty() or installed.is_empty() or int(previous[0]) >= 4 or int(installed[0]) < 4: + return {"ok": true, "changed": false} + if OS.get_thread_caller_id() != OS.get_main_thread_id(): + return {"ok": false, "error": "upgrade endpoint selection requires the main thread"} + var existing := v4_endpoint_ports_status() + if not bool(existing.ok) or bool(existing.present): + existing["changed"] = false + return existing + var es := EditorInterface.get_editor_settings() + if es == null: + return {"ok": false, "error": "EditorSettings is unavailable"} + var legacy_http := _read_port_setting(McpSettings.SETTING_HTTP_PORT, DEFAULT_HTTP_PORT) + var legacy_ws := _read_port_setting(SETTING_WS_PORT, DEFAULT_WS_PORT) + var selected: Array[int] = [] + var candidate := legacy_http + 1 if legacy_http < MAX_PORT else MIN_PORT + candidate = WindowsPortReservation.suggest_non_excluded_port(candidate, MAX_PORT - candidate + 1, MAX_PORT) + var reserved_output := str(WindowsPortReservation._get_cached_excluded_output().get("text", "")) + for _probe in range(SUGGEST_PORT_MAX_PROBES): + candidate = WindowsPortReservation.suggest_non_excluded_port_from_output(reserved_output, candidate, MAX_PORT - candidate + 1, MAX_PORT) + if candidate < MIN_PORT or candidate > MAX_PORT: + break + if candidate not in [legacy_http, legacy_ws] and not selected.has(candidate) and PortResolver.can_bind_local_port(candidate) and not PortResolver.is_port_in_use(candidate): + selected.append(candidate) + if selected.size() == 2: + break + candidate += 1 + if candidate > MAX_PORT: + candidate = MIN_PORT + if selected.size() != 2: + return {"ok": false, "error": "No independent HTTP/WebSocket port pair was available for this major upgrade."} + for port in selected: + if WindowsPortReservation.parse_excluded(reserved_output, port) or not PortResolver.can_bind_local_port(port) or PortResolver.is_port_in_use(port): + return {"ok": false, "error": "The selected upgrade port %d became unavailable; retry endpoint selection." % port} + var pair := {"http_port": selected[0], "ws_port": selected[1]} + es.set_setting(SETTING_V4_ENDPOINT_PORTS, pair) + return {"ok": true, "changed": true, "http_port": selected[0], "ws_port": selected[1]} + + +static func http_url() -> String: + return "http://127.0.0.1:%d/mcp" % http_port() + + +## Read a URL already captured on the main thread without evaluating the +## EditorSettings-backed fallback unless the snapshot is genuinely incomplete. +static func server_url_from(launch_context: Dictionary) -> String: + if launch_context.has("server_url"): + return str(launch_context["server_url"]) + return http_url() + + +static func _read_port_setting(key: String, default_port: int) -> int: + var es := EditorInterface.get_editor_settings() + if es == null or not es.has_setting(key): + return default_port + var value: int = int(es.get_setting(key)) + if value < MIN_PORT or value > MAX_PORT: + return default_port + return value + + +## Register the port overrides in EditorSettings so they show up in the +## editor's Settings > Plugins section with a range hint. Called once from +## `plugin.gd._enter_tree` before `_start_server` so spawn args see the +## configured values. Safe to call repeatedly — `add_property_info` is +## idempotent and `set_initial_value` only seeds the default. +static func ensure_settings_registered() -> void: + var es := EditorInterface.get_editor_settings() + if es == null: + return + _register_port_setting(es, McpSettings.SETTING_HTTP_PORT, DEFAULT_HTTP_PORT) + _register_port_setting(es, SETTING_WS_PORT, DEFAULT_WS_PORT) + _register_bool_setting(es, SETTING_STARTUP_TRACE, false) + _register_bool_setting(es, SETTING_KEEP_SERVER_ON_EXIT, false) + _register_string_setting(es, McpSettings.SETTING_EXCLUDED_DOMAINS, "") + _register_bool_setting(es, McpSettings.SETTING_TELEMETRY_ENABLED, true) + _register_string_setting(es, McpSettings.SETTING_ALLOW_HOSTS, "") + _register_client_scope_setting(es) + _register_string_setting(es, SETTING_EXTERNAL_CLIENT_CWD, "") + + +static func _register_port_setting(es: EditorSettings, key: String, default_port: int) -> void: + if not es.has_setting(key): + es.set_setting(key, default_port) + es.set_initial_value(key, default_port, false) + es.add_property_info({ + "name": key, + "type": TYPE_INT, + "hint": PROPERTY_HINT_RANGE, + "hint_string": "%d,%d,1" % [MIN_PORT, MAX_PORT], + }) + + +## Surface the CLI registration scope as an enum in Settings > Plugins so it is +## discoverable without hand-editing editor_settings-4.tres. Kept at `user` by +## default; `project` writes the entry into /.mcp.json instead. +static func _register_client_scope_setting(es: EditorSettings) -> void: + var key := McpSettings.SETTING_CLIENT_SCOPE + if not es.has_setting(key): + es.set_setting(key, McpSettings.DEFAULT_CLIENT_SCOPE) + es.set_initial_value(key, McpSettings.DEFAULT_CLIENT_SCOPE, false) + es.add_property_info({ + "name": key, + "type": TYPE_STRING, + "hint": PROPERTY_HINT_ENUM, + "hint_string": ",".join(PackedStringArray(McpSettings.CLIENT_SCOPES)), + }) + + +static func _register_bool_setting(es: EditorSettings, key: String, default_value: bool) -> void: + if not es.has_setting(key): + es.set_setting(key, default_value) + es.set_initial_value(key, default_value, false) + es.add_property_info({ + "name": key, + "type": TYPE_BOOL, + }) + + +static func _register_string_setting(es: EditorSettings, key: String, default_value: String) -> void: + ## Same idempotent set-then-hint dance as the bool helper; `PROPERTY_HINT_NONE` + ## leaves the editor's plain text-input widget in place (the cwd string is + ## freeform). The hint-string slot is required by the API even when empty. + if not es.has_setting(key): + es.set_setting(key, default_value) + es.set_initial_value(key, default_value, false) + es.add_property_info({ + "name": key, + "type": TYPE_STRING, + "hint": PROPERTY_HINT_NONE, + "hint_string": "", + }) + + +static func startup_trace_enabled() -> bool: + ## env_lookup + _editor_setting_lookup for the same worker-thread + ## reason as mode_override (#691). + var raw := McpPathTemplate.env_lookup(STARTUP_TRACE_ENV).strip_edges().to_lower() + if raw == "1" or raw == "true" or raw == "yes" or raw == "on": + return true + var setting: Variant = _editor_setting_lookup(SETTING_STARTUP_TRACE) + if setting != null: + return bool(setting) + return false + + +## keep_server_on_exit (#800): when enabled, editor teardown detaches from +## the managed server instead of killing it, and the spawn env opts the +## server out of both self-reap paths (owner-PID watchdog, session-idle +## backstop) — so MCP clients connected over HTTP stay served across editor +## sessions, and the next editor start adopts the survivor. Off by default: +## "server dies with the editor" stays the shipped behavior. Read via +## _editor_setting_lookup for the same worker-thread reason as +## startup_trace_enabled (#691). +static func keep_server_on_exit() -> bool: + var setting: Variant = _editor_setting_lookup(SETTING_KEEP_SERVER_ON_EXIT) + if setting != null: + return bool(setting) + return false + + +## #691: EditorSettings counterpart of McpPathTemplate.env_lookup. The #678 +## startup walk's discovery worker reaches mode_override() (via +## get_server_command) and EditorInterface / EditorSettings are not +## thread-safe objects. Main thread: live read + mutex-guarded snapshot +## refresh. Worker thread: snapshot only — a never-warmed key reads as +## null (unset), never a live EditorInterface call. Warmed alongside the +## env snapshot in warm_env_snapshot(), which runs on the main thread +## before any worker dispatch. +static var _setting_snapshot := {} +static var _setting_snapshot_mutex := Mutex.new() +## The aggregate MCP status command runs on a worker thread. Keep the same +## main-thread-only LaunchContext contract used by dock workers by publishing a +## deep snapshot whenever capture_launch_context() runs on the main thread. +static var _launch_context_snapshot := {} +static var _endpoint_policy_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) + + +## One activation-scoped desired endpoint policy. The composition root resolves +## the WS port, publishes this value once, and derives both managed-server and +## attach plans from it. Returned dictionaries never alias the retained copy. +static func capture_endpoint_policy(resolved_ws_port: int = -1) -> Dictionary: + var selected_ws_port := ws_port() if resolved_ws_port <= 0 else resolved_ws_port + return { + "http_port": http_port(), + "ws_port": selected_ws_port, + "excluded_domains": excluded_domains(), + "allow_hosts": allow_hosts(), + "keep_alive": keep_server_on_exit(), + "telemetry_enabled": McpSettings.telemetry_enabled(), + } + + +## Snapshot every value needed to render or verify an attach launch command. +## Endpoint fields come only from the root-published activation policy; later +## main-thread refreshes update discovery/project values without re-reading a +## divergent desired endpoint. Workers receive the deep snapshot only (#691). +static func capture_launch_context(endpoint_policy: Dictionary = {}) -> 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 policy := endpoint_policy.duplicate(true) + if policy.is_empty(): + _launch_context_snapshot_mutex.lock() + policy = _endpoint_policy_snapshot.duplicate(true) + _launch_context_snapshot_mutex.unlock() + if policy.is_empty(): + ## Standalone/test callers without a composition root still get a + ## complete context. A real plugin activation always publishes first. + policy = capture_endpoint_policy() + var captured_http_port := int(policy.get("http_port", DEFAULT_HTTP_PORT)) + var context := { + "http_port": captured_http_port, + "ws_port": int(policy.get("ws_port", DEFAULT_WS_PORT)), + "excluded_domains": str(policy.get("excluded_domains", "")), + "plugin_version": get_plugin_version(), + "allow_dev_venv": mode_override() != "user", + "platform": OS.get_name(), + "server_url": "http://127.0.0.1:%d/mcp" % captured_http_port, + "project_roots": capture_project_roots(), + ## The opt-out must ride the attach argv: the client spawns the bridge + ## (and the bridge its backend) with no editor in the loop, so the + ## env-injection path in server_lifecycle.gd never runs for them. + "telemetry_enabled": bool(policy.get("telemetry_enabled", true)), + } + _launch_context_snapshot_mutex.lock() + _endpoint_policy_snapshot = policy.duplicate(true) + _launch_context_snapshot = context.duplicate(true) + _launch_context_snapshot_mutex.unlock() + return context + + +## Validate and persist a Dock settings intent at the existing configurator +## boundary. Validation completes before the first write, so a bad mixed delta +## cannot partially change desired launch policy. +static func apply_endpoint_settings(changes: Dictionary) -> Dictionary: + if OS.get_thread_caller_id() != OS.get_main_thread_id(): + return {"ok": false, "error": "settings writes require the main thread"} + var normalized := {} + for key in changes: + var value: Variant = changes[key] + match str(key): + "http_port": + if not (value is int or value is float): + return {"ok": false, "error": "HTTP port must be an integer"} + var port := int(value) + if float(value) != float(port) or port < MIN_PORT or port > MAX_PORT: + return {"ok": false, "error": "HTTP port is outside the supported range"} + normalized[McpSettings.SETTING_HTTP_PORT] = port + "ws_port": + if not (value is int or value is float): + return {"ok": false, "error": "WebSocket port must be an integer"} + var ws_port_value := int(value) + if ( + float(value) != float(ws_port_value) + or ws_port_value < MIN_PORT + or ws_port_value > MAX_PORT + ): + return {"ok": false, "error": "WebSocket port is outside the supported range"} + normalized[SETTING_WS_PORT] = ws_port_value + "excluded_domains": + if not (value is String): + return {"ok": false, "error": "excluded domains must be text"} + normalized[McpSettings.SETTING_EXCLUDED_DOMAINS] = _canonicalize_excluded_domains(value) + "telemetry_enabled": + if not (value is bool): + return {"ok": false, "error": "telemetry preference must be boolean"} + normalized[McpSettings.SETTING_TELEMETRY_ENABLED] = value + "allow_hosts": + if not (value is String): + return {"ok": false, "error": "allow-host policy must be text"} + var allowed := McpAllowHosts.normalize(value) + if not McpAllowHosts.invalid_tokens(allowed).is_empty(): + return {"ok": false, "error": "allow-host policy is invalid"} + normalized[McpSettings.SETTING_ALLOW_HOSTS] = allowed + _: + return {"ok": false, "error": "unknown endpoint setting: %s" % key} + var next_http := int(normalized.get(McpSettings.SETTING_HTTP_PORT, http_port())) + var next_ws := int(normalized.get(SETTING_WS_PORT, ws_port())) + var override := v4_endpoint_ports_status() + var ports_changed := normalized.has(McpSettings.SETTING_HTTP_PORT) or normalized.has(SETTING_WS_PORT) + if bool(override.present) and ports_changed: + if next_http < MIN_PORT or next_http > MAX_PORT or next_ws < MIN_PORT or next_ws > MAX_PORT: + return {"ok": false, "error": "A complete valid HTTP/WebSocket pair is required to repair the v4 override."} + normalized.erase(McpSettings.SETTING_HTTP_PORT) + normalized.erase(SETTING_WS_PORT) + normalized[SETTING_V4_ENDPOINT_PORTS] = {"http_port": next_http, "ws_port": next_ws} + if next_http == next_ws and (ports_changed or not bool(override.present)): + return {"ok": false, "error": "HTTP and WebSocket ports must differ"} + var es := EditorInterface.get_editor_settings() + if es == null: + return {"ok": false, "error": "EditorSettings is unavailable"} + for key in normalized: + es.set_setting(key, normalized[key]) + return {"ok": true, "changes": normalized.duplicate(true)} + + +## Resolve roots used by cwd-relative client project config tiers while the +## engine singletons are safe to access. Workers receive this immutable snapshot. +static func capture_project_roots() -> PackedStringArray: + var current_access := DirAccess.open(".") + var current_root := "" if current_access == null else current_access.get_current_dir().simplify_path() + var project_root := ProjectSettings.globalize_path("res://").simplify_path() + # Codex F1: when the user sets `godot_ai/external_client_cwd` we probe that + # directory in addition to the two guessed roots. The setter / setter-snapshot + # pathway is the same as the existing EditorSettings (warm_env_snapshot + # pre-warms this key so worker-thread callers see the snapshot, not the live + # EditorInterface). Empty string is filtered out by `_canonicalize_roots_for_test`. + var external_cwd := str(_editor_setting_lookup(SETTING_EXTERNAL_CLIENT_CWD)) + return _canonicalize_roots_for_test(PackedStringArray([current_root, project_root, external_cwd])) + + +## Canonicalize a list of root paths to one filesystem representation per +## directory and deduplicate. `ProjectSettings.globalize_path` maps any +## `res://` / `user://` form to the absolute path and is idempotent on an +## already-absolute path, so a `res://`-form candidate and its absolute twin +## collapse to the same entry. Without this pass, `_project_candidate_paths` +## would probe `/.pi/mcp.json` against both representations of the same +## directory and `manual_target_details` would mis-report a single project +## override as multiple tiers (codex-review finding F4). Public-ish seam +## (named `_..._for_test`) so the clients suite can pin the behaviour without +## having to mock `DirAccess.open(".")`. +static func _canonicalize_roots_for_test(raws: PackedStringArray) -> PackedStringArray: + var seen := {} + var roots := PackedStringArray() + for raw in raws: + var s := str(raw).strip_edges() + if s.is_empty(): + continue + var canon := ProjectSettings.globalize_path(s).simplify_path() + if canon.is_empty() or seen.has(canon): + continue + seen[canon] = true + roots.append(canon) + return roots + + +static func _project_roots_from_context(context: Dictionary) -> PackedStringArray: + var roots = context.get("project_roots", PackedStringArray()) + return roots if roots is PackedStringArray else PackedStringArray() + + +## Read the `godot_ai/allow_remote_hosts` EditorSetting as a canonicalized +## comma-separated list of CIDRs / bare IPs (#507). Returns "" when the +## setting is missing or empty — callers skip appending `--allow-host` in +## that case so spawns stay byte-for-byte identical to the loopback-only +## default (and compatible with pre-#421 servers). Mirrors +## `excluded_domains()` above. +static func allow_hosts() -> String: + var es := EditorInterface.get_editor_settings() + if es == null or not es.has_setting(McpSettings.SETTING_ALLOW_HOSTS): + return "" + return McpAllowHosts.normalize(str(es.get_setting(McpSettings.SETTING_ALLOW_HOSTS))) + + +## Suggest a port the caller can actually switch to. Walks +## `candidate`..`candidate+span-1` and returns the first port that is both +## (a) NOT inside a Windows winnat reservation range (Hyper-V / WSL2 / Docker +## grab these; bind fails with WinError 10013 and netstat shows nothing) and +## (b) actually bindable right now on 127.0.0.1. The bind probe is what makes +## "free" honest on macOS/Linux, where the reservation table is empty but the +## next port up may still be occupied — the same suggestion feeds the dock +## crash body, the port-picker spinbox, and the non-recoverable INCOMPATIBLE +## log line. Falls back to the clamped candidate if nothing in the window +## clears both checks (caller surfaces it as a best-effort hint; the user can +## retry or pick another). Best-effort by nature: a TOCTOU window remains +## between the probe and the caller actually binding the port. The bind probe +## is bounded to `SUGGEST_PORT_MAX_PROBES` attempts so this cold path can't +## stall on a pathological run of occupied ports. +static func suggest_free_port(start: int, span: int = 2048) -> int: + var candidate := clampi(start, MIN_PORT, MAX_PORT - span + 1) + var limit := mini(candidate + span - 1, MAX_PORT) + var p := candidate + var probes := 0 + while p <= limit and probes < SUGGEST_PORT_MAX_PROBES: + ## Jump past a whole Windows-reserved range in one step (no-op on + ## POSIX: returns `p` unchanged), so we don't probe port-by-port + ## through the large adjacent ranges those services reserve. The + ## jump itself runs no bind probes, so it doesn't count against the cap. + var not_reserved := WindowsPortReservation.suggest_non_excluded_port(p, limit - p + 1, MAX_PORT) + if not_reserved < p or not_reserved > limit: + break + p = not_reserved + probes += 1 + if PortResolver.can_bind_local_port(p): + return p + p += 1 + return candidate + + +# --- Client operations (string id) --------------------------------------- + +static func client_ids() -> PackedStringArray: + return ClientRegistry.ids() + + +static func has_client(id: String) -> bool: + return ClientRegistry.has_id(id) + + +static func client_display_name(id: String) -> String: + var c := ClientRegistry.get_by_id(id) + return c.display_name if c != null else id + + +## Pass an explicit `url` when calling from a worker thread: `http_url()` +## reads `EditorInterface.get_editor_settings()`, which is main-thread-only. +## Empty defaults to the live server URL — appropriate for MCP-tool callers +## that always run on main. +static func configure(id: String, url: String = "", launch_context: Dictionary = {}) -> Dictionary: + if ClientRegistry.stale_session_detected(): + return {"status": "error", "message": ClientRegistry.RESTART_TO_FINISH_UPDATE} + var client := ClientRegistry.get_by_id(id) + if client == null: + return {"status": "error", "message": "Unknown client: %s" % id} + if not client.automatic_config_edits: + return _manual_edit_result(client, "configure") + 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 claim := MutationLock.acquire(client.id, "configure") + if not bool(claim.get("ok", false)): + return { + "status": "error", + "message": str(claim.get("error", MutationLock.recovery_message())), + } + var result := _dispatch_configure(client, url, launch, context) + ## Trust-but-verify: a strategy may report ok and have actually written the + ## file, yet the entry is missing/stale on the read-back path — most often + ## because the user's installed client is reading a different file than + ## `path_template` resolves to (issue #201). Re-read the live state and + ## surface a clear error before the dock reports a bogus green dot. + var verified := _verify_post_state( + client, result, Client.Status.CONFIGURED, url, "configure", launch, context + ) + return _finish_client_mutation(claim, verified) + + +static func check_status(id: String) -> Client.Status: + if ClientRegistry.stale_session_detected(): + return Client.Status.ERROR + var client := ClientRegistry.get_by_id(id) + if client == null: + return Client.Status.NOT_CONFIGURED + var context := capture_launch_context() if client.command_shape != Client.CommandShape.NONE else {} + return _dispatch_check_status(client, http_url(), context) + + +static func check_status_for_url_with_cli_path( + id: String, url: String, cli_path: String, launch_context: Dictionary = {} +) -> Client.Status: + return check_status_details_for_url_with_cli_path(id, url, cli_path, launch_context).get("status", Client.Status.NOT_CONFIGURED) + + +## True when 's stored entry verifies EXACTLY against the launch this +## editor would have rendered at `from_version` — same ports, exclusions, +## telemetry flag, command shape; nothing differs but the version pin. This +## is the post-update auto-repin gate: only such entries are provably "what +## Configure wrote before the update", so rewriting them to the current +## version restores the user's own prior intent. Anything else — entries +## pointing at another editor's ports (the smoke-fixture blast radius that +## motivated this gate), hand-edits, changed settings — stays untouched for +## the drift banner's human click. An empty `from_version` (marker written +## by a pre-gate runner) fails closed. +## +## The version-substituted context is safe to resolve and cache: +## `plugin_version` is part of both the attach-launch resolution +## (`_resolve_attach_launch_uncached`) and its cache key. +static func entry_drift_is_version_pin_only( + id: String, from_version: String, launch_context: Dictionary = {} +) -> bool: + var pinned_from := from_version.strip_edges() + if pinned_from.is_empty(): + return false + ## Never shell out from this gate — it runs on the MAIN thread from the + ## dock's sweep-completion callback (#890 review P2). The cli-descriptor + ## probe paths run a subprocess with a multi-second timeout (`claude mcp + ## get` is 6s), which would freeze the editor; those clients fail closed + ## to the drift banner's click flow, whose fan-out already runs on + ## worker threads. Every path left after these guards is a bounded + ## config-file read plus cached launch discovery (worst case one CLI + ## lookup on a cold CliFinder cache — the same bounded one-shot the + ## dock's `_reprobe_uv_if_negative` accepts on this thread). + var client := ClientRegistry.get_by_id(id) + if client == null: + return false + if client.config_type == "cli": + if client.command_shape == Client.CommandShape.NONE: + return false + if not client.has_json_fallback(): + return false + if _scope_diverges_from_json_fallback(client): + return false + var context := launch_context if not launch_context.is_empty() else capture_launch_context() + var old_context := context.duplicate(true) + old_context["plugin_version"] = pinned_from + ## Same URL under both versions: the URL carries the port, not the + ## version, so URL-mode entries can never be version-pin-only drift and + ## correctly fail this check. + var url := str(context.get("server_url", http_url())) + var status := check_status_for_url_with_cli_path(id, url, "", old_context) + return status == Client.Status.CONFIGURED + + +## Detailed variant used by the dock refresh worker. Returns +## `{"status": Status, "error_msg": String}` so the worker can surface +## "probe timed out" on the row instead of silently flipping it to +## NOT_CONFIGURED. Callers that only need the status can use the simpler +## helper above. +static func check_status_details_for_url_with_cli_path( + id: String, + url: String, + cli_path: String, + launch_context: Dictionary = {}, + resolved_launch: Dictionary = {}, +) -> Dictionary: + ## One comprehensible line per row beats a wall of per-field type errors — + ## the dock keeps painting, every row names the same repair (restart). + if ClientRegistry.stale_session_detected(): + return {"status": Client.Status.ERROR, "error_msg": ClientRegistry.RESTART_TO_FINISH_UPDATE} + var client := ClientRegistry.get_by_id(id) + if client == null: + return {"status": Client.Status.NOT_CONFIGURED, "error_msg": ""} + # A cli client with no resolved binary normally reads as NOT_CONFIGURED. + # Skip that shortcut when the client has a JSON fallback (#463): the + # dispatch below reads its config file directly so the status dot reflects + # a fallback-configured entry instead of always showing red. + if client.config_type == "cli" and cli_path.is_empty() and not client.has_json_fallback(): + return {"status": Client.Status.NOT_CONFIGURED, "error_msg": ""} + var path_error := _config_path_resolution_error(client) + if not path_error.is_empty(): + return {"status": Client.Status.ERROR, "error_msg": path_error} + if client.command_shape != Client.CommandShape.NONE and launch_context.is_empty(): + return { + "status": Client.Status.ERROR, + "error_msg": "Missing launch-context snapshot; retry the status refresh.", + } + return _dispatch_check_status_with_cli_path_details( + client, url, cli_path, launch_context, resolved_launch + ) + + +## #691: main-thread pre-warm of McpPathTemplate's env snapshot, covering +## the base vars plus every descriptor-declared config-file/config-home env +## (OPENCODE_CONFIG, CLAUDE_CONFIG_DIR, CODEX_HOME, …), so worker-thread config-path +## resolution never calls OS.get_environment concurrently with the spawn +## window's setenv/unsetenv. Also warms the EditorSettings snapshot for +## the mode/trace overrides so worker-thread mode_override() / +## startup_trace_enabled() never touch EditorInterface. Idempotent; +## called from plugin _enter_tree and before each dock worker dispatch. +static func warm_env_snapshot(endpoint_policy: Dictionary = {}) -> 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) + UvResolution.warm_environment() + _editor_setting_lookup(MODE_OVERRIDE_SETTING) + _editor_setting_lookup(SETTING_STARTUP_TRACE) + _editor_setting_lookup(SETTING_KEEP_SERVER_ON_EXIT) + _editor_setting_lookup(SETTING_EXTERNAL_CLIENT_CWD) + McpSettings.warm_client_scope() + # Publish the complete launch context while EditorInterface access is safe; + # worker callers of capture_launch_context() read this snapshot only. + capture_launch_context(endpoint_policy) + + +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, + "automatic_config_edits": client.automatic_config_edits, + } + + +## Pure response projection shared by the one client-work owner and tests. +## Missing rows are explicit errors, never silently reported as unconfigured. +static func client_status_response(results: Dictionary) -> Dictionary: + var clients := [] + for client_id in client_ids(): + var details: Dictionary = results.get(client_id, { + "status": Client.Status.ERROR, + "installed": false, + "error_msg": "Client status probe returned no result.", + }) + clients.append(_client_status_sweep_entry( + client_id, details, bool(details.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} + if not client.automatic_config_edits: + return _manual_edit_result(client, "remove") + 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 claim := MutationLock.acquire(client.id, "remove") + if not bool(claim.get("ok", false)): + return { + "status": "error", + "message": str(claim.get("error", MutationLock.recovery_message())), + } + var result := _dispatch_remove(client, context) + var verified := _verify_post_state( + client, result, Client.Status.NOT_CONFIGURED, url, "remove", launch, context + ) + return _finish_client_mutation(claim, verified) + + +## 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 _manual_edit_result(client: Client, action: String) -> Dictionary: + return { + "status": "error", + "message": ( + "%s settings require a manual edit; godot-ai will not %s this file automatically. " + + "Use the configuration shown below." + ) % [client.display_name, action], + } + + +## One global claim covers the mutation and its read-back proof for every +## automatic strategy. Ambiguous CLI termination deliberately strands it; +## every other outcome releases the exact token or fails closed. +static func _finish_client_mutation(claim: Dictionary, result: Dictionary) -> Dictionary: + if bool(result.get("termination_failed", false)): + return result + if MutationLock.release(claim): + return result + return { + "status": "error", + "message": MutationLock.recovery_message(), + "mutation_lock_release_failed": true, + } + +static func _dispatch_configure( + client: Client, url: String, launch: Dictionary = {}, launch_context: Dictionary = {} +) -> Dictionary: + launch = launch_for_client(client, launch) + match client.config_type: + "json": + return JsonStrategy.configure(client, SERVER_NAME, url, launch, _project_roots_from_context(launch_context)) + "toml": + return TomlStrategy.configure(client, SERVER_NAME, url, launch) + "yaml": + return YamlStrategy.configure(client, SERVER_NAME, url, launch) + "dsh": + return DshStrategy.configure(client, SERVER_NAME, url, launch) + "cli": + # #463: fall back to writing the config file directly when the CLI + # binary isn't on PATH (Claude Code as a VS Code/Cursor extension). + if client.has_json_fallback() and CliStrategy.resolve_cli_path(client).is_empty(): + return _note_unhonoured_scope( + client, JsonStrategy.configure(client, SERVER_NAME, url, launch, _project_roots_from_context(launch_context)) + ) + return CliStrategy.configure(client, SERVER_NAME, url, launch) + return {"status": "error", "message": "Unknown config_type for %s: %s" % [client.id, client.config_type]} + + +static func _dispatch_remove(client: Client, launch_context: Dictionary = {}) -> Dictionary: + match client.config_type: + "json": + return JsonStrategy.remove(client, SERVER_NAME, _project_roots_from_context(launch_context)) + "toml": + return TomlStrategy.remove(client, SERVER_NAME) + "yaml": + return YamlStrategy.remove(client, SERVER_NAME) + "dsh": + return DshStrategy.remove(client, SERVER_NAME) + "cli": + # #463: mirror the configure fallback so Remove also works without + # the CLI binary — otherwise a fallback-written entry is unremovable. + if client.has_json_fallback() and CliStrategy.resolve_cli_path(client).is_empty(): + return JsonStrategy.remove(client, SERVER_NAME, _project_roots_from_context(launch_context)) + return CliStrategy.remove(client, SERVER_NAME) + return {"status": "error", "message": "Unknown config_type for %s: %s" % [client.id, client.config_type]} + + +static func _dispatch_check_status( + client: Client, url: String, launch_context: Dictionary = {} +) -> Client.Status: + return _dispatch_check_status_with_cli_path(client, url, "", launch_context) + + +static func _dispatch_check_status_with_cli_path( + client: Client, url: String, cli_path: String, launch_context: Dictionary = {} +) -> Client.Status: + return _dispatch_check_status_with_cli_path_details(client, url, cli_path, launch_context).get("status", Client.Status.NOT_CONFIGURED) + + +static func _dispatch_check_status_with_cli_path_details( + client: Client, + url: String, + cli_path: String, + launch_context: Dictionary = {}, + resolved_launch: Dictionary = {}, +) -> Dictionary: + match client.config_type: + "json": + var launch := {} + if client.command_shape != Client.CommandShape.NONE: + launch = _resolved_or_discovered_launch(client, resolved_launch, launch_context) + return JsonStrategy.check_status_details(client, SERVER_NAME, url, launch, _project_roots_from_context(launch_context)) + "toml": + var launch := {} + if client.command_shape != Client.CommandShape.NONE: + launch = _resolved_or_discovered_launch(client, resolved_launch, launch_context) + return TomlStrategy.check_status_details(client, SERVER_NAME, url, launch) + "yaml": + var yaml_launch := {} + if client.command_shape != Client.CommandShape.NONE: + yaml_launch = _resolved_or_discovered_launch(client, resolved_launch, launch_context) + return YamlStrategy.check_status_details(client, SERVER_NAME, url, yaml_launch) + "dsh": + var dsh_launch := {} + if client.command_shape != Client.CommandShape.NONE: + dsh_launch = _resolved_or_discovered_launch(client, resolved_launch, launch_context) + return DshStrategy.check_status_details(client, SERVER_NAME, url, dsh_launch) + "cli": + # Command-shape CLI clients register through their CLI, but the entry + # lands in the same file the JSON fallback reads (`claude mcp add + # --scope user` writes mcpServers in ~/.claude.json). Reading that + # file gives exact launch-drift detection — a changed port, version + # pin, or exclusion list — which scanning `mcp list` stdout cannot, + # so it is preferred even when the CLI binary resolves. + # #872: ...but only while the selected scope is still the one + # `path_template` points at — see _scope_diverges_from_json_fallback. + if ( + client.command_shape != Client.CommandShape.NONE + and client.has_json_fallback() + and not _scope_diverges_from_json_fallback(client) + ): + var command_launch := _resolved_or_discovered_launch(client, resolved_launch, launch_context) + return JsonStrategy.check_status_details(client, SERVER_NAME, url, command_launch, _project_roots_from_context(launch_context)) + var resolved_cli := cli_path if not cli_path.is_empty() else CliStrategy.resolve_cli_path(client) + # #463: with no CLI binary, read the JSON fallback config so a + # fallback-configured entry reports CONFIGURED instead of red. + if resolved_cli.is_empty() and client.has_json_fallback(): + var fallback_launch := {} + if client.command_shape != Client.CommandShape.NONE: + fallback_launch = _resolved_or_discovered_launch(client, resolved_launch, launch_context) + return JsonStrategy.check_status_details(client, SERVER_NAME, url, fallback_launch, _project_roots_from_context(launch_context)) + var cli_launch := {} + if client.command_shape != Client.CommandShape.NONE: + cli_launch = _resolved_or_discovered_launch(client, resolved_launch, launch_context) + # #872: at a scope `path_template` can't see, a plain listing probe + # cannot tell our entry from a leftover one in another scope. When + # the descriptor offers a scope-aware probe, use it — same single + # subprocess, but it reports which scope actually resolved. + if ( + not client.cli_scope_status_template.is_empty() + and CliStrategy.uses_scope_token(client) + ): + return CliStrategy.check_scope_status_details( + client, SERVER_NAME, url, resolved_cli, cli_launch, McpSettings.client_scope() + ) + return CliStrategy.check_status_details(client, SERVER_NAME, url, resolved_cli, cli_launch) + return {"status": Client.Status.NOT_CONFIGURED, "error_msg": ""} + + +## #872: the #463 JSON fallback writes the user-scope file whatever +## `godot_ai/mcp_client_scope` says — that file IS `path_template`, and +## `clients/_path_template.gd` has no project-relative token to aim it +## elsewhere. Returning a bare "configured" would hide that the requested +## scope was not honoured, so say so in the message. +## +## Deliberately a message and not a status. `_verify_post_state` compares the +## post-write status against CONFIGURED and replaces the ok with an error on +## any mismatch (pinned by test_verify_post_state_treats_drift_as_failure_ +## after_configure), so reporting this write as NOT_CONFIGURED or MISMATCH +## would turn a *successful* configure into a user-facing failure — the same +## false-error class #872's blocker was about, just moved onto the fallback +## path. The entry is genuinely written and genuinely works; only its scope +## differs from the request, and that is a caveat, not a failure. +static func _note_unhonoured_scope(client: Client, result: Dictionary) -> Dictionary: + if result.get("status") != "ok": + return result + if not CliStrategy.uses_scope_token(client): + return result + var scope := McpSettings.client_scope() + if scope == McpSettings.DEFAULT_CLIENT_SCOPE: + return result + var noted := result.duplicate(true) + noted["message"] = ( + "%s — wrote %s scope, not %s: the %s CLI wasn't found, and the file fallback can only write %s" + % [ + str(result.get("message", "")), + McpSettings.DEFAULT_CLIENT_SCOPE, + scope, + client.display_name, + client.resolved_config_path(), + ] + ) + return noted + + +## True when this client's CLI writes its entry somewhere `path_template` +## cannot see, so reading that file would describe the wrong thing (#872). +## +## `claude mcp add --scope project` writes /.mcp.json and `--scope local` +## writes a per-project block inside ~/.claude.json — neither is the top-level +## `mcpServers` map `path_template` + `server_key_path` resolve to. Reading it +## anyway makes `_verify_post_state` turn every successful project-scope +## Configure into "configure ok but verification still reads Not configured", +## and pins the dock row red (or green, describing a stale user-scope entry). +## +## Diverging sends status down the CLI probe instead: `mcp list` is +## scope-agnostic and inherits the same cwd the register ran in, so read-back +## and write agree by construction. The cost is losing exact launch-drift +## detection — a stdout scan sees the command, not the full argv — which is the +## documented trade for a status that is merely coarse instead of wrong. +## +## Deliberately says nothing about whether the CLI resolves (#879). An earlier +## version walked PATH here and returned false for an unresolvable binary, on +## the reasoning that configure would then have taken the #463 JSON fallback — +## which writes user scope whatever the setting says — so the file was the +## right thing to read again. That extra clause could not change any outcome: +## this is only consulted from the one call site below, already guarded on +## `has_json_fallback()`, and when the CLI does not resolve the very next +## branch (`resolved_cli.is_empty() and client.has_json_fallback()`) takes the +## fallback read anyway. All it bought was a second full `McpCliFinder.find` +## sweep per status check, on top of the one that immediately follows. +static func _scope_diverges_from_json_fallback(client: Client) -> bool: + if not CliStrategy.uses_scope_token(client): + return false + return McpSettings.client_scope() != McpSettings.DEFAULT_CLIENT_SCOPE + + +static func _resolved_or_discovered_launch( + client: Client, resolved_launch: Dictionary, launch_context: Dictionary +) -> Dictionary: + var launch := ( + resolved_launch + if not resolved_launch.is_empty() + else resolve_attach_launch(launch_context) + ) + return launch_for_client(client, launch) + + +## After a configure/remove returns ok, re-read the live status. If it doesn't +## match `expected`, replace the result with an error that names the actual +## status and the resolved config path so the user can self-diagnose. The +## strategy's own error path is left untouched — already actionable. +static func _verify_post_state( + client: Client, + result: Dictionary, + expected: Client.Status, + url: String, + action: String, + resolved_launch: Dictionary = {}, + launch_context: Dictionary = {}, +) -> Dictionary: + if result.get("status") != "ok": + return result + var details := _dispatch_check_status_with_cli_path_details( + client, url, "", launch_context, resolved_launch + ) + var actual := details.get("status", Client.Status.NOT_CONFIGURED) + if actual == expected: + return result + var path_hint := _post_state_path_hint(client, str(details.get("resolved_scope", ""))) + return { + "status": "error", + "message": "%s reported %s ok but verification still reads %s (expected %s).%s" % [ + client.display_name, action, + Client.status_label(actual), Client.status_label(expected), + path_hint, + ], + } + + +## Where to send the user when verification finds an entry that should not be +## there. `resolved_config_path()` is `path_template` — right only for the +## default user scope. Naming ~/.claude.json for a project-scope survivor sends +## them to a file with nothing in it, which is worse than naming no file at all +## (#879). The scope probe supplies `resolved_scope`; `path_template` has no +## project-relative token (see `_note_unhonoured_scope`), so the project case is +## described rather than resolved to a path. +static func _post_state_path_hint(client: Client, resolved_scope: String) -> String: + match resolved_scope: + "project": + return ( + " The surviving entry is project-scoped: inspect the .mcp.json in the" + + " directory the editor was launched from and remove the godot-ai entry" + + " by hand if needed." + ) + "local": + var local_path := client.resolved_config_path() + if local_path.is_empty(): + return "" + return ( + " The surviving entry is local-scoped: inspect the block for the" + + " directory the editor was launched from in %s and remove the" + + " godot-ai entry by hand if needed." + ) % local_path + _: + var path := client.resolved_config_path() + if path.is_empty(): + return "" + return " Inspect %s and remove the godot-ai entry by hand if needed." % path + + +## #877: Configure's first act is the all-scope pre-cleanup — it deletes the +## `godot-ai` entry from every scope the descriptor can write to, including a +## `.mcp.json` resolved against the CLI's working directory, which need not be +## this project's folder. The manual-command panel that spells those removes +## out is only shown when Configure FAILS (`mcp_dock.gd`), so on the success +## path — the one where the sweep actually ran — this note is its only +## disclosure. Empty for descriptors without a scope token: their single +## implicit pass removes exactly the entry the register is about to rewrite, +## which needs no warning (the same rule `_sweep_caveat` applies). +## +## "attempted", not "cleared": `CliStrategy.configure` discards every +## pre-cleanup result, and `mcp remove` exits non-zero for an absent entry as +## well as for a real failure, so a timed-out or failed remove leaves the scope +## untouched while the register still returns ok. The note names what ran, +## not what it can prove. +static func configure_sweep_note(id: String) -> String: + var client := ClientRegistry.get_by_id(id) + if client == null or client.config_type != "cli": + return "" + if client.cli_unregister_template.is_empty() or not CliStrategy.uses_scope_token(client): + return "" + return "attempted to clear %s from %s" % [ + SERVER_NAME, ", ".join(CliStrategy.cleanup_scopes(client)), + ] + + +static func manual_command(id: String) -> String: + var client := ClientRegistry.get_by_id(id) + if client == null: + return "" + var path_resolution := client.resolved_config_path_details() + var path_error := str(path_resolution.get("error", "")) + if not path_error.is_empty(): + return "Config path unavailable: %s" % path_error + var context := capture_launch_context() if client.command_shape != Client.CommandShape.NONE else {} + var launch := ( + launch_for_client(client, resolve_attach_launch(context)) + if client.command_shape != Client.CommandShape.NONE + else {} + ) + var cmd := ManualCommand.build( + client, + SERVER_NAME, + server_url_from(context), + str(path_resolution.get("path", "")), + launch, + _project_roots_from_context(context), + ) + if cmd.is_empty(): + return cmd + ## #507: when the allow-host opt-in names a non-loopback range, also + ## surface the LAN URL so the user can copy-paste the right address into + ## a remote agent. Informational only — configure/remove still WRITE the + ## loopback URL above; nothing about the config-file contract changes. + var note := McpAllowHosts.lan_url_note(allow_hosts(), IP.get_local_addresses(), http_port()) + if not note.is_empty(): + cmd += "\n\n" + note + return cmd + + +static func config_path(id: String) -> String: + var client := ClientRegistry.get_by_id(id) + return client.resolved_config_path() if client != null else "" + + +## Resolve the highest-precedence config tier that actually contains the server +## entry. For Pi-style merge clients, returns the tier path the entry lives in +## (e.g. `~/.pi/agent/.mcp.json`) instead of the lowest-tier `path_template` +## `config_path()` returns. Falls back to `path_template` when no entry is +## found anywhere. Empty `launch_context` is allowed; command-shape clients +## synthesize one via `capture_launch_context()` so dock row construction can +## call this without first capturing launch state. +static func effective_config_path(id: String, launch_context: Dictionary = {}) -> String: + var client := ClientRegistry.get_by_id(id) + if client == null: + return "" + var resolution := client.resolved_config_path_details() + var fallback := str(resolution.get("path", "")) + var context := launch_context + if context.is_empty() and client.command_shape != Client.CommandShape.NONE: + context = capture_launch_context() + var roots := _project_roots_from_context(context) + var target := JsonStrategy.manual_target_details(client, SERVER_NAME, fallback, roots) + if bool(target.get("ok", false)): + return str(target.get("path", fallback)) + return fallback + + +## Path that `_check_status_merged` would consider authoritative for the +## server entry, or `path_template` when no entry is found anywhere. +## For Pi-style merge clients this is the latest project tier when one +## exists (matching the F2 last-wins status logic), falling back to the +## latest global tier, then to `path_template`. +## +## The dock uses this for Open/Reveal so the file the user inspects is +## the same file that drives the status check — `effective_config_path` +## fails closed (returns `path_template`) when there are multiple project +## tiers, which historically sent users to the wrong file (codex round 3, +## F-3-4). Splitting this from `effective_config_path` keeps the manual +## instructions fail-closed while letting the dock follow the +## authoritative tier. +static func effective_authoritative_path(id: String, launch_context: Dictionary = {}) -> String: + var client := ClientRegistry.get_by_id(id) + if client == null: + return "" + var resolution := client.resolved_config_path_details() + var fallback := str(resolution.get("path", "")) + var context := launch_context + if context.is_empty() and client.command_shape != Client.CommandShape.NONE: + context = capture_launch_context() + var roots := _project_roots_from_context(context) + var authoritative: String = JsonStrategy.authoritative_tier_path(client, SERVER_NAME, roots) + if not authoritative.is_empty(): + return authoritative + return fallback + + +static func is_installed(id: String) -> bool: + var client := ClientRegistry.get_by_id(id) + return client != null and client.is_installed() + + +# --- Server command discovery -------------------------------------------- +# +# Three-tier resolution: +# 1. .venv python — dev checkout, source code +# 2. uvx — user install, published package from PyPI +# 3. godot-ai CLI — system-wide pip/pipx/uv install + +static func get_plugin_version() -> String: + var cfg := ConfigFile.new() + if cfg.load("res://addons/godot_ai/plugin.cfg") == OK: + return cfg.get_value("plugin", "version", "0.0.1") + return "0.0.1" + + +## Strip PEP 440 local build metadata for PyPI pins: `3.0.2+local.1` → `3.0.2`. +## Pre-release segments (`3.1.0-rc1`) are preserved — only `+…` is removed. +static func _pypi_pin_version(version: String) -> String: + var v := version.strip_edges() + var plus := v.find("+") + if plus >= 0: + v = v.substr(0, plus) + return v + + +## Resolve the client-owned `godot-ai attach` command from a main-thread +## LaunchContext. Discovery itself is worker-safe: path/environment lookup is +## snapshot-backed and subprocess probes are wall-clock bounded. +## +## `discovery_override` is a data-only test seam. Supplying a key (including +## an empty value) bypasses that tier's live lookup; `system_version_result` +## bypasses the real `godot-ai --version` subprocess. +static func resolve_attach_launch( + launch_context: Dictionary, discovery_override: Dictionary = {} +) -> Dictionary: + ## Test overrides always bypass the session cache so fixture-controlled + ## discovery remains deterministic. Production results are keyed by every + ## setting that affects the rendered command; a port/domain/version change + ## therefore cannot reuse stale arguments. + if not discovery_override.is_empty(): + return _resolve_attach_launch_uncached(launch_context, discovery_override) + var cache_key := _attach_launch_cache_key(launch_context) + _attach_launch_cache_mutex.lock() + if _attach_launch_cache.has(cache_key): + var cached: Dictionary = _attach_launch_cache[cache_key].duplicate(true) + _attach_launch_cache_mutex.unlock() + return cached + ## Keep the cache lock through the bounded discovery probes. This cold path + ## runs at most once per distinct context and prevents simultaneous status + ## workers from repeating the same subprocess probes. Invalidation waits for + ## the in-flight result, then clears it, so stale work cannot repopulate a + ## freshly invalidated cache. + var resolved := _resolve_attach_launch_uncached(launch_context) + _attach_launch_cache[cache_key] = resolved.duplicate(true) + _attach_launch_cache_mutex.unlock() + return resolved + + +static func _resolve_attach_launch_uncached( + launch_context: Dictionary, discovery_override: Dictionary = {} +) -> Dictionary: + for key in ["http_port", "ws_port", "excluded_domains", "plugin_version", "allow_dev_venv", "platform"]: + if not launch_context.has(key): + return _attach_discovery_error("Launch context is missing `%s`; retry Configure." % key) + + var plugin_version := str(launch_context.get("plugin_version", "")).strip_edges() + if plugin_version.is_empty(): + return _attach_discovery_error("The bundled godot-ai version is unavailable; reinstall the plugin and retry Configure.") + + var common_args: Array[String] = [ + "attach", + "--port", str(int(launch_context.get("http_port", DEFAULT_HTTP_PORT))), + "--ws-port", str(int(launch_context.get("ws_port", DEFAULT_WS_PORT))), + ] + var exclusions := str(launch_context.get("excluded_domains", "")).strip_edges() + if not exclusions.is_empty(): + common_args.append_array(["--exclude-domains", exclusions]) + ## Default true when the key is absent (hand-built contexts in tests, stale + ## pre-upgrade snapshots) — matching the server's send-by-default posture. + ## Toggling the setting changes the rendered argv, so existing entries read + ## CONFIGURED_MISMATCH and the dock offers Reconfigure, like any other + ## launch-affecting value. + if not bool(launch_context.get("telemetry_enabled", true)): + common_args.append("--disable-telemetry") + + var venv_python := "" + if discovery_override.has("venv_python"): + venv_python = str(discovery_override["venv_python"]) + elif bool(launch_context.get("allow_dev_venv", true)): + venv_python = _cached_venv_python() + if bool(launch_context.get("allow_dev_venv", true)) and not venv_python.is_empty(): + var venv_args: Array[String] = ["-m", "godot_ai"] + venv_args.append_array(common_args) + return _finalize_attach_launch( + "dev_venv", venv_python, venv_args, launch_context, discovery_override + ) + + var uvx := "" + if discovery_override.has("uvx_path"): + uvx = str(discovery_override["uvx_path"]) + else: + ## Strict lookup for attach entries: never write a bare `uvx` command + ## that a GUI-launched client may be unable to resolve from its PATH. + uvx = find_uvx() + if not uvx.is_empty(): + var uvx_args := UvResolution.args() + uvx_args.append_array([ + "--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 := _consoleless_python_for_interpreter(python) + if not managed_pythonw.is_empty(): + 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"]) + + +## uv can return a launcher in ~/.local/bin whose GUI interpreter lives in +## the managed Python installation. Ask that interpreter for its base path. +## The optional result keeps tests independent of installed executables. +static func _consoleless_python_for_interpreter( + python: String, probe_result: Dictionary = {} +) -> String: + if not python.is_absolute_path() or not FileAccess.file_exists(python): + return "" + var sibling := python.get_base_dir().path_join("pythonw.exe") + if FileAccess.file_exists(sibling): + return sibling + var probe := probe_result + if probe.is_empty(): + probe = McpCliExec.run( + python, ["-I", "-c", "import sys; print(getattr(sys, '_base_executable', sys.executable))"], + _DISCOVERY_TIMEOUT_MS, false + ) + if int(probe.get("exit_code", -1)) != 0 or not probe.get("stdout") is String: + return "" + var base_python := str(probe["stdout"]).strip_edges() + if base_python.contains("\n") or base_python.contains("\r"): + return "" + if not base_python.is_absolute_path() or not FileAccess.file_exists(base_python): + return "" + var base_pythonw := base_python.get_base_dir().path_join("pythonw.exe") + return base_pythonw if FileAccess.file_exists(base_pythonw) else "" + + +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) + + +static func get_server_command() -> 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)" + % [pypi_version, version] + ) + else: + print("MCP | using uvx (godot-ai==%s)" % pypi_version) + var cmd: Array[String] = [uvx] + cmd.append_array(UvResolution.args()) + 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" + + +## Wall-clock budget for the Configure-time pre-warm. Far above +## `McpCliExec.DEFAULT_TIMEOUT_MS` (8s) on purpose: this call is *expected* to +## take tens of seconds on a cold cache — downloading and unpacking the tool +## environment is the entire point — so the usual CLI-registry budget would +## kill it right when it is doing useful work. Still bounded so a wedged uv +## can't hold the worker thread forever. +const PREWARM_TIMEOUT_MS := 180000 + + +static func find_uvx() -> String: + return CliFinder.find(_uvx_cli_names()) + + +## Pure argv builder for the pre-warm spawn, split out so tests can pin the +## exact command without spawning a process. Empty array when there is no +## version to pin, or when the version carries characters outside the PEP +## 440 alphabet — the value can originate from a GitHub release tag, and +## while OS.create_process argv can't be shell-injected, constraining the +## pin keeps a hostile tag from smuggling anything into the requirement +## spec uv parses (#890 review). +static func prewarm_server_package_argv(version: String) -> Array[String]: + var pinned := version.strip_edges() + if pinned.is_empty(): + return [] + ## No `*`: PEP 440 reserves it for prefix-match SPECIFIERS (`==3.2.*`), + ## not version identifiers — letting it through would turn the exact pin + ## into a prefix match (#890 CodeRabbit). + var re := RegEx.new() + if re.compile("^[A-Za-z0-9.+!-]+$") != OK or re.search(pinned) == null: + return [] + var args := UvResolution.args() + args.append_array(["--from", "godot-ai==%s" % pinned, "godot-ai", "--version"]) + return args + + +## Bounded worker-thread prewarm for Configure and the post-update client +## migration barrier (#851, and the reconnect timeouts reported alongside it). +## +## The Configure case that bites is when the plugin ADOPTS an already-running +## server (no uvx +## spawn, so nothing warms the env), the user clicks Configure, and the first +## client launch is the one that pays for building the whole tool environment. +## That build is ~67 packages; a warm launch is ~0.1s. Two symptoms fall out +## of the cold path: a visible terminal window on Windows (#851), and a spawn +## that overruns the MCP client's default 30s connect timeout, which the user +## sees as the Godot AI tools silently disappearing. +## +## The work is deliberately owned and joined rather than detached. Configure is +## an explicit click, so the cost is attributable and the Dock can show it as +## progress rather than as an unexplained pause. Runs on the dock's client +## action worker thread — never the main thread — and `McpCliExec.run` bounds +## it, so a wedged uv cannot trap the worker. +## +## Best-effort by contract. The config file is already written and correct +## when this runs; a failure here only means the next client launch pays the +## cold cost it always used to. Callers must NOT downgrade a successful +## Configure because of this result. +## +## Returns the `McpCliExec.run` dict, plus `skipped` (true when there is no +## uvx tier to warm or no usable version pin). +static func prewarm_server_package_blocking( + version: String, + timeout_ms: int = PREWARM_TIMEOUT_MS, + cancel_check: Callable = Callable(), +) -> Dictionary: + var args := prewarm_server_package_argv(version) + if args.is_empty(): + return {"skipped": true, "reason": "no version pin"} + var uvx := find_uvx() + if uvx.is_empty(): + ## dev-venv and system tiers have no per-version cache to warm. + return {"skipped": true, "reason": "no uvx"} + var command: Array[String] = [uvx] + command.append_array(args) + var result := McpCliExec.run( + uvx, + args, + timeout_ms, + true, + cancel_check, + UvResolution.is_production_command(command), + ) + result["skipped"] = false + return result + + +## Decide whether the freshly-written entry has an environment worth warming. +## +## Pure and side-effect free, split from the spawn below so tests can pin the +## decision without building a real uv environment — the same split +## `prewarm_server_package_argv` uses for the argv (#890). +## +## Warms only the `uvx` tier: dev-venv and system launches run an +## already-installed package and have no per-version environment to build. +## Resolving the tier here rather than in the dock keeps launcher knowledge in +## the configurator; the dock stays free of tier branching. +## +## Returns `{warm: bool, version: String, reason: String}`. +static func prewarm_attach_plan( + launch_context: Dictionary, discovery_override: Dictionary = {} +) -> Dictionary: + var launch := resolve_attach_launch(launch_context, discovery_override) + if not bool(launch.get("ok", false)): + return {"warm": false, "version": "", "reason": "launch discovery failed"} + var tier := str(launch.get("tier", "")) + if tier != "uvx": + return {"warm": false, "version": "", "reason": "tier %s has no env to warm" % tier} + var version := _pypi_pin_version(str(launch_context.get("plugin_version", ""))) + if version.is_empty(): + return {"warm": false, "version": "", "reason": "no version pin"} + return {"warm": true, "version": version, "reason": ""} + + +## Warm the environment the freshly-written entry will actually launch. +## Thin wrapper over `prewarm_attach_plan` + `prewarm_server_package_blocking`. +static func prewarm_attach_launch( + launch_context: Dictionary, + timeout_ms: int = PREWARM_TIMEOUT_MS, + discovery_override: Dictionary = {}, + cancel_check: Callable = Callable(), +) -> Dictionary: + var plan := prewarm_attach_plan(launch_context, discovery_override) + if not bool(plan.get("warm", false)): + return {"skipped": true, "reason": str(plan.get("reason", ""))} + return prewarm_server_package_blocking( + str(plan.get("version", "")), timeout_ms, cancel_check + ) + + +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. + ## The detection therefore has to be right before launch. +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..3dbf72c --- /dev/null +++ b/addons/godot_ai/clients/_atomic_write.gd @@ -0,0 +1,251 @@ +@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: + # Last line of defence for the path-resolution layer above. A relative (or + # empty) destination resolves against the EDITOR's working directory, not the + # caller's. The empty-path case is especially deceptive: editor safe-save + # runs mkstemp on "-XXXXXX", writes the payload into the project root, then + # fails only when renaming back to "" and leaves the random file behind. + # Callers gate this already (`McpClient.resolved_config_path_details`), but a + # writer that cannot tell where it is writing must refuse rather than guess. + if not path.is_absolute_path(): + return false + # 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 := _open_restricted_temp(tmp_path, target_mode) + if file == null: + return false + 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 before rename. The temp was already + # restricted before this handle opened, so this is defence in depth rather + # than the first point at which secrets become owner-only. + if not _apply_mode(tmp_path, target_mode): + DirAccess.remove_absolute(tmp_path) + return false + + # 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: + if not _apply_mode(backup_path, target_mode): + ## The backup contains the same secrets as the live config. Do + ## not leave that copy behind — or touch the destination — when + ## it cannot be restricted to the intended mode. + DirAccess.remove_absolute(backup_path) + DirAccess.remove_absolute(tmp_path) + return false + backup_made = true + else: + ## copy_absolute may leave a partial destination on failure. It + ## contains config bytes under the process umask, so remove it. + DirAccess.remove_absolute(backup_path) + + 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. A + # permission failure is a failed write: fall through to the same + # restore/remove path as a partial copy instead of reporting success + # with a potentially exposed token-bearing config. + if _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. + var restored := ( + DirAccess.copy_absolute(backup_path, path) == OK + and _apply_mode(path, target_mode) + ) + if not restored: + ## The backup's mode was already verified before the swap. Remove + ## any partial/default-mode destination, then move that restricted + ## inode back into place. If even the same-directory rename fails, + ## the complete prior contents remain in `.backup` for recovery. + DirAccess.remove_absolute(path) + DirAccess.rename_absolute(backup_path, path) + 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 + + +## Create/truncate an EMPTY temp file, close it, restrict its mode, then reopen +## it without truncation for the payload write. Godot 4.6 on macOS returns +## FAILED when set_unix_permissions targets a path whose FileAccess is still +## open; the old create -> chmod-while-open -> write sequence therefore emitted +## one warning per configured client and briefly wrote secrets under the umask +## mode. Closing the empty inode first makes the security ordering real and +## removes that platform-specific warning without suppressing genuine errors. +static func _open_restricted_temp(path: String, mode: int) -> FileAccess: + var created := FileAccess.open(path, FileAccess.WRITE) + if created == null: + return null + created.close() + if not _apply_mode(path, mode): + DirAccess.remove_absolute(path) + return null + return FileAccess.open(path, FileAccess.READ_WRITE) + + +static func _apply_mode(path: String, mode: int) -> bool: + # 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 true + var err := FileAccess.set_unix_permissions(path, mode) + if err == OK or err == ERR_UNAVAILABLE: + return true + # Surface a real chmod failure (not the Windows no-op) so permission + # hardening on a sensitive config doesn't fail completely silently. + push_warning("MCP | could not set permissions on %s: %s" % [path, error_string(err)]) + return false + + +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..0483496 --- /dev/null +++ b/addons/godot_ai/clients/_base.gd @@ -0,0 +1,578 @@ +@tool +class_name McpClient +extends RefCounted + +## Descriptor for one MCP client (Cursor, Claude Desktop, Codex, ...). +## +## Subclasses set fields in `_init()` and MUST NOT carry Callables — strategies +## (json/toml/cli) interpret the data. Enforced by +## `test_clients.gd::test_descriptors_are_data_only`. +## +## Why no Callables: per-client `.gd` files get hot-reloaded on disk-mtime +## change. A worker thread mid-call into a descriptor lambda races the +## bytecode swap and SEGVs (issue #229). Bonus: also obsoletes the stale- +## Callable workaround from #192. + +## CONFIGURED_MISMATCH = an entry with our `SERVER_NAME` exists in the user's +## client config, but its URL or launch command doesn't match the current +## ports/version/exclusions — typical after a setting change or update. +## Distinguishing this from `NOT_CONFIGURED` lets the dock surface a "your +## saved client configuration is stale" banner instead of conflating it with +## "you never configured this client". +enum Status { NOT_CONFIGURED, CONFIGURED, CONFIGURED_MISMATCH, ERROR } + + +## Lowercase string label for a `Status` value. Single source of truth so the +## MCP `client_status` tool, the dock, and the verify-after-write diagnostic +## in `McpClientConfigurator` all emit the same names — agents pattern-match +## against this set, so a fifth value being silently introduced would break +## them. +static func status_label(status: McpClient.Status) -> String: + match status: + Status.CONFIGURED: + return "configured" + Status.NOT_CONFIGURED: + return "not_configured" + Status.CONFIGURED_MISMATCH: + return "configured_mismatch" + return "error" + + +## One-line configure success message, shared by every strategy so the dock +## and the `client_manage` tool describe the transport that was actually +## written. Command-shape clients register the stdio `godot-ai attach` +## bridge — the URL-era "(HTTP: )" suffix would name a transport the +## write never touched (found live in the #838 Windows smoke). +static func configured_message(client: McpClient, server_url: String) -> String: + if client.command_shape != CommandShape.NONE: + return "%s configured (stdio attach)" % client.display_name + return "%s configured (HTTP: %s)" % [client.display_name, server_url] + +var id: String = "" ## stable key, e.g. "cursor" +var display_name: String = "" ## "Cursor" +var config_type: String = "" ## "json" | "toml" | "yaml" | "cli" | "dsh" +## False when the client's settings format cannot be round-tripped safely by +## the matching strategy. Status checks remain read-only; Configure and Remove +## return the existing manual instructions without touching the file. +var automatic_config_edits: bool = true + +## True when the config file is JSONC (Zed ships a `//` header). Honored by the +## JSON strategy's reads only — comments are stripped from a throwaway parse +## copy, so the row shows a real status instead of a permanent parse error +## (#914). Requires `automatic_config_edits = false`; the strategy ignores it +## otherwise rather than let a re-serializing Configure drop the comments. +var config_allows_comments: bool = false + +# JSON / TOML clients ------------------------------------------------------ +## {"darwin": "~/...", "windows": "$APPDATA/...", "linux": "$XDG_CONFIG_HOME/..."} +## Keys may also use "unix" as a shorthand for darwin+linux. +var path_template: Dictionary = {} + +## Optional ordered path candidates by platform. Each value is an Array of +## templates; one `*` may appear in a directory segment so packaged-app roots +## can be discovered without hardcoding publisher hashes. +## +## Resolution contract: +## 1. Existing files win in descriptor order, except that a unique wildcard +## match is authoritative even before its config leaf exists. A matching +## package root therefore creates inside that package rather than writing +## a fallback path that may become invisible after copy-on-write. When +## that private leaf is new, Configure seeds it from the first later +## existing candidate so read-through content is not shadowed. +## 2. If no file or wildcard package match exists, the first non-wildcard +## template is the deterministic create target. +## 3. Multiple matches within any wildcard group are ambiguous and fail +## closed instead of choosing an arbitrary package. +## +## Exact-file and config-home environment overrides still have higher +## priority. When this map has no entry for the current platform, +## `path_template` remains the fallback. +var config_path_candidates: Dictionary = {} + +## Optional global JSON config files merged by the client from lowest to highest +## precedence. Unlike `config_path_candidates`, every existing file participates; +## Configure updates the effective last definition, status verifies it, and +## Remove clears every global definition transactionally. +var config_merge_path_templates: Dictionary = {} +## Project-relative tiers that may override the global files. Their root is the +## external client's working directory, which the Godot process cannot know. +## The strategy checks plausible roots and fails closed instead of mutating them. +var config_merge_project_paths: PackedStringArray = PackedStringArray() + +## De-duplicate persistent path-ambiguity warnings across recurring status +## refreshes. The actionable message still returns on every resolution; only +## the editor-console echo is single-shot until the ambiguity clears/changes. +var _last_config_path_warning := "" +var _config_path_warning_mutex := Mutex.new() + +## Path inside the config object where the per-server map lives. +## Cursor / Claude Desktop / most others: ["mcpServers"] +## VS Code: ["servers"] +## OpenCode: ["mcp"] +var server_key_path: PackedStringArray = PackedStringArray() +## Alternative server-map paths accepted by the client, in precedence order +## after `server_key_path`. JSON strategy operations select the first non-null +## existing path so Configure, status, and Remove agree with the client parser. +var server_key_path_aliases: Array[PackedStringArray] = [] + +## Field inside the entry dict that holds our server URL. +## "url" by default; some clients use "serverUrl" or "httpUrl". +var entry_url_field: String = "url" + +## Required entry fields — written on every Configure AND verified by the +## default verifier. Use this for transport pins (e.g. `type: +## "streamable-http"`) where a missing/wrong value breaks negotiation: a +## legacy entry without the pin fails verification and surfaces as drift. +## +## DO NOT put user-mutable state here (auto-approval lists, `disabled` +## flags, opt-in toggles). Verifying those treats every user customisation +## as drift, and Configure-All-Mismatched then silently overwrites them +## back to defaults — see the `entry_initial_fields` doc below. +var entry_extra_fields: Dictionary = {} + +## Default fields written ONLY when the entry doesn't yet exist. Reconfigure +## preserves whatever the user (or the client itself) has set; the verifier +## ignores these keys entirely. Use for opt-in flags and user-state arrays — +## e.g. Roo / Cline / Kilo `alwaysAllow` / `autoApprove` lists, `disabled: +## false`, `isActive: true`. The pre-#229 behaviour was equivalent: per- +## client `entry_builder` lambdas seeded these as defaults but the +## per-client `verify_entry` lambdas only checked transport pins, so a +## user-customised array was `CONFIGURED`, not drift. Splitting the field +## restores that contract under the data-only descriptor model. +var entry_initial_fields: Dictionary = {} + +## Client-owned stdio launch shape. Each strategy renders the shape in its +## config language: +## +## - FLAT — `command` string + `args` array as sibling keys. JSON and YAML +## strategies. A client whose docs require a type discriminator next to the +## flat keys (VS Code's `type: "stdio"`, Claude Code's fallback file) stays +## FLAT and declares it via `command_transport_key` / `command_transport_value`, +## so TYPED_FLAT remains reserved vocabulary. +## - COMMAND_ARRAY — the launch argv carried as one array. In the JSON +## strategy the entry's `command` field IS that array (OpenCode's +## `"command": ["uvx", …]`). In the TOML strategy the launcher renders as a +## `command = "…"` line plus an `args = […]` array (Codex, Grok) — the name +## refers to the argv-as-TOML-array body it emits. +## - NESTED_COMMAND — command/args nested inside a sub-object. Reserved; no +## current client needs it and strategies reject it with an actionable error. +## +## CLI-registered clients (`config_type == "cli"`) express the launch through +## `cli_register_template` tokens instead; their `command_shape` governs the +## JSON-fallback file rendering (Claude Code, #463). +## +## Values are data-only shared vocabulary; keeping them data-only avoids +## reintroducing the descriptor Callable race from #229. +enum CommandShape { NONE, FLAT, TYPED_FLAT, COMMAND_ARRAY, NESTED_COMMAND } +var command_shape: CommandShape = CommandShape.NONE + +## 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. +## Relative values fail closed for the same reason `config_file_env`'s do. +var config_home_env: String = "" +## Path of the config file relative to the env var's directory, e.g. +## "config.toml". Joined verbatim — no per-OS variants needed because the env +## value itself is already an absolute (or ~-prefixed) directory. +var config_home_env_subpath: String = "" + +# CLI clients -------------------------------------------------------------- +var cli_names: PackedStringArray = PackedStringArray() +## Argument templates with `{name}` and `{url}` tokens; the strategy +## substitutes them at call time. Tokens are matched verbatim — no escaping +## semantics, no shell expansion. Command-shape templates additionally use the +## whole-element tokens `{command}` / `{args...}` (see `McpCliStrategy.format_args`). +## Populated by CLI descriptors (currently `claude_code`; `kimi_code` moved to +## mcp.json in #813). +var cli_register_template: PackedStringArray = PackedStringArray() +var cli_unregister_template: PackedStringArray = PackedStringArray() +## Args run to read current state; stdout is scanned for the server name and +## URL. Presence of `name` AND `url` → CONFIGURED, name only → MISMATCH, +## neither → NOT_CONFIGURED. +var cli_status_args: PackedStringArray = PackedStringArray() +## Optional scope-aware status template with a `{name}` token, for descriptors +## that also take `{scope}` in their register template. `cli_status_args` lists +## whatever the CLI resolves for the current directory and cannot say WHICH +## scope an entry came from, so a leftover user-scope entry reads as CONFIGURED +## while the selected project scope is empty — the dock then shows green over +## exactly the "loaded in every workspace" state the scope setting exists to +## end (#872). This template must print a `Scope: …` line +## (`claude mcp get ` does) and exit non-zero when the server is absent. +## Only consulted when the selected scope isn't the one `path_template` reads. +var cli_scope_status_template: PackedStringArray = PackedStringArray() + +# Codex / TOML clients ----------------------------------------------------- +## Dotted TOML path under which our entry lives, e.g. ["mcp_servers", "godot-ai"]. +## Strategies build the [section."name"] header from this. +var toml_section_path: PackedStringArray = PackedStringArray() +var toml_legacy_section_aliases: PackedStringArray = PackedStringArray() +## Lines (without the [header]) emitted under the section, with `{url}` +## tokens. Substituted at call time. +var toml_body_template: PackedStringArray = PackedStringArray() + + +## Resolved absolute config path for this client on the current OS. Exact-file +## overrides win first, followed by directory-valued `config_home_env`, then +## ordered candidates / `path_template`. Ignoring either override can write a +## file the client never reads and false-succeed. +func resolved_config_path() -> String: + return str(resolved_config_path_details().get("path", "")) + + +## Detailed sibling used by status/configure/remove so safe resolution +## failures reach the dock instead of collapsing into NOT_CONFIGURED. `error` +## is empty for ordinary unsupported/missing path mappings to preserve the +## long-standing status behavior for clients not installed on this platform. +func resolved_config_path_details() -> Dictionary: + var details := _resolve_config_path_details() + var path := str(details.get("path", "")) + if path.is_empty() or path.is_absolute_path(): + return details + ## Last gate before every strategy's read/write. `McpPathTemplate.expand` + ## leaves a token it cannot resolve in place, so an unset `$USERPROFILE` (or + ## a `~` with no HOME) reaches here as a RELATIVE path rather than as the + ## root-relative `/godot` that sails through `is_absolute_path()`. Acting on + ## it would resolve against the editor's own working directory and create a + ## literal `$USERPROFILE` folder in the Godot project. No descriptor + ## template is intentionally relative — every one is `~`- or `$VAR`-rooted — + ## so a non-absolute survivor is always a resolution failure. Report it with + ## the unexpanded path, which still shows what could not be resolved. + return {"path": "", "error": unresolved_config_path_error(display_name, path)} + + +## Shared wording for a path template `McpPathTemplate.expand` could not fully +## resolve. Used by the guard above and by the merge-tier loader in +## `_json_strategy.gd`, which resolves its own templates and never passes +## through `resolved_config_path_details`. +static func unresolved_config_path_error(client_name: String, path: String) -> String: + return ( + "Could not resolve %s's config path: %s did not expand to an absolute " + + "path. Set HOME/USERPROFILE (or the variable the path names) in the " + + "environment the editor was launched from." + ) % [client_name, path] + + +func _resolve_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 home_override := config_home_override_details() + if not str(home_override.get("path", "")).is_empty() or not str(home_override.get("error", "")).is_empty(): + _clear_config_path_warning() + return home_override + 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 expanded_template := McpPathTemplate.expand(template) + # An empty wildcard group ordinarily means "this package is not + # installed", but an unresolved root token produces the same empty + # group. Do not silently treat "could not inspect" as "not present" and + # fall through to a later writable candidate. + if not expanded_template.is_absolute_path(): + _clear_config_path_warning() + return { + "path": "", + "error": unresolved_config_path_error(display_name, expanded_template), + } + 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 := _first_existing_later_candidate(ordered_templates, index + 1) + if not str(seed.get("error", "")).is_empty(): + _clear_config_path_warning() + return {"path": "", "error": seed["error"]} + _clear_config_path_warning() + return {"path": path, "error": "", "seed_path": seed.get("path", "")} + if fallback_create_path.is_empty(): + fallback_create_path = path + _clear_config_path_warning() + return {"path": fallback_create_path, "error": ""} + + +## Find a readable seed for a newly-created authoritative wildcard target. +## An unresolved later root is an error, not an absent seed: writing without +## inspecting it could silently discard configuration visible through the +## package's read-through fallback. +func _first_existing_later_candidate(templates: Array, start_index: int) -> Dictionary: + for index in range(start_index, templates.size()): + var template := str(templates[index]) + var expanded_template := McpPathTemplate.expand(template) + if not expanded_template.is_absolute_path(): + return { + "path": "", + "error": unresolved_config_path_error(display_name, expanded_template), + } + var group := McpPathTemplate.expand_path_candidates(template) + # 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": path, "error": ""} + return {"path": "", "error": ""} + + +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 config-home env override plus any fail-closed diagnostic. Empty path and +## error means no override applies (no mapping declared, env var unset, or env +## var empty/whitespace). +func config_home_override_details() -> Dictionary: + if config_home_env.is_empty() or config_home_env_subpath.is_empty(): + return {"path": "", "error": ""} + ## 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 {"path": "", "error": ""} + # Expand a leading ~ so `CODEX_HOME=~/codex-alt` behaves like the shell. + var expanded := McpPathTemplate.expand(home) + ## Same fail-closed rule as `config_file_override_details`: a relative value + ## resolves against the EDITOR's working directory, not the client's, so + ## honouring it silently aims the write at the Godot project directory + ## instead of the file the client reads. The write layer can only name the + ## mangled path it was handed; only here do we still know which env var + ## produced it, so this is where the user-facing explanation belongs. + if not expanded.is_absolute_path(): + return { + "path": "", + "error": "%s's $%s override must be an absolute config-home directory path; got %s" % [ + display_name, config_home_env, home, + ], + } + return {"path": expanded.path_join(config_home_env_subpath), "error": ""} + + +## The env-var-relocated config path, or "" when no override applies (no +## mapping declared, env var unset, env var empty/whitespace, or a value that +## failed closed — `config_home_override_details` carries that diagnostic). +func config_home_override() -> String: + return str(config_home_override_details().get("path", "")) + + +## 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 + + +## Whether an existing entry's launch text launches Godot AI: an executable +## named `godot-ai`, a `godot-ai==` package pin, the bare `godot-ai` +## console-script argument, or the `godot_ai` module. Tokens are matched +## exactly, so a URL or path that merely contains the name (a docs link, a +## project directory) does not count. The post-update major migration +## rewrites only such entries; anything else is the user's own server. +static func launch_mentions_godot_ai(text: String) -> bool: + ## Free text (a CLI probe's output) is split on spaces; structured launch + ## values should go through `launch_values_mention_godot_ai` unsplit. + return launch_values_mention_godot_ai(PackedStringArray(text.split(" ", false))) + + +## Each value is one command, argument or URL. A URI never names an +## executable, and Windows separators are normalized before the basename +## check so `C:\Program Files\Godot AI\godot-ai.exe` is recognized whole. +static func launch_values_mention_godot_ai(values: PackedStringArray) -> bool: + for raw_value in values: + var value := raw_value.strip_edges().lstrip("\"'[{(").rstrip("\"'])},") + if value.is_empty() or value.contains("://"): + continue + if value == "godot-ai" or value == "godot_ai" or value.begins_with("godot-ai=="): + return true + var base := value.replace("\\", "/").get_file() + if base == "godot-ai" or base == "godot-ai.exe": + return true + return false + + +## The launch-bearing fields of an entry, one value each, for the check +## above. The entry sits under our server name, so the name itself must not +## count. +static func entry_launch_values(entry: Dictionary) -> PackedStringArray: + var values := PackedStringArray() + for key in ["command", "args", "url"]: + if not entry.has(key): + continue + var value: Variant = entry[key] + if value is Array: + for item in value: + values.append(str(item)) + else: + values.append(str(value)) + return values 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..c645730 --- /dev/null +++ b/addons/godot_ai/clients/_cli_exec.gd @@ -0,0 +1,219 @@ +@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 terminate the exact captured process 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 the wall-clock budget expired. +## cancelled: true if the caller requested a cooperative early stop. +## spawn_failed: true if `OS.execute_with_pipe` didn't return a usable PID. +## termination_failed: true if an expired/cancelled process or any of its +## possible descendants could not be proven stopped. POSIX +## only gives this helper exact-PID authority, so timeout and +## cancellation are necessarily unproven there. + +const DEFAULT_TIMEOUT_MS := 8000 +const _POLL_INTERVAL_MS := 50 +const _QUICK_EXIT_WINDOW_MS := 250 +const _KILL_GRACE_MS := 500 +const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd") +const UvResolution := preload("res://addons/godot_ai/utils/uv_resolution_policy.gd") + + +static func run( + exe: String, + args: Array, + timeout_ms: int = DEFAULT_TIMEOUT_MS, + capture_stderr: bool = true, + cancel_check: Callable = Callable(), + isolate_uv_resolution: bool = false, +) -> Dictionary: + if exe.is_empty(): + return _spawn_failed_result() + return _run_piped( + exe, args, timeout_ms, capture_stderr, cancel_check, isolate_uv_resolution + ) + + +static func _run_piped( + exe: String, + args: Array, + timeout_ms: int, + capture_stderr: bool, + cancel_check: Callable, + isolate_uv_resolution: 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) + + PortResolver.lock_process_spawn() + var saved_uv_environment := ( + UvResolution.isolate_environment() if isolate_uv_resolution else {} + ) + var info := OS.execute_with_pipe(spawn_exe, spawn_args) + UvResolution.restore_environment(saved_uv_environment) + PortResolver.unlock_process_spawn() + 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) + var kill_grant: Dictionary = {} + ## Most discovery commands finish within a few polls. Do not spend Windows + ## identity-query time capturing kill authority for an already-exited child. + ## This short window shares the command deadline and observes cancellation; + ## a child still running afterward needs the same exact grant as before. + var quick_deadline := mini(deadline, Time.get_ticks_msec() + _QUICK_EXIT_WINDOW_MS) + var cancellation_requested := false + while OS.is_process_running(pid) and Time.get_ticks_msec() < quick_deadline: + if cancel_check.is_valid() and bool(cancel_check.call()): + cancellation_requested = true + break + OS.delay_msec(mini(_POLL_INTERVAL_MS, maxi(0, quick_deadline - Time.get_ticks_msec()))) + if OS.is_process_running(pid): + kill_grant = PortResolver.capture_process_kill_grant(pid) + + while OS.is_process_running(pid): + var cancelled := cancellation_requested or (cancel_check.is_valid() and bool(cancel_check.call())) + if cancelled or Time.get_ticks_msec() >= deadline: + ## Kill before draining: a pipe read can block while the launcher or + ## one of its descendants still owns the write end. Windows taskkill + ## /T gives us tree authority; POSIX OS.kill only targets this exact + ## PID, so even a dead launcher cannot prove its descendants stopped. + var killed: Array[int] = [] + if not kill_grant.is_empty(): + killed = PortResolver.kill_exact_processes([kill_grant], false, true) + var kill_deadline := Time.get_ticks_msec() + _KILL_GRACE_MS + while PortResolver.pid_alive(pid) and Time.get_ticks_msec() < kill_deadline: + OS.delay_msec(_POLL_INTERVAL_MS) + + var termination_failed := ( + PortResolver.pid_alive(pid) + or OS.get_name() != "Windows" + or not killed.has(pid) + ) + var partial_stdout := "" + var partial_stderr := "" + if not termination_failed: + 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": not cancelled, + "cancelled": cancelled, + "spawn_failed": false, + "termination_failed": termination_failed, + } + 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, + "cancelled": false, + "spawn_failed": false, + "termination_failed": false, + } + + +static func _spawn_failed_result() -> Dictionary: + return { + "exit_code": -1, + "stdout": "", + "stderr": "", + "output": "", + "timed_out": false, + "cancelled": false, + "spawn_failed": true, + "termination_failed": false, + } + + +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..2d2d013 --- /dev/null +++ b/addons/godot_ai/clients/_cli_strategy.gd @@ -0,0 +1,466 @@ +@tool +class_name McpCliStrategy +extends RefCounted + +## Strategy for MCP clients that own their own state via a CLI (e.g. +## `claude mcp add`). Reads `cli_register_template` / `cli_unregister_template` +## / `cli_status_args` from the descriptor and substitutes `{name}` / `{url}` +## tokens. Command-shape descriptors additionally use the whole-element launch +## tokens `{command}` / `{args...}` (see `format_args`). No descriptor-supplied +## Callables — see `_base.gd` for why. +## +## Every shell-out goes through `McpCliExec.run`, which wraps the call in a +## wall-clock timeout. A hung CLI (e.g. `claude mcp list` under +## inter-Claude-Code contention) gets killed at the budget instead of +## locking up the caller forever — see issues #238 / #239. + +## The descriptor token resolved from `godot_ai/mcp_client_scope` (#872). +## Only Claude Code takes it today; any `config_type = "cli"` descriptor can. +const SCOPE_TOKEN := "{scope}" + +const _CONFIGURE_TIMEOUT_MS := 10000 +const _REMOVE_TIMEOUT_MS := 10000 +const _STATUS_TIMEOUT_MS := 6000 +const MutationLock := preload("res://addons/godot_ai/utils/client_mutation_lock.gd") + + +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} + return _configure_claimed(client, server_name, server_url, launch, cli) + + +static func _configure_claimed( + client: McpClient, + server_name: String, + server_url: String, + launch: Dictionary, + cli: String, +) -> Dictionary: + + # Best-effort prior cleanup so re-configure is idempotent. Ordinary absent- + # entry/non-zero results remain ignorable; an unproven timeout is not, + # because its descendant could race the configure that follows. + if not client.cli_unregister_template.is_empty(): + ## #872: a `{scope}` descriptor sweeps EVERY scope, not just the + ## selected one. Flipping the setting user -> project and pressing + ## Configure would otherwise leave the old user-scope entry alive — + ## exactly the "loaded in every unrelated workspace" problem the + ## setting exists to fix. `mcp remove` on an absent entry is a no-op, + ## so ordinary results are discarded; ambiguous termination aborts the + ## sequence. Each extra scope costs one bounded subprocess. + for pre_scope in _cleanup_scopes(client): + var pre_args := _format_args( + client.cli_unregister_template, server_name, server_url, {}, pre_scope + ) + var cleaned := McpCliExec.run(cli, pre_args, _REMOVE_TIMEOUT_MS) + var cleanup_failure := _unproven_mutation_failure( + client, "Prior cleanup", cleaned + ) + if not cleanup_failure.is_empty(): + return cleanup_failure + + 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) + var termination_failure := _unproven_mutation_failure(client, "Configure", result) + if not termination_failure.is_empty(): + return termination_failure + 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, "", _probe_output_launches_godot_ai(text) + ) + 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 = "", owned: bool = false +) -> Dictionary: + return {"status": status, "error_msg": error_msg, "owned": owned} + + +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} + return _remove_claimed(client, server_name, cli) + + +static func _remove_claimed(client: McpClient, server_name: String, cli: String) -> Dictionary: + 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) + var termination_failure := _unproven_mutation_failure(client, "Remove", result) + if not termination_failure.is_empty(): + return termination_failure + 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]} + + +## A POSIX exact-PID kill cannot prove that a CLI's descendants stopped. Never +## issue the next write after that ambiguous boundary: the old process could +## otherwise land a stale mutation after the new one. The durable global claim +## remains until the user explicitly clears it after stopping those processes. +static func _unproven_mutation_failure( + client: McpClient, operation: String, result: Dictionary +) -> Dictionary: + if not bool(result.get("termination_failed", false)): + return {} + return { + "status": "error", + "message": ( + "%s for %s could not be proven stopped. %s" + % [operation, client.display_name, MutationLock.recovery_message()] + ), + "termination_failed": true, + } + + +## Substitute `{name}` and `{url}` tokens in every template entry. +## Tokens match verbatim — `{name_suffix}` is NOT touched, so callers don't +## have to worry about partial-token collisions in their argv. +## +## Launch tokens are whole-element only: an element that is exactly +## `{command}` becomes the resolved attach launcher path, and an element that +## is exactly `{args...}` is spliced into the argv as one element per launch +## arg. Whole-element matching keeps a literal brace inside a path or flag +## from ever triggering an expansion. +## +## `scope_override` forces a specific `{scope}` value instead of the live +## setting; the configure pre-cleanup uses it to sweep the scopes the user is +## NOT currently on. Empty means "resolve from the setting". +static func format_args( + template: PackedStringArray, + server_name: String, + server_url: String, + launch: Dictionary = {}, + scope_override: String = "", +) -> Array[String]: + return _format_args(template, server_name, server_url, launch, scope_override) + + +static func _format_args( + template: PackedStringArray, + server_name: String, + server_url: String, + launch: Dictionary = {}, + scope_override: String = "", +) -> Array[String]: + var scope := scope_override if not scope_override.is_empty() else McpSettings.client_scope() + var out: Array[String] = [] + for arg in template: + var s := String(arg) + if s == "{command}": + out.append(str(launch.get("command", ""))) + continue + if s == "{args...}": + for launch_arg in launch.get("args", []): + out.append(str(launch_arg)) + continue + s = s.replace("{name}", server_name) + s = s.replace("{url}", server_url) + ## Resolved per-call rather than baked into the descriptor so the + ## setting can change without an editor restart, and so the manual + ## command shown in the dock always matches what Configure would run. + s = s.replace(SCOPE_TOKEN, scope) + out.append(s) + return out + + +## Scope-aware status for descriptors that declare `cli_scope_status_template`. +## Returns the same `{"status", "error_msg"}` shape as check_status_details. +## +## Split from the subprocess call so the verdict is a pure function of what the +## CLI printed — `_scope_probe_verdict` is unit-tested against real recorded +## `claude mcp get` output rather than needing a `claude` binary on the runner. +static func check_scope_status_details( + client: McpClient, + server_name: String, + server_url: String, + cli: String, + launch: Dictionary, + expected_scope: String, +) -> Dictionary: + if cli.is_empty() or client.cli_scope_status_template.is_empty(): + return _status_details(McpClient.Status.NOT_CONFIGURED) + var expected_target := server_url + var expected_args: Array[String] = [] + var expected_type := "" + if client.command_shape != McpClient.CommandShape.NONE: + ## Same fail-closed contract as configure and the plain status probe. + var launch_error := command_launch_error(client, launch) + if not launch_error.is_empty(): + return _status_details(McpClient.Status.ERROR, launch_error) + expected_target = str(launch.get("command", "")) + for launch_arg in launch.get("args", []): + expected_args.append(str(launch_arg)) + expected_type = str(client.command_transport_value) + var args := _format_args(client.cli_scope_status_template, server_name, server_url) + var result := McpCliExec.run(cli, args, _STATUS_TIMEOUT_MS, false) + if result.get("timed_out", false): + return _status_details(McpClient.Status.ERROR, "probe timed out") + if result.get("spawn_failed", false): + return _status_details(McpClient.Status.NOT_CONFIGURED) + return _scope_probe_verdict( + int(result.get("exit_code", -1)), + str(result.get("stdout", "")), + expected_scope, + expected_target, + expected_args, + expected_type, + ) + + +## The `Scope:` label the probe printed, normalised to a CLIENT_SCOPES value, +## or "" when no line was recognisable. Matched on the first word after the +## label so the parenthetical blurb ("(shared via .mcp.json)") can change +## wording between CLI releases without breaking detection. +static func _scope_from_probe_output(text: String) -> String: + for raw_line in text.split("\n"): + var line := String(raw_line).strip_edges() + var marker := line.find("Scope:") + if marker < 0: + continue + var rest := line.substr(marker + 6).strip_edges().to_lower() + for scope in McpSettings.CLIENT_SCOPES: + var name := String(scope) + if rest.begins_with(name): + return name + return "" + + +## The probe output names our server; only its launch fields decide whether +## the existing entry is ours to rewrite after an update. +static func _probe_output_launches_godot_ai(text: String) -> bool: + return McpClient.launch_mentions_godot_ai( + _field_from_probe_output(text, "Command:") + + " " + + _field_from_probe_output(text, "Args:") + + " " + + _field_from_probe_output(text, "URL:") + ) + + +static func _field_from_probe_output(text: String, label: String) -> String: + for raw_line in text.split("\n"): + var line := String(raw_line).strip_edges() + if line.begins_with(label): + return line.substr(label.length()).strip_edges() + return "" + + +## Pure verdict for a scope probe. A non-zero exit is the CLI's "no such +## server" signal. An entry resolved from a scope other than the selected one +## is MISMATCH, not CONFIGURED: the dock offers Reconfigure, and Configure's +## all-scope pre-cleanup is what actually moves it. +## +## A missing or unrecognised `Scope:` line degrades to the target check alone, +## deliberately rather than failing closed. `_verify_post_state` turns any +## non-CONFIGURED status after a successful write into an error, so treating an +## unparsed label as MISMATCH would mean a future CLI release that reworded or +## dropped that line breaks Configure outright — a false error plus a +## permanently amber row whose Reconfigure button cannot clear it. The entry +## has already been matched by name and exact launcher path at that point; the +## only thing in doubt is which scope it came from, and a slightly optimistic +## dot is a much cheaper failure than a configure flow that reports success as +## failure. Today's claude (2.1.241) always prints the line, so this is a +## forward-compatibility hedge, not a live code path. +static func _scope_probe_verdict( + exit_code: int, + text: String, + expected_scope: String, + expected_target: String, + expected_args: Array[String] = [], + expected_type: String = "", +) -> Dictionary: + if exit_code != 0: + return _status_details(McpClient.Status.NOT_CONFIGURED) + ## `resolved_scope` rides along as structured data so callers can act on it + ## — `_verify_post_state`'s path hint needs to know WHICH scope survived to + ## name the right file, and parsing it back out of the human-facing message + ## would couple that hint to this wording (#879). Parsed BEFORE the target + ## check so both mismatch paths carry it: an entry whose launcher drifted + ## sends the user to the wrong file just as readily as one whose scope did. + var resolved := _scope_from_probe_output(text) + var command_matches := ( + expected_target.is_empty() + or _field_from_probe_output(text, "Command:") == expected_target + ) + var launch_details_match := true + if not expected_type.is_empty(): + launch_details_match = ( + _field_from_probe_output(text, "Type:") == expected_type + and _field_from_probe_output(text, "Args:") == " ".join(expected_args) + ) + if not command_matches or not launch_details_match: + var drifted := _status_details( + McpClient.Status.CONFIGURED_MISMATCH, "", _probe_output_launches_godot_ai(text) + ) + if not resolved.is_empty(): + drifted["resolved_scope"] = resolved + return drifted + if resolved.is_empty(): + return _status_details(McpClient.Status.CONFIGURED) + if resolved != expected_scope: + var details := _status_details( + McpClient.Status.CONFIGURED_MISMATCH, + "registered at %s scope, not %s" % [resolved, expected_scope], + _probe_output_launches_godot_ai(text), + ) + details["resolved_scope"] = resolved + return details + return _status_details(McpClient.Status.CONFIGURED) + + +## True when the descriptor's register template takes `{scope}`, i.e. where +## its entry lands is decided by the `godot_ai/mcp_client_scope` setting rather +## than fixed by the descriptor. The configurator uses this to decide whether +## the JSON-fallback file is still a valid place to read status back from. +static func uses_scope_token(client: McpClient) -> bool: + return client.cli_register_template.has(SCOPE_TOKEN) + + +## Public view of the pre-cleanup sweep, for the manual-command text: what the +## dock tells the user to run has to match what Configure actually runs (#877). +static func cleanup_scopes(client: McpClient) -> Array[String]: + return _cleanup_scopes(client) + + +## Scopes the configure pre-cleanup removes from. A descriptor without the +## `{scope}` token has exactly one place its entry can live, so it keeps the +## single pass it always had — "" means "resolve the scope normally". +static func _cleanup_scopes(client: McpClient) -> Array[String]: + if not uses_scope_token(client): + return [""] + var scopes: Array[String] = [] + for scope in McpSettings.CLIENT_SCOPES: + scopes.append(String(scope)) + return scopes + + +static func _resolve_cli(client: McpClient) -> String: + return McpCliFinder.find(McpClient._array_from_packed(client.cli_names)) + + +static func resolve_cli_path(client: McpClient) -> String: + return _resolve_cli(client) diff --git a/addons/godot_ai/clients/_cli_strategy.gd.uid b/addons/godot_ai/clients/_cli_strategy.gd.uid 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/_dsh_strategy.gd b/addons/godot_ai/clients/_dsh_strategy.gd new file mode 100644 index 0000000..4d86c72 --- /dev/null +++ b/addons/godot_ai/clients/_dsh_strategy.gd @@ -0,0 +1,763 @@ +@tool +class_name McpDshStrategy +extends RefCounted + +## Configure / status / remove for DeepSeek Harness (dsh) MCP servers. +## +## DeepSeek Harness has no `mcp` CLI verb (verified against dsh +## 0.1.0-rc.6 — `dsh --help` exposes only `web` and `plugin`). MCP servers +## register as `@deepseek-ai/dsh-mcp-client` plugin entries in the HOME patch +## layer `$DSH_HOME/cordis.patch.yml` — the machine-local layer dsh applies +## over EVERY profile's own patch layer (the web GUI included), so one entry +## covers every profile the user boots. +## +## The patch file is a top-level YAML array of loader patch entries +## (`@deepseek-ai/cordis-plugin-include`'s `applyEntryPatches`). A non-insert +## row only OVERRIDES an existing bundle row (a new id is skipped with a +## warning), so a brand-new server must be added with an `insert` row: +## +## - insert: +## - id: mcp-godot-ai +## name: '@deepseek-ai/dsh-mcp-client' +## config: +## serverName: godot-ai +## transport: stdio +## command: "..." +## args: [...] +## +## Both shapes verified live against the dsh loader +## (`dsh --profile web --dump-config` composes the inserted entry; a plain +## row for a new id warns "patch: entry not found" and is skipped). We write +## one dedicated insert row per server, preserve every other row byte-for-byte +## (including other users' insert rows, overrides, comments, and `!!js` +## expressions), and status-verify the entry we wrote — the same file-read +## drift contract the JSON/TOML/YAML strategies use. +## +## The entry's `config` nests the launch under `serverName`/`transport`/ +## `command`/`args`; `serverName` is the model-facing tool namespace +## (`mcp__godot-ai__`), so it carries the server name like the +## `server_key_path` map key does for the other strategies. + +## Loader entry id prefix inside the patch list. The full id is +## `` — "mcp-godot-ai" for the godot-ai server. +const ENTRY_ID_PREFIX := "mcp-" +## The loader plugin that bridges MCP servers into the harness. Must be +## installed in the profile's node_modules (dsh ships it as a dependency of +## the CLI package). Quoted in the file: `@` is a reserved YAML indicator and +## cannot start a plain scalar. +const PLUGIN_NAME := "@deepseek-ai/dsh-mcp-client" +## Indent of a nested entry header under `- insert:` (children step 2 spaces +## deeper), matching the shipped profile patch files. +const ENTRY_INDENT := 4 + + +static func entry_id(server_name: String) -> String: + return ENTRY_ID_PREFIX + server_name + + +static func configure( + client: McpClient, + server_name: String, + server_url: String, + launch: Dictionary = {}, +) -> Dictionary: + var resolution := client.resolved_config_path_details() + var path := str(resolution.get("path", "")) + var path_error := str(resolution.get("error", "")) + if not path_error.is_empty(): + return {"status": "error", "message": path_error} + if path.is_empty(): + return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name} + ## Fail closed before touching the file — same contract as JSON/TOML/YAML. + var launch_error := command_launch_error(client, launch) + if not launch_error.is_empty(): + return {"status": "error", "message": launch_error} + + var read := _read(path) + if not read["ok"]: + return {"status": "error", "message": "Refusing to overwrite %s: %s. Fix or move the file, then re-run Configure." % [path, read["error"]]} + ## Fail closed on content that is not a loader patch list: dsh's patch + ## parser rejects a non-array file at boot, so appending our row to a + ## top-level mapping would hand the user a broken file (#867 review). + ## `[]` is a valid empty sequence meaning "no rows"; the literal must not + ## survive an append (block rows after `[]` are malformed YAML), so only + ## that line is dropped — comments around it belong to the user and stay. + var text := String(read["data"]) + text = _strip_empty_flow_sequence(text) + if not _is_patch_sequence(text): + return { + "status": "error", + "message": "Refusing to overwrite %s: it is not a top-level YAML list of loader entries. Fix or remove the file, then re-run Configure." % path, + } + + var existing_config: Variant = null + var lines := _split_lines(text) + var found := _find_entry_block(lines, entry_id(server_name), true) + if found.is_empty(): + var plain := _find_entry_block(lines, entry_id(server_name), false) + if not plain.is_empty(): + ## A plain `- id: mcp-godot-ai` row is inert for a new id — the + ## loader skips it with a warning and never registers the server. + ## Replacing the whole row with the insert form migrates it. + found = plain + if not found.is_empty(): + ## Pass the parsed config through so user-mutable fields (env, + ## toolCallTimeoutMs, reconnect) survive a reconfigure. + existing_config = _extract_config(lines, found) + var new_entry := build_entry(client, server_name, server_url, existing_config, launch) + ## Nested replacement must keep the sequence's existing header indent — + ## items of one block sequence share indentation, so a hand-written + ## 2-space file re-emitted at 4 spaces would no longer parse (#867 + ## review). Fresh rows and plain-row migrations keep the 4-space style. + var base_indent := ENTRY_INDENT + if not found.is_empty(): + var header_indent := int(found.get("indent", 0)) + if header_indent > 0: + base_indent = header_indent + var rendered := render_nested_entry(entry_id(server_name), new_entry, base_indent) + var out := "" + if found.is_empty(): + out = _append_row(lines, rendered) + else: + out = _replace_lines(lines, found, rendered) + if not McpAtomicWrite.write(path, out): + return {"status": "error", "message": "Cannot write to %s" % path} + return {"status": "ok", "message": McpClient.configured_message(client, server_url)} + + +static func check_status( + client: McpClient, + server_name: String, + server_url: String, + launch: Dictionary = {}, +) -> McpClient.Status: + return check_status_details(client, server_name, server_url, launch)["status"] + + +## Same contract as the other strategies (#711): {status, error_msg}. An +## existing-but-unreadable config is ERROR with the diagnostic — not +## NOT_CONFIGURED — so the dock row can tell "no config" from "config the +## editor can't read" instead of offering a Configure that would fail. +static func check_status_details( + client: McpClient, server_name: String, server_url: String, launch: Dictionary = {} +) -> Dictionary: + var resolution := client.resolved_config_path_details() + var path := str(resolution.get("path", "")) + var path_error := str(resolution.get("error", "")) + if not path_error.is_empty(): + return {"status": McpClient.Status.ERROR, "error_msg": path_error} + if path.is_empty() or not FileAccess.file_exists(path): + return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""} + var read := _read(path) + if not read["ok"]: + return { + "status": McpClient.Status.ERROR, + "error_msg": "Cannot read %s: %s" % [path, read["error"]], + } + var text := String(read["data"]) + if not _is_patch_sequence(text): + ## A top-level mapping/scalar is not a dsh patch list: nothing in it + ## can be "configured", and a nested `- id:` inside a mapping must + ## not read as ours (#867 review). + return { + "status": McpClient.Status.ERROR, + "error_msg": "%s is not a top-level YAML list of loader entries; fix or remove the file." % path, + } + var lines := _split_lines(text) + var found := _find_entry_block(lines, entry_id(server_name), true) + if found.is_empty(): + ## A plain row for our id is inert (the loader skips unknown ids), so + ## the server is NOT registered no matter what the file says. It only + ## becomes registered once Configure rewrites it as an insert row. + return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""} + var config := _extract_config(lines, found) + if config.is_empty(): + return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""} + ## An entry exists but no verified launcher does — mirror the other + ## strategies: this is an environment ERROR, not entry drift. + var launch_error := command_launch_error(client, launch) + if not launch_error.is_empty(): + return {"status": McpClient.Status.ERROR, "error_msg": launch_error} + if verify_entry(client, config, server_name, server_url, launch): + return {"status": McpClient.Status.CONFIGURED, "error_msg": ""} + return { + "status": McpClient.Status.CONFIGURED_MISMATCH, + "error_msg": "", + "owned": McpClient.launch_values_mention_godot_ai(McpClient.entry_launch_values(config)), + } + + +static func remove(client: McpClient, server_name: String) -> Dictionary: + var resolution := client.resolved_config_path_details() + var path := str(resolution.get("path", "")) + var path_error := str(resolution.get("error", "")) + if not path_error.is_empty(): + return {"status": "error", "message": path_error} + if path.is_empty() or not FileAccess.file_exists(path): + return {"status": "ok", "message": "Not configured"} + var read := _read(path) + if not read["ok"]: + return {"status": "error", "message": "Refusing to rewrite %s: %s." % [path, read["error"]]} + var text := String(read["data"]) + if not _is_patch_sequence(text): + ## Same fail-closed contract as configure (#867 review): never modify + ## a file that dsh cannot load as a patch list. + return { + "status": "error", + "message": "Refusing to rewrite %s: it is not a top-level YAML list of loader entries. Fix or remove the file, then re-run Remove." % path, + } + var lines := _split_lines(text) + var found := _find_entry_block(lines, entry_id(server_name), true) + if found.is_empty(): + found = _find_entry_block(lines, entry_id(server_name), false) + if found.is_empty(): + return {"status": "ok", "message": "%s configuration removed" % client.display_name} + var out := _remove_lines(lines, found) + if _is_blank_or_comment_only(out): + ## dsh's patch parser requires a top-level YAML array: an empty or + ## comment-only patch file FAILS BOOT (`loadOptionalPatches` throws on + ## a non-array file), while an absent file means "no layer". After our + ## row is gone, an all-blank file must therefore be deleted, not + ## written back. + if FileAccess.file_exists(path): + var remove_err := DirAccess.remove_absolute(path) + if remove_err != OK: + return {"status": "error", "message": "Cannot remove %s (error %d)" % [path, remove_err]} + return {"status": "ok", "message": "%s configuration removed" % client.display_name} + if not McpAtomicWrite.write(path, out): + return {"status": "error", "message": "Cannot write to %s" % path} + return {"status": "ok", "message": "%s configuration removed" % client.display_name} + + +## Build the `config` dict written inside the loader entry. Command mode +## (FLAT, #838) nests `serverName`/`transport`/`command`/`args`; URL mode nests +## `serverName`/`transport`/`url`. Existing user state (env, timeouts, +## reconnect) survives a reconfigure via the deep copy. +static func build_entry( + client: McpClient, + server_name: String, + server_url: String, + existing: Variant = null, + launch: Dictionary = {}, +) -> Dictionary: + var entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {} + entry["serverName"] = server_name + if client.command_shape == McpClient.CommandShape.FLAT: + entry["command"] = str(launch.get("command", "")) + entry["args"] = _array_copy(launch.get("args", [])) + if not client.command_transport_key.is_empty(): + entry[client.command_transport_key] = client.command_transport_value + for key in client.command_initial_fields: + if not entry.has(key): + entry[key] = client.command_initial_fields[key] + for key in client.command_legacy_keys: + entry.erase(String(key)) + return entry + if client.command_shape == McpClient.CommandShape.NONE: + ## Single URL-mode source of truth (#867 review): build_entry's URL + ## branch delegates to build_url_entry so the two can never drift. + return build_url_entry(client, server_name, server_url, existing) + return {} + + +## URL-mode sibling for the manual-instruction fallback text and the +## build_entry URL branch. DeepSeek Harness' mcp-client bridge accepts +## `transport: streamable-http` with a `url`, so the entry keeps the same +## `serverName` namespace with an HTTP transport instead of the stdio attach +## bridge. Existing user state survives via the deep copy. +static func build_url_entry( + client: McpClient, server_name: String, server_url: String, existing: Variant = null +) -> Dictionary: + var entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {} + entry["serverName"] = server_name + entry["transport"] = "streamable-http" + if not client.entry_url_field.is_empty(): + entry[client.entry_url_field] = server_url + return entry + + +## Verify a stored `config` dict matches the current launch. Command mode +## checks every launch-affecting value exactly and requires legacy URL keys +## gone; serverName must match the pinned namespace. URL mode checks the url +## and the streamable-http pin. +static func verify_entry( + client: McpClient, + config: Dictionary, + server_name: String, + server_url: String, + launch: Dictionary = {}, +) -> bool: + if config.get("serverName") != server_name: + return false + if client.command_shape != McpClient.CommandShape.NONE: + if client.command_shape != McpClient.CommandShape.FLAT or not bool(launch.get("ok", false)): + return false + for key in client.command_legacy_keys: + if config.has(String(key)): + return false + if config.get("command") != launch.get("command"): + return false + if not _arrays_equal(config.get("args", null), launch.get("args", null)): + return false + if not client.command_transport_key.is_empty(): + if config.get(client.command_transport_key, null) != client.command_transport_value: + return false + return true + if config.get(client.entry_url_field, "") != server_url: + return false + return config.get("transport", "") == "streamable-http" + + +## Empty string when this client's launch requirements are satisfied. +## Mirrors `McpYamlStrategy.command_launch_error`; the patch-list dialect +## supports FLAT (and the URL fallback) only. +static func command_launch_error(client: McpClient, launch: Dictionary) -> String: + if client.command_shape == McpClient.CommandShape.NONE: + return "" + if client.command_shape != McpClient.CommandShape.FLAT: + return "%s uses a command shape not supported by DeepSeek Harness config yet" % client.display_name + if not bool(launch.get("ok", false)): + return str(launch.get("error", "No compatible attach launcher was found.")) + return "" + + +# --- Rendering -------------------------------------------------------------- + +## Render a full `- insert:` row containing one nested loader entry. Public +## seam for the dock's manual-instruction text so the pasted YAML matches +## what Configure would write byte-for-byte. +static func render_insert_row(entry_id_value: String, config: Dictionary) -> PackedStringArray: + var lines: PackedStringArray = ["- insert:"] + lines.append_array(render_nested_entry(entry_id_value, config)) + return lines + + +## Render one nested loader entry (the `- id:` block inside an `insert` +## list). `base_indent` defaults to the 4-space style used by the shipped +## patch files; a nested replacement reuses the indent the existing entry was +## found at so the block sequence keeps a single indentation (#867 review). +static func render_nested_entry( + entry_id_value: String, config: Dictionary, base_indent: int = ENTRY_INDENT +) -> PackedStringArray: + var lines: PackedStringArray = [] + lines.append("%s- id: %s" % [_indent_str(base_indent), entry_id_value]) + lines.append("%sname: '%s'" % [_indent_str(base_indent + 2), PLUGIN_NAME]) + lines.append("%sconfig:" % _indent_str(base_indent + 2)) + for key in config: + lines.append_array(_emit_field(String(key), config[key], base_indent + 4)) + return lines + + +## Emit one `key: value` line (or a nested block for Dictionary values) at +## the given indent. Arrays render in flow style; scalars through the shared +## YAML emitter so quoting rules stay identical across the two YAML dialects. +static func _emit_field(key: String, value: Variant, indent: int) -> PackedStringArray: + var lines: PackedStringArray = [] + var prefix := _indent_str(indent) + if value is Dictionary: + lines.append("%s%s:" % [prefix, key]) + for sub_key in value: + lines.append_array(_emit_field(String(sub_key), value[sub_key], indent + 2)) + elif value is Array or value is PackedStringArray: + lines.append("%s%s: %s" % [prefix, key, McpYamlStrategy.emit_flow_array(_array_copy(value))]) + else: + lines.append("%s%s: %s" % [prefix, key, McpYamlStrategy.emit_scalar(value)]) + return lines + + +# --- Line-level parsing (no YAML parser in Godot) --------------------------- + +## Split file text into lines, dropping only a single trailing newline so the +## remainder round-trips byte-for-byte. `text` is never split on the empty +## string, so an empty file yields an empty array. +static func _split_lines(text: String) -> PackedStringArray: + if text.is_empty(): + return PackedStringArray() + var lines := text.split("\n") + if lines.size() > 0 and String(lines[lines.size() - 1]).is_empty(): + lines.remove_at(lines.size() - 1) + return lines + + +## Locate the block owned by our loader entry: +## require_nested=true — the `- id: ` line inside an `insert` +## list (indent > 0), plus its indented children. +## require_nested=false — a plain top-level `- id: ` row (indent +## 0), plus its indented children. +## Returns {"start": int, "end": int, "indent": int} (end exclusive) or {} +## when absent. The header indent is detected dynamically so a hand-written +## file using non-2-space indentation is still found; children are lines +## deeper than the header. `end` stops right AFTER the last content line: +## trailing blank and comment lines between our entry and the next sibling +## belong to the file, not to our block, so `_replace_lines` / `_remove_lines` +## (which splice [start, end)) never delete them (#867 review). +static func _find_entry_block(lines: PackedStringArray, entry_id_value: String, require_nested: bool) -> Dictionary: + for i in range(lines.size()): + var indent := _indent_of(lines[i]) + if require_nested and indent <= 0: + continue + if not require_nested and indent != 0: + continue + var parsed := _parse_entry_header(lines[i]) + if parsed.is_empty() or parsed.get("id") != entry_id_value: + continue + ## A nested match must actually live inside an `- insert:` row: a + ## `- id:` list under some other top-level row (an override row's own + ## sequence, say) is not a loader entry and is not ours to touch. + if require_nested and not _under_insert_row(lines, i): + continue + var j := i + 1 + var last_content := i + while j < lines.size(): + var l := lines[j] + if _is_blank_or_comment(l): + j += 1 + continue + if _indent_of(l) <= indent: + break + last_content = j + j += 1 + return {"start": i, "end": last_content + 1, "indent": indent} + return {} + + +## True when the nearest preceding 0-indent content line above `index` is an +## `- insert:` row header. Blank and comment lines are skipped; hitting the +## top of the file without a header means the nested line is orphaned and +## cannot be a loader entry. +static func _under_insert_row(lines: PackedStringArray, index: int) -> bool: + for i in range(index - 1, -1, -1): + if _is_blank_or_comment(lines[i]): + continue + if _indent_of(lines[i]) == 0: + return lines[i].strip_edges().begins_with("- insert:") + return false + + +## Parse a `- id: ` header line into {"id": String}. Returns {} for +## any other line. The value may be plain or single/double-quoted. +static func _parse_entry_header(line: String) -> Dictionary: + var stripped := line.strip_edges() + if not stripped.begins_with("- "): + return {} + var body := stripped.substr(2).strip_edges() + if not body.begins_with("id:"): + return {} + var value := body.substr(3).strip_edges() + if value.begins_with("\"") and value.ends_with("\"") and value.length() >= 2: + var parsed: Variant = JSON.parse_string(value) + if parsed is String: + value = parsed + elif value.begins_with("'") and value.ends_with("'") and value.length() >= 2: + value = value.substr(1, value.length() - 2) + return {"id": value} + + +## Extract the `config:` sub-dict from a found entry block. Fields may nest +## (env, reconnect); deeper values are parsed with the same scalar coercion +## the YAML strategy uses. Returns {} when the block has no config block. +static func _extract_config(lines: PackedStringArray, block: Dictionary) -> Dictionary: + var start := int(block.get("start", -1)) + var end := int(block.get("end", -1)) + if start < 0: + return {} + var config_indent := -1 + var config_start := -1 + for i in range(start, end): + var stripped := lines[i].strip_edges() + if _is_blank_or_comment(lines[i]): + continue + if stripped == "config:": + config_indent = _indent_of(lines[i]) + config_start = i + break + if config_start < 0: + return {} + return _parse_block_lines(lines, config_start + 1, end, config_indent) + + +## Parse sibling `key: value` lines at a fixed minimum indent into a +## Dictionary. Nested blocks (a key with no inline value) recurse and their +## child lines are consumed by the recursion — the parent index advances past +## them so they are never re-parsed as top-level config fields (#867 review). +## Flow arrays parse back into Arrays via JSON. Quoted and plain scalars +## strip via the YAML strategy's shared `coerce_scalar`. +static func _parse_block_lines(lines: PackedStringArray, start: int, end: int, min_indent: int) -> Dictionary: + var out: Dictionary = {} + var i := start + while i < end: + var l := lines[i] + if _is_blank_or_comment(l): + i += 1 + continue + var indent := _indent_of(l) + if indent <= min_indent: + break + var stripped := l.strip_edges() + var colon := stripped.find(":") + if colon < 0: + i += 1 + continue + var key := stripped.substr(0, colon).strip_edges() + var value := stripped.substr(colon + 1).strip_edges() + if value.is_empty(): + ## Nested block: find where its children end (a line at or above + ## the block key's indent), parse only that span, and jump past it. + var child_end := i + 1 + while child_end < end: + if not _is_blank_or_comment(lines[child_end]) and _indent_of(lines[child_end]) <= indent: + break + child_end += 1 + out[key] = _parse_block_lines(lines, i + 1, child_end, indent) + i = child_end + else: + ## Shared with the YAML strategy so the two dialects can never + ## drift on quoting/typing rules (#867 review). + out[key] = McpYamlStrategy.coerce_scalar(value) + i += 1 + return out + + +# --- Rewrites --------------------------------------------------------------- + +## Append a new `- insert:` row at the end of the file. A blank separator +## line keeps rows visually distinct; the file always ends with a newline. +static func _append_row(lines: PackedStringArray, nested_lines: PackedStringArray) -> String: + var parts: Array[String] = [] + for l in lines: + parts.append(l) + ## Collapse trailing blank lines so the appended row is not orphaned + ## below stray empties; one separator blank keeps rows readable. + while parts.size() > 0 and String(parts[parts.size() - 1]).strip_edges().is_empty(): + parts.remove_at(parts.size() - 1) + if parts.size() > 0: + parts.append("") + parts.append("- insert:") + for l in nested_lines: + parts.append(l) + return _join_lines(parts) + + +## Replace the lines owned by our entry. When the found block is a plain +## top-level row (indent 0 header), the whole row — including its children — +## is replaced by the insert form. When it is a nested block inside an +## insert row, only that block's lines are replaced, leaving sibling entries +## and comments in the same row byte-for-byte intact. +static func _replace_lines(lines: PackedStringArray, block: Dictionary, nested_lines: PackedStringArray) -> String: + var start := int(block.get("start", -1)) + var end := int(block.get("end", -1)) + var parts: Array[String] = [] + for i in range(lines.size()): + if i == start: + ## A nested block keeps the surrounding `- insert:` row, so only + ## the nested entry lines (indent > 0) are spliced in. + if _indent_of(lines[start]) > 0: + for l in nested_lines: + parts.append(l) + else: + parts.append("- insert:") + for l in nested_lines: + parts.append(l) + if i >= start and i < end: + continue + parts.append(lines[i]) + return _join_lines(parts) + + +## Remove our entry. A nested block is spliced out; if its enclosing insert +## row is left without any nested entries (only the bare `- insert:` header, +## blanks, and comments), the row header goes too so the file stays a valid +## patch list. A plain row for our id is removed wholesale. +static func _remove_lines(lines: PackedStringArray, block: Dictionary) -> String: + var start := int(block.get("start", -1)) + var end := int(block.get("end", -1)) + var nested := _indent_of(lines[start]) > 0 + var parts: Array[String] = [] + if nested: + ## Find the enclosing `- insert:` row header: the nearest preceding + ## 0-indent `- ` line. + var row_start := -1 + for i in range(start - 1, -1, -1): + if _indent_of(lines[i]) == 0 and lines[i].strip_edges().begins_with("- "): + row_start = i + break + ## Row end: first 0-indent non-blank/comment line after the block. + var row_end := lines.size() + for i in range(end, lines.size()): + if _indent_of(lines[i]) == 0 and not _is_blank_or_comment(lines[i]): + row_end = i + break + var has_other_entries := false + for i in range(row_start + 1, row_end): + if i >= start and i < end: + continue + if _is_blank_or_comment(lines[i]): + continue + if _indent_of(lines[i]) > 0 and lines[i].strip_edges().begins_with("- "): + has_other_entries = true + break + if has_other_entries or row_start < 0: + ## Only our block goes; sibling entries and any trailing + ## comments in the same row survive. + for i in range(lines.size()): + if i >= start and i < end: + continue + parts.append(lines[i]) + else: + ## The row held only our entry: drop the `- insert:` header and + ## our block, but keep trailing blank/comment lines — a comment + ## the user wrote above the next row must survive (#867 review). + for i in range(lines.size()): + if i >= row_start and i < end: + continue + parts.append(lines[i]) + parts = _trim_trailing_blanks(parts) + else: + ## Plain top-level row for our id: drop the whole block. + for i in range(lines.size()): + if i >= start and i < end: + continue + parts.append(lines[i]) + parts = _trim_trailing_blanks(parts) + return _join_lines(parts) + + +## Remove trailing blank lines left behind by a removal. +static func _trim_trailing_blanks(parts: Array[String]) -> Array[String]: + while parts.size() > 0 and String(parts[parts.size() - 1]).strip_edges().is_empty(): + parts.remove_at(parts.size() - 1) + return parts + + +## True when the text holds no patch rows at all — blank lines and comments +## only. Such a file cannot be written back (see `remove`), because dsh's +## patch parser rejects a non-array file at boot. +static func _is_blank_or_comment_only(text: String) -> bool: + for line in text.split("\n"): + var stripped := line.strip_edges() + if not stripped.is_empty() and not stripped.begins_with("#"): + return false + return true + + +## When the document's only content line is the empty flow sequence `[]`, +## return the text with that one line removed — blanks and comments stay, +## because they belong to the user. Any other document comes back unchanged; +## a `[]` mixed with real rows is left for `_is_patch_sequence` to refuse. +static func _strip_empty_flow_sequence(text: String) -> String: + var lines := _split_lines(text) + var bracket_line := -1 + for i in range(lines.size()): + if _is_blank_or_comment(lines[i]): + continue + if bracket_line < 0 and lines[i].strip_edges() == "[]": + bracket_line = i + continue + return text + if bracket_line < 0: + return text + var parts: Array[String] = [] + for i in range(lines.size()): + if i != bracket_line: + parts.append(lines[i]) + if parts.is_empty(): + return "" + return _join_lines(parts) + + +## True when the text is a valid loader patch list (or empty/comment-only / +## the empty flow sequence `[]`, which all mean "no layer"). Anything else — +## a top-level mapping or scalar — would make dsh fail boot, so Configure +## refuses to write into it instead of producing a broken file (#867 review). +## Only 0-indent content lines define the top-level structure: they must all +## be `- ` rows. Deeper lines are row children and are not inspected. `[]` is +## valid only as the COMPLETE document — appending rows after the empty flow +## sequence would produce malformed YAML, so a mixed `[]` + rows file fails +## the check too. +static func _is_patch_sequence(text: String) -> bool: + if text.strip_edges().is_empty(): + return true + var saw_brackets := false + var content_count := 0 + for line in text.split("\n"): + var stripped := line.strip_edges() + if stripped.is_empty() or stripped.begins_with("#"): + continue + content_count += 1 + if stripped == "[]": + saw_brackets = true + continue + if _indent_of(line) == 0 and not stripped.begins_with("- "): + return false + if saw_brackets and content_count > 1: + return false + return true + + +static func _join_lines(parts: Array) -> String: + if parts.is_empty(): + return "" + var lines: Array[String] = [] + for p in parts: + lines.append(String(p)) + return "\n".join(lines) + "\n" + + +# --- Small helpers ---------------------------------------------------------- + +static func _indent_str(n: int) -> String: + var out := "" + for i in range(n): + out += " " + return out + + +static func _indent_of(line: String) -> int: + var n := 0 + while n < line.length() and (line[n] == " " or line[n] == "\t"): + n += 1 + return n + + +static func _is_blank_or_comment(line: String) -> bool: + var stripped := line.strip_edges() + return stripped.is_empty() or stripped.begins_with("#") + + +static func _array_copy(value: Variant) -> Array: + if value is Array: + return (value as Array).duplicate(true) + if value is PackedStringArray: + return McpClient._array_from_packed(value) + return [] + + +static func _arrays_equal(left: Variant, right: Variant) -> bool: + if not (left is Array or left is PackedStringArray): + return false + if not (right is Array or right is PackedStringArray): + return false + var left_array := _array_copy(left) + var right_array := _array_copy(right) + if left_array.size() != right_array.size(): + return false + for i in range(left_array.size()): + if left_array[i] != right_array[i]: + return false + return true + + +## Returns {"ok": true, "data": String} when the file is absent or readable, +## and {"ok": false, "error": String} when unreadable. Callers must NOT fall +## back to an empty string on the error path — doing so blows away the user's +## other patch rows on the next write. +static func _read(path: String) -> Dictionary: + if not FileAccess.file_exists(path): + return {"ok": true, "data": ""} + var f := FileAccess.open(path, FileAccess.READ) + if f == null: + var err := FileAccess.get_open_error() + return {"ok": false, "error": "could not open for reading (%s)" % error_string(err)} + var t := f.get_as_text() + f.close() + return {"ok": true, "data": t} diff --git a/addons/godot_ai/clients/_dsh_strategy.gd.uid b/addons/godot_ai/clients/_dsh_strategy.gd.uid new file mode 100644 index 0000000..36703f4 --- /dev/null +++ b/addons/godot_ai/clients/_dsh_strategy.gd.uid @@ -0,0 +1 @@ +uid://ch0brn7swb6qx diff --git a/addons/godot_ai/clients/_json_strategy.gd b/addons/godot_ai/clients/_json_strategy.gd new file mode 100644 index 0000000..e7f9a50 --- /dev/null +++ b/addons/godot_ai/clients/_json_strategy.gd @@ -0,0 +1,1114 @@ +@tool +class_name McpJsonStrategy +extends RefCounted + +## Read–merge–write strategy for JSON-backed MCP clients. +## All knobs come from the McpClient descriptor as plain data — no Callables. +## See `_base.gd` for why descriptors are data-only. + + +static func configure( + client: McpClient, + server_name: String, + server_url: String, + launch: Dictionary = {}, + project_roots: PackedStringArray = PackedStringArray(), +) -> Dictionary: + if _uses_merge_tiers(client): + return _configure_merged(client, server_name, server_url, launch, project_roots) + var resolution := client.resolved_config_path_details() + var path := str(resolution.get("path", "")) + var path_error := str(resolution.get("error", "")) + if not path_error.is_empty(): + return {"status": "error", "message": path_error} + if path.is_empty(): + return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name} + + var seed_path := str(resolution.get("seed_path", "")) + var read_path := seed_path if not FileAccess.file_exists(path) and not seed_path.is_empty() else path + var read := _read_or_init(read_path) + if not read["ok"]: + return {"status": "error", "message": "Refusing to overwrite %s: %s. Fix or move the file, then re-run Configure." % [read_path, read["error"]]} + var launch_error := command_launch_error(client, launch) + if not launch_error.is_empty(): + return {"status": "error", "message": launch_error} + var config: Dictionary = read["data"] + var holder := _ensure_path(config, select_server_key_path(config, client)) + ## Pass the existing entry through so `build_entry` can preserve user-mutable + ## state (auto-approval lists, `disabled` toggles) instead of resetting it + ## to descriptor defaults on every Configure click. See `entry_initial_fields` + ## docs in `_base.gd`. + var existing: Variant = holder.get(server_name, null) + holder[server_name] = build_entry(client, server_url, existing, launch) + + # F5: refuse to re-serialize a file whose parsed integers above 2^53 would + # lose precision. Editing by hand keeps those values intact. + if _has_lossy_numbers(config): + return {"status": "error", "message": "Refusing to rewrite %s: contains integers above 2^53 that Godot's JSON parser would re-emit imprecise. Edit the file by hand." % path} + if not McpAtomicWrite.write(path, JSON.stringify(_narrow_integral_numbers(config), "\t", false)): + return {"status": "error", "message": "Cannot write to %s" % path} + return {"status": "ok", "message": McpClient.configured_message(client, server_url)} + +## Pi-style clients merge several global config files. Update the effective +## highest-precedence definition; fail closed when a project override exists +## because Pi's external working directory cannot be inferred safely here. +static func _configure_merged( + client: McpClient, + server_name: String, + server_url: String, + launch: Dictionary, + project_roots: PackedStringArray, +) -> Dictionary: + var launch_error := command_launch_error(client, launch) + if not launch_error.is_empty(): + return {"status": "error", "message": launch_error} + var project := _load_project_definitions(client, server_name, project_roots) + if not project.get("ok", false): + return {"status": "error", "message": str(project.get("error", "Cannot inspect project config tiers"))} + var project_tiers: Array = project.get("tiers", []) + if not project_tiers.is_empty(): + return {"status": "error", "message": _project_override_message(project_tiers, "update or remove", client.display_name, server_name)} + var loaded := _load_merge_tiers(client) + if not loaded.get("ok", false): + return {"status": "error", "message": str(loaded.get("error", "Cannot read merged config tiers"))} + var tiers: Array = loaded.get("tiers", []) + if tiers.is_empty(): + return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name} + var target_index := 0 + for index in range(tiers.size()): + var config: Dictionary = tiers[index]["data"] + var holder := _walk_path(config, select_server_key_path(config, client)) + if holder is Dictionary and holder.has(server_name): + target_index = index + var tier: Dictionary = tiers[target_index] + var config: Dictionary = tier["data"] + var holder := _ensure_path(config, select_server_key_path(config, client)) + var existing: Variant = holder.get(server_name, null) + holder[server_name] = build_entry(client, server_url, existing, launch) + var path := str(tier["path"]) + # F5: refuse to re-serialize a tier whose parsed integers above 2^53 would + # lose precision. The same check guards the simple `configure` path. + if _has_lossy_numbers(config): + return {"status": "error", "message": "Refusing to rewrite %s: contains integers above 2^53 that Godot's JSON parser would re-emit imprecise. Edit the file by hand." % path} + if not McpAtomicWrite.write(path, JSON.stringify(_narrow_integral_numbers(config), "\t", false)): + return {"status": "error", "message": "Cannot write to %s" % path} + # Codex F1 caveat: when we wrote a global tier but couldn't find any project + # override in the roots we probed AND the user hasn't told us where Pi (or any + # external cwd client) actually runs from, we can't prove this write is the + # effective config. Surface the actionable hint instead of silently passing. + # The early `not project_tiers.is_empty()` return above already covers the + # "project override exists in probed roots" case (we fail closed there), so + # reaching this point with `project_tiers.is_empty()` means: no probed + # override was found. + var message := McpClient.configured_message(client, server_url) + var external_cwd := str(McpClientConfigurator._editor_setting_lookup(McpClientConfigurator.SETTING_EXTERNAL_CLIENT_CWD)) + if external_cwd.is_empty() and project_tiers.is_empty(): + message += " If %s is launched from a cwd this editor cannot see, the effective entry may still be in a project-tier file — set godot_ai/external_client_cwd to that cwd to verify." % client.display_name + return {"status": "ok", "message": message} + + +static func check_status( + client: McpClient, + server_name: String, + server_url: String, + launch: Dictionary = {}, + project_roots: PackedStringArray = PackedStringArray(), +) -> McpClient.Status: + return check_status_details(client, server_name, server_url, launch, project_roots).get("status", McpClient.Status.NOT_CONFIGURED) + + +## Detailed variant feeding the dock's error_msg plumbing (#711): a config +## file that EXISTS but can't be read or parsed is Status.ERROR carrying the +## read/parse error, not NOT_CONFIGURED — the write path refuses to touch +## such a file (see `_read_or_init`), so the status dot must say "broken +## file", not "click Configure". +static func check_status_details( + client: McpClient, + server_name: String, + server_url: String, + launch: Dictionary = {}, + project_roots: PackedStringArray = PackedStringArray(), +) -> Dictionary: + if _uses_merge_tiers(client): + return _check_status_merged(client, server_name, server_url, launch, project_roots) + var resolution := client.resolved_config_path_details() + var path := str(resolution.get("path", "")) + var path_error := str(resolution.get("error", "")) + if not path_error.is_empty(): + return {"status": McpClient.Status.ERROR, "error_msg": path_error} + if path.is_empty() or not FileAccess.file_exists(path): + return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""} + var read := _read_or_init(path, _status_allows_comments(client)) + if not read["ok"]: + return {"status": McpClient.Status.ERROR, "error_msg": String(read["error"])} + var config: Dictionary = read["data"] + var holder := _walk_path(config, select_server_key_path(config, client)) + if not (holder is Dictionary) or not holder.has(server_name): + return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""} + return _entry_status_details(client, holder[server_name], server_url, launch) + + +## Verify the effective last definition after applying the client's merge order. +static func _check_status_merged( + client: McpClient, + server_name: String, + server_url: String, + launch: Dictionary, + project_roots: PackedStringArray, +) -> Dictionary: + var allow_comments := _status_allows_comments(client) + var loaded := _load_merge_tiers(client, allow_comments) + if not loaded.get("ok", false): + return {"status": McpClient.Status.ERROR, "error_msg": str(loaded.get("error", "Cannot read merged config tiers"))} + var effective: Variant = null + for tier in loaded.get("tiers", []): + var config: Dictionary = tier["data"] + var holder := _walk_path(config, select_server_key_path(config, client)) + if holder is Dictionary and holder.has(server_name): + effective = holder[server_name] + var project := _load_project_definitions(client, server_name, project_roots, allow_comments) + if not project.get("ok", false): + return {"status": McpClient.Status.ERROR, "error_msg": str(project.get("error", "Cannot inspect project config tiers"))} + var project_tiers: Array = project.get("tiers", []) + if not project_tiers.is_empty(): + # Last-definition-wins mirrors the global-tier fold above and how + # pi-codemode-mcp merges project tiers on disk. Earlier tiers are dead + # once a later one defines the same server, so an early-stale entry + # doesn't make Pi's effective config drift (codex-review finding F2). + # Pass `[latest]` to `_project_override_message` so the error names only + # the file the user actually has to edit. + var latest: Dictionary = project_tiers[project_tiers.size() - 1] + var details := _entry_status_details(client, latest["entry"], server_url, launch) + if details.get("status") != McpClient.Status.CONFIGURED: + ## Keep `owned` from the effective entry: the post-update migration + ## decides from it whether this mismatch is ours to repin. + var mismatch := details.duplicate() + mismatch["status"] = McpClient.Status.CONFIGURED_MISMATCH + mismatch["error_msg"] = _project_override_message([latest], "update or remove", client.display_name, server_name) + return mismatch + return {"status": McpClient.Status.CONFIGURED, "error_msg": ""} + if effective == null: + return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""} + return _entry_status_details(client, effective, server_url, launch) + + +static func _entry_status_details( + client: McpClient, + entry: Variant, + server_url: String, + launch: Dictionary, +) -> Dictionary: + if not (entry is Dictionary): + return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""} + var launch_error := command_launch_error(client, launch) + if not launch_error.is_empty(): + return {"status": McpClient.Status.ERROR, "error_msg": launch_error} + if verify_entry(client, entry, server_url, launch): + return {"status": McpClient.Status.CONFIGURED, "error_msg": ""} + return { + "status": McpClient.Status.CONFIGURED_MISMATCH, + "error_msg": "", + "owned": McpClient.launch_values_mention_godot_ai(McpClient.entry_launch_values(entry)), + } + + +static func remove( + client: McpClient, + server_name: String, + project_roots: PackedStringArray = PackedStringArray(), +) -> Dictionary: + if _uses_merge_tiers(client): + return _remove_merged(client, server_name, project_roots) + var resolution := client.resolved_config_path_details() + var path := str(resolution.get("path", "")) + var path_error := str(resolution.get("error", "")) + if not path_error.is_empty(): + return {"status": "error", "message": path_error} + if path.is_empty() or not FileAccess.file_exists(path): + return {"status": "ok", "message": "Not configured"} + # Token-preserving removal (F5): pull the original file text via + # `_read_file_text` and edit only the entry's bytes — keeps every other + # byte, including integers above 2^53, byte-for-byte identical to what + # the user wrote. The parsed-dict round-trip path is gone. + var file_read := _read_file_text(path) + if not file_read.get("ok", false): + return {"status": "error", "message": "Refusing to rewrite %s: %s." % [path, file_read.get("error", "")]} + var config: Dictionary = file_read.get("data", {}) + var key_path := select_server_key_path(config, client) + var original_text: String = str(file_read.get("original_text", "")) + var updated: String = _text_remove_server_entry(original_text, key_path, server_name) + if updated == original_text: + return {"status": "ok", "message": "Not configured"} + if not McpAtomicWrite.write(path, updated): + return {"status": "error", "message": "Cannot write to %s" % path} + return {"status": "ok", "message": "%s configuration removed" % client.display_name} + + +static func _remove_merged( + client: McpClient, server_name: String, project_roots: PackedStringArray +) -> Dictionary: + var project := _load_project_definitions(client, server_name, project_roots) + if not project.get("ok", false): + return {"status": "error", "message": str(project.get("error", "Cannot inspect project config tiers"))} + var project_tiers: Array = project.get("tiers", []) + if not project_tiers.is_empty(): + return {"status": "error", "message": _project_override_message(project_tiers, "remove", client.display_name, server_name)} + var loaded := _load_merge_tiers(client) + if not loaded.get("ok", false): + return {"status": "error", "message": str(loaded.get("error", "Cannot read merged config tiers"))} + var writes: Array[Dictionary] = [] + for tier in loaded.get("tiers", []): + if not tier.get("exists", false): + continue + var key_path := select_server_key_path(tier["data"], client) + var original: String = str(tier.get("original_text", "")) + # Token-preserving removal (F5): edit `original` textually instead of + # re-serialising the parsed config. This preserves every byte outside + # the entry block, so unrelated integers (including those above the + # IEEE-754 exact-int range that `_narrow_integral_numbers` cannot + # round-trip) keep their original literals. + var updated: String = _text_remove_server_entry(original, key_path, server_name) + if updated == original: + continue # entry not in this tier; skip + writes.append({ + "path": tier["path"], + "text": updated, + "original_text": original, + }) + if writes.is_empty(): + return {"status": "ok", "message": "Not configured"} + var written := _write_transaction(writes) + if not written.get("ok", false): + return {"status": "error", "message": written.get("error", "Cannot remove merged configuration")} + return {"status": "ok", "message": "%s configuration removed" % client.display_name} + + +## Synthesize the entry dict the strategy writes under +## `server_key_path[server_name]`. Both URL and command entries deep-copy the +## existing dict before overwriting strategy-owned fields, preserving unknown +## client additions as well as descriptor-documented user fields. +static func build_entry( + client: McpClient, + server_url: String, + existing: Variant = null, + launch: Dictionary = {}, +) -> Dictionary: + if _is_supported_command_shape(client.command_shape): + var command_entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {} + if client.command_shape == McpClient.CommandShape.COMMAND_ARRAY: + ## OpenCode-style: the entry's `command` field IS the argv array. + ## A stale sibling `args` from a FLAT-style hand edit would be + ## ambiguous next to it, so it is strategy-owned and removed. + command_entry["command"] = _launch_argv(launch) + command_entry.erase("args") + else: + command_entry["command"] = str(launch.get("command", "")) + command_entry["args"] = _array_copy(launch.get("args", [])) + if not client.command_transport_key.is_empty(): + command_entry[client.command_transport_key] = client.command_transport_value + for key in client.command_initial_fields: + if not command_entry.has(key): + command_entry[key] = client.command_initial_fields[key] + for key in client.command_legacy_keys: + command_entry.erase(String(key)) + _remove_legacy_env_keys(command_entry, client.command_env_legacy_keys) + return command_entry + if client.command_shape != McpClient.CommandShape.NONE: + return {} + return build_url_entry(client, server_url, existing) + + +static func build_url_entry(client: McpClient, server_url: String, existing: Variant = null) -> Dictionary: + var entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {} + entry[client.entry_url_field] = server_url + for k in client.entry_extra_fields: + entry[k] = client.entry_extra_fields[k] + for k in client.entry_initial_fields: + if not entry.has(k): + entry[k] = client.entry_initial_fields[k] + return entry + + +## Default verifier for a stored entry. Command entries must match every +## launch-affecting value exactly; legacy URL or env keys are migration drift. +## For URL clients, assert `entry[entry_url_field] == url` AND every +## key in `entry_extra_fields` matches verbatim. Type-pinning for Cline / +## Roo / Kilo (`type: "streamable-http"` etc.) falls out of this — pre-fix +## entries that lack the type field fail verification and surface as drift. +static func verify_entry( + client: McpClient, + entry: Dictionary, + server_url: String, + launch: Dictionary = {}, +) -> bool: + if client.command_shape != McpClient.CommandShape.NONE: + if not _is_supported_command_shape(client.command_shape) or not bool(launch.get("ok", false)): + return false + for key in client.command_legacy_keys: + if entry.has(String(key)): + return false + var env = entry.get("env", null) + if env is Dictionary: + for key in client.command_env_legacy_keys: + if env.has(String(key)): + return false + if client.command_shape == McpClient.CommandShape.COMMAND_ARRAY: + if not _arrays_equal(entry.get("command", null), _launch_argv(launch)): + return false + if entry.has("args"): + return false + else: + if entry.get("command") != launch.get("command"): + return false + if not _arrays_equal(entry.get("args", null), launch.get("args", null)): + return false + if not client.command_transport_key.is_empty(): + if not entry.has(client.command_transport_key): + return false + if entry.get(client.command_transport_key) != client.command_transport_value: + return false + return true + if entry.get(client.entry_url_field, "") != server_url: + return false + for k in client.entry_extra_fields: + if entry.get(k) != client.entry_extra_fields[k]: + return false + return true + + +static func command_launch_error(client: McpClient, launch: Dictionary) -> String: + if client.command_shape == McpClient.CommandShape.NONE: + return "" + if not _is_supported_command_shape(client.command_shape): + return "%s uses a command shape not supported by JSON yet" % client.display_name + if not bool(launch.get("ok", false)): + return str(launch.get("error", "No compatible attach launcher was found.")) + return "" + + +static func _is_supported_command_shape(shape: McpClient.CommandShape) -> bool: + return shape == McpClient.CommandShape.FLAT or shape == McpClient.CommandShape.COMMAND_ARRAY + + +## The full launch argv as one array: launcher path followed by every arg. +static func _launch_argv(launch: Dictionary) -> Array: + var argv: Array = [str(launch.get("command", ""))] + argv.append_array(_array_copy(launch.get("args", []))) + return argv + + +static func _remove_legacy_env_keys(entry: Dictionary, legacy_keys: PackedStringArray) -> void: + if legacy_keys.is_empty(): + return + var existing_env = entry.get("env", null) + if not (existing_env is Dictionary): + return + var env: Dictionary = (existing_env as Dictionary).duplicate(true) + for key in legacy_keys: + env.erase(String(key)) + if env.is_empty(): + entry.erase("env") + else: + entry["env"] = env + + +static func _array_copy(value: Variant) -> Array: + if value is Array: + return (value as Array).duplicate(true) + if value is PackedStringArray: + return McpClient._array_from_packed(value) + return [] + + +static func _arrays_equal(left: Variant, right: Variant) -> bool: + if not (left is Array or left is PackedStringArray): + return false + if not (right is Array or right is PackedStringArray): + return false + var left_array := _array_copy(left) + var right_array := _array_copy(right) + if left_array.size() != right_array.size(): + return false + for i in range(left_array.size()): + if left_array[i] != right_array[i]: + return false + return true + + +## Read a config file once and parse the captured text. Returns +## {"exists": bool, "ok": bool, "data": Dictionary, "original_text": String} +## when the file is absent or parses cleanly, and +## {"exists": true, "ok": false, "error": String, "original_text": String} +## when the file exists with non-empty content we cannot safely round-trip. +## Callers must NOT treat the error path as an empty config — doing so blows +## away the user's other MCP entries on the next write. The `original_text` +## is the exact captured source so transactional rollback can restore +## byte-for-byte; the UTF-8 BOM is stripped only from the parsing copy. +## `allow_comments` also strips JSONC comments from the parsing copy; only +## read-only callers pass it (see `_status_allows_comments`). +static func _read_file_text(path: String, allow_comments: bool = false) -> Dictionary: + if not FileAccess.file_exists(path): + return {"exists": false, "ok": true, "data": {}, "original_text": ""} + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + var open_err := FileAccess.get_open_error() + return {"exists": true, "ok": false, "error": "could not open for reading (error %d)" % open_err, "original_text": ""} + # Read bytes, not `get_as_text()`. Godot's UTF-8 decoder (both `get_as_text` + # and `get_string_from_utf8`) silently consumes a leading EF BB BF, so a + # text-only read dropped the BOM from `original_text` and the next Remove + # wrote it back out of the user's file — a byte mutation outside the entry + # we were asked to touch. Detect the marker on the raw buffer and restore + # U+FEFF when the decoder ate it. + var buf := file.get_buffer(file.get_length()) + file.close() + var had_bom := buf.size() >= 3 and buf[0] == 0xEF and buf[1] == 0xBB and buf[2] == 0xBF + var content := buf.get_string_from_utf8() + if had_bom and not content.begins_with(""): + content = "" + content + # Everything downstream of the BOM parses and measures the body: JSON.parse + # rejects a leading U+FEFF outright, and a BOM-only file is empty, not + # broken. `original_text` keeps the marker so writes round-trip it. + var body := content.substr(1) if content.begins_with("") else content + if body.strip_edges().is_empty(): + return {"exists": true, "ok": true, "data": {}, "original_text": content} + var parse_copy := body + if allow_comments: + # Parse copy only — `original_text` stays byte-for-byte, and an + # unterminated block comment fails closed rather than letting the + # leftover source parse as valid JSON. + var stripped := _strip_jsonc(parse_copy) + if not stripped.get("ok", false): + var strip_msg := str(stripped.get("error", "invalid JSONC")) + push_warning("MCP | %s in %s" % [strip_msg, path]) + return {"exists": true, "ok": false, "error": strip_msg, "original_text": content} + parse_copy = str(stripped.get("text", "")) + var json := JSON.new() + if json.parse(parse_copy) != OK: + var msg := "JSON parse error on line %d: %s" % [json.get_error_line(), json.get_error_message()] + push_warning("MCP | %s in %s" % [msg, path]) + return {"exists": true, "ok": false, "error": msg, "original_text": content} + if not (json.data is Dictionary): + return {"exists": true, "ok": false, "error": "top-level value is %s, expected object" % type_string(typeof(json.data)), "original_text": content} + return {"exists": true, "ok": true, "data": json.data, "original_text": content} + + +## Read-only by construction: requiring both flags means no Configure/Remove +## can parse a file it would then re-serialize without the discarded comments. +## Re-enabling automatic edits restores the parse error instead of a lossy write. +static func _status_allows_comments(client: McpClient) -> bool: + return client.config_allows_comments and not client.automatic_config_edits + + +## Strip `//` and `/* */` comments, leaving sequences inside JSON strings +## intact. Parse copies only. +## Returns `{"ok": true, "text": String}` or `{"ok": false, "error": String}`. +static func _strip_jsonc(text: String) -> Dictionary: + var parts := PackedStringArray() + var i := 0 + var n := text.length() + var in_string := false + var escape := false + var run_start := 0 + while i < n: + var c := text[i] + if in_string: + if escape: + escape = false + elif c == "\\": + escape = true + elif c == '"': + in_string = false + i += 1 + continue + if c == '"': + in_string = true + i += 1 + continue + var skipped := _skip_jsonc_comment_at(text, i) + if skipped < 0: + return {"ok": false, "error": "unterminated block comment"} + if skipped != i: + if i > run_start: + parts.append(text.substr(run_start, i - run_start)) + # Re-emit newlines so JSON.parse error lines still match the source. + var emitted_nl := false + for j in range(i, skipped): + if text[j] == "\n": + parts.append("\n") + emitted_nl = true + # A comment must not glue its neighbours (`tru/* x */e` is not + # `true`). A newline already splits them; otherwise use a space. + if not emitted_nl: + parts.append(" ") + i = skipped + run_start = i + continue + i += 1 + if in_string: + return {"ok": false, "error": "unterminated string"} + if n > run_start: + parts.append(text.substr(run_start, n - run_start)) + return {"ok": true, "text": "".join(parts)} + + +## Skip a JSONC comment starting at `i`. Returns `i` unchanged when `i` is not +## a comment opener, the index after a consumed comment, or -1 when a block +## comment runs to EOF without `*/`. +static func _skip_jsonc_comment_at(text: String, i: int) -> int: + var n := text.length() + if i + 1 >= n or text[i] != "/": + return i + if text[i + 1] == "/": + i += 2 + # `get_string_from_utf8()` keeps CR, so a CR-only file never presents a `\n` and + # a `\n`-only terminator would swallow the rest of the file. + while i < n and text[i] != "\n" and text[i] != "\r": + i += 1 + return i + if text[i + 1] == "*": + i += 2 + while i + 1 < n: + if text[i] == "*" and text[i + 1] == "/": + return i + 2 + i += 1 + return -1 + return i + + +## 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, allow_comments: bool = false) -> Dictionary: + var read := _read_file_text(path, allow_comments) + var result: Dictionary = {"ok": read.get("ok", false), "data": read.get("data", {})} + if not result.get("ok", false): + result["error"] = read.get("error", "") + return result + + +## Walk a key path, creating intermediate Dicts as needed. Returns the leaf Dict. +static func _ensure_path(root: Dictionary, key_path: PackedStringArray) -> Dictionary: + var cur := root + for key in key_path: + var next = cur.get(key) + if not (next is Dictionary): + next = {} + cur[key] = next + cur = next + return cur + + +## Walk a key path, returning the leaf Dict if all hops exist; else null. +static func _walk_path(root: Dictionary, key_path: PackedStringArray) -> Variant: + var cur: Variant = root + for key in key_path: + if not (cur is Dictionary) or not cur.has(key): + return null + cur = cur[key] + return cur + + +## Match clients that accept multiple server-map keys without creating a +## higher-precedence canonical map that shadows an existing legacy map. A +## non-null scalar still wins, matching nullish-coalescing parsers; Configure's +## `_ensure_path` then repairs it using the strategy's existing behavior. +static func select_server_key_path(root: Dictionary, client: McpClient) -> PackedStringArray: + var candidates: Array[PackedStringArray] = [client.server_key_path] + # Dynamic access keeps mixed-snapshot self-updates parse-safe when a new + # strategy is briefly loaded with an older McpClient base (#398/#736). + var aliases = client.get("server_key_path_aliases") + if aliases is Array: + for alias in aliases: + if alias is PackedStringArray: + candidates.append(alias) + for key_path in candidates: + if _walk_path(root, key_path) != null: + return key_path + return client.server_key_path + + +static func _uses_merge_tiers(client: McpClient) -> bool: + var templates = client.get("config_merge_path_templates") + return templates is Dictionary and not templates.is_empty() + + +## Resolve the global config tiers in the client's documented merge order. +static func _merge_paths(client: McpClient) -> PackedStringArray: + var result := PackedStringArray() + var templates = client.get("config_merge_path_templates") + if templates is Dictionary: + var platform_key := McpPathTemplate.platform_key(templates) + if not platform_key.is_empty(): + var raw_paths: Variant = templates.get(platform_key, []) + if raw_paths is Array or raw_paths is PackedStringArray: + for template in raw_paths: + var path := McpPathTemplate.expand(str(template)).simplify_path() + if not path.is_empty() and not result.has(path): + result.append(path) + return result + + +## Pi's project tiers are relative to Pi's process cwd, not Godot's. Inspect +## plausible roots only to detect overrides; callers fail closed rather than +## mutating both guesses. +static func _project_candidate_paths( + client: McpClient, roots: PackedStringArray +) -> PackedStringArray: + var result := PackedStringArray() + var project_paths = client.get("config_merge_project_paths") + if not (project_paths is PackedStringArray): + return result + for project_path in project_paths: + var absolute_path := String(project_path).simplify_path() + if absolute_path.is_absolute_path() and FileAccess.file_exists(absolute_path) and not result.has(absolute_path): + result.append(absolute_path) + for root in roots: + if String(root).is_empty(): + continue + for relative_path in project_paths: + if String(relative_path).is_absolute_path(): + continue + var path := String(root).path_join(String(relative_path)).simplify_path() + if FileAccess.file_exists(path) and not result.has(path): + result.append(path) + return result + + +static func _load_project_definitions( + client: McpClient, + server_name: String, + project_roots: PackedStringArray, + allow_comments: bool = false, +) -> Dictionary: + var tiers: Array[Dictionary] = [] + for path in _project_candidate_paths(client, project_roots): + var read := _read_or_init(path, allow_comments) + if not read.get("ok", false): + return {"ok": false, "error": "Cannot inspect project config %s: %s" % [path, read.get("error", "invalid JSON")]} + var config: Dictionary = read["data"] + var key_path := select_server_key_path(config, client) + var holder := _walk_path(config, key_path) + if holder is Dictionary and holder.has(server_name): + tiers.append({ + "path": path, + "data": config, + "key_path": key_path, + "entry": holder[server_name], + }) + return {"ok": true, "tiers": tiers} + + +static func _project_override_message( + tiers: Array, action: String, client_name: String, server_name: String +) -> String: + var paths := PackedStringArray() + for tier in tiers: + paths.append(str(tier["path"])) + return "%s project config overrides %s at %s. %s resolves project files from its own working directory, so the dock cannot safely choose one; %s the entry manually." % [client_name, server_name, ", ".join(paths), client_name, action] + + +static func _load_merge_tiers(client: McpClient, allow_comments: bool = false) -> Dictionary: + var tiers: Array[Dictionary] = [] + for path in _merge_paths(client): + ## These templates never pass through `resolved_config_path_details`, + ## so they need its fail-closed gate here: `McpPathTemplate.expand` + ## leaves a token it cannot resolve in place, and the relative survivor + ## would be read — and on Configure, written — against the editor's own + ## working directory. Fail the whole fold rather than dropping the tier: + ## a dropped tier reads as "the user has no config there", which is + ## exactly the wrong thing to conclude when we could not look. + if not path.is_absolute_path(): + return { + "ok": false, + "error": McpClient.unresolved_config_path_error(client.display_name, path), + } + var read := _read_file_text(path, allow_comments) + if not read.get("ok", false): + return { + "ok": false, + "error": "Refusing to rewrite %s: %s. Fix or move the file, then re-run Configure." % [path, read.get("error", "invalid JSON")], + } + tiers.append({ + "path": path, + "exists": read.get("exists", false), + "original_text": read.get("original_text", ""), + "data": read.get("data", {}), + }) + return {"ok": true, "tiers": tiers} + + +## Commit a multi-file removal with best-effort rollback. Each individual write +## is atomic; this layer restores earlier tiers if a later commit fails. +static func _write_transaction(writes: Array[Dictionary]) -> Dictionary: + var completed: Array[Dictionary] = [] + for write in writes: + var path := str(write["path"]) + if McpAtomicWrite.write(path, str(write["text"])): + completed.append(write) + continue + var rollback_failed := PackedStringArray() + completed.reverse() + for previous in completed: + if not McpAtomicWrite.write(str(previous["path"]), str(previous["original_text"])): + rollback_failed.append(str(previous["path"])) + var suffix := "" + if not rollback_failed.is_empty(): + suffix = " Rollback also failed for: %s" % ", ".join(rollback_failed) + return {"ok": false, "error": "Cannot write to %s; earlier tier changes were rolled back.%s" % [path, suffix]} + return {"ok": true} +## Path that `_check_status_merged`'s last-wins logic would consider +## authoritative for the server entry, or "" when no entry is found in +## any tier. Mirrors the same project-tier / global-tier resolution the +## status check uses so callers (notably the dock Open/Reveal buttons) +## land on the file that actually drives Pi's effective config. +## +## Resolution order (codex round 3, F-3-4): +## 1. Latest project tier containing the entry — F2 last-wins means the +## latest of `.pi/mcp.json` and `.mcp.json` wins (matches `_check_status_merged`). +## 2. Latest global tier containing the entry — already iterated in +## merge order, last-iterated-wins (same loop pattern as +## `manual_target_details` lines 660-665). +## 3. "" when no tier has the entry — caller decides what fallback +## (`path_template`) to use. +## +## Returns "" (not `path_template`) on the "nothing anywhere" branch so +## the dock can distinguish "no entry yet" from "entry is at the +## template path" if it wants to. +static func authoritative_tier_path( + client: McpClient, + server_name: String, + project_roots: PackedStringArray, +) -> String: + if not _uses_merge_tiers(client): + return "" + var allow_comments := _status_allows_comments(client) + var project := _load_project_definitions(client, server_name, project_roots, allow_comments) + if not bool(project.get("ok", false)): + return "" + var project_tiers: Array = project.get("tiers", []) + if not project_tiers.is_empty(): + # F2 last-wins: latest project tier is authoritative. When there are + # multiple project tiers, the user-visible status is driven by the + # latest, so the Open/Reveal buttons should send them there too. + var latest: Dictionary = project_tiers[project_tiers.size() - 1] + return str(latest.get("path", "")) + var loaded := _load_merge_tiers(client, allow_comments) + if not bool(loaded.get("ok", false)): + return "" + var tiers: Array = loaded.get("tiers", []) + # Iterate in priority order, overwrite `selected_path` each time we + # find a tier containing the entry. With Pi's merge path order + # [mcp.json, .mcp.json] this leaves the higher-precedence `.mcp.json`. + var selected_path: String = "" + for tier in tiers: + var data: Dictionary = tier.get("data", {}) + var holder: Variant = _walk_path(data, select_server_key_path(data, client)) + if holder is Dictionary and holder.has(server_name): + selected_path = str(tier.get("path", "")) + return selected_path + + +## Pick the file and top-level map shown by the manual JSON instructions using +## the same merge and alias precedence as automatic Configure. +static func manual_target_details( + client: McpClient, + server_name: String, + fallback_path: String, + project_roots: PackedStringArray = PackedStringArray(), +) -> Dictionary: + # A read: the instructions a JSONC client is sent to must not carry + # "Target inspection failed" for the file its own client ships. + var allow_comments := _status_allows_comments(client) + if _uses_merge_tiers(client): + var project := _load_project_definitions(client, server_name, project_roots, allow_comments) + if not project.get("ok", false): + return {"ok": false, "error": project.get("error", "Cannot inspect project config tiers")} + var project_tiers: Array = project.get("tiers", []) + if project_tiers.size() > 1: + return {"ok": false, "error": _project_override_message(project_tiers, "update or remove", client.display_name, server_name)} + if project_tiers.size() == 1: + var selected_project: Dictionary = project_tiers[0] + return { + "ok": true, + "path": selected_project["path"], + "key_path": selected_project["key_path"], + } + var loaded := _load_merge_tiers(client, allow_comments) + if not loaded.get("ok", false): + return {"ok": false, "error": loaded.get("error", "Cannot read merged config tiers")} + var tiers: Array = loaded.get("tiers", []) + var selected: Variant = tiers[0] if not tiers.is_empty() else null + for tier in tiers: + var config: Dictionary = tier["data"] + var holder := _walk_path(config, select_server_key_path(config, client)) + if holder is Dictionary and holder.has(server_name): + selected = tier + if selected != null: + var config: Dictionary = selected["data"] + return { + "ok": true, + "path": selected["path"], + "key_path": select_server_key_path(config, client), + } + var read := _read_or_init(fallback_path, allow_comments) + if not read.get("ok", false): + return {"ok": false, "error": "Cannot inspect %s: %s" % [fallback_path, read.get("error", "invalid JSON")]} + return { + "ok": true, + "path": fallback_path, + "key_path": select_server_key_path(read["data"], client), + } + + +## Godot's JSON.parse turns every JSON number into a float, so a later +## JSON.stringify re-emits the user's integer fields as "8080.0" — which strict +## consumers (Go's encoding/json into an int field, etc.) reject, and which +## needlessly rewrites every number across the user's *other* entries. Re-narrow +## exactly-representable integral floats back to int so they serialize without +## the ".0". Walks dicts/arrays in place and returns the (same) value. +## +## Integers above 2^53 already lost precision when Godot parsed them to double, +## so they're left as the float Godot produced rather than faking exactness — +## byte-perfect preservation would require not parsing the file at all, and such +## magnitudes don't occur in MCP client configs. +static func _narrow_integral_numbers(value: Variant) -> Variant: + match typeof(value): + TYPE_FLOAT: + if is_finite(value) and value == floor(value) and absf(value) <= 9007199254740992.0: + return int(value) + TYPE_DICTIONARY: + for k in value: + value[k] = _narrow_integral_numbers(value[k]) + TYPE_ARRAY: + for i in value.size(): + value[i] = _narrow_integral_numbers(value[i]) + return value + + +## True when `value` contains any float outside the IEEE-754 exact-int range +## (`abs(f) > 2^53`) that would lose precision when JSON.stringify re-emits it. +## The configure path refuses such files instead of silently mutating unrelated +## integers; the remove path never needs this check because it does +## token-preserving surgery (F5). +static func _has_lossy_numbers(value: Variant) -> bool: + match typeof(value): + TYPE_FLOAT: + # `>=` not `>`: `2^53 + 1` rounds down to `2^53.0` when Godot parses + # the integer to double, so even a "clean" `2^53.0` after parse may + # have lost a least-significant bit we cannot recover. Refusing at + # the boundary is conservative but correct — and 2^53+ magnitudes + # don't appear in real MCP client configs anyway. + if is_finite(value) and absf(value) >= 9007199254740992.0: + return true + TYPE_DICTIONARY: + for k in value: + if _has_lossy_numbers(value[k]): + return true + TYPE_ARRAY: + for i in value.size(): + if _has_lossy_numbers(value[i]): + return true + return false + + +## Token-preserving entry removal. Walks the JSON text with a brace-balanced +## scanner to locate the entry at `key_path[0]/.../[server_name]` and returns +## `text` with that entry's bytes deleted (along with the surrounding +## whitespace + trailing comma). Everything else — including integers above +## the IEEE-754 exact-int range that `_narrow_integral_numbers` would silently +## mutate — is preserved byte-for-byte. Returns `text` unchanged when the +## entry isn't found in `text` (callers treat this as a no-op write). +static func _text_remove_server_entry(text: String, key_path: PackedStringArray, server_name: String) -> String: + # Descend through `key_path`. Each step finds `"":` directly inside the + # current container and moves the cursor past the value's opening `{` or `[`. + # After the loop, `cursor` is positioned just past the opening bracket of the + # container that holds our entry, and `container_depth` tracks how deeply + # nested that container is from the root (0 = root object/array). + var cursor := 0 + # Skip past the root's opening `{` or `[` (and any leading whitespace or + # UTF-8 BOM) so `_find_key_at_container_depth` starts scanning from + # inside the root container with `inner_depth == 0`. Without this step, + # the first character encountered is the root's `{`, which bumps + # `inner_depth` to 1 and makes the top-level key check fail. The BOM + # skip mirrors `_read_file_text`'s parse_copy BOM-strip above so the + # scanner sees the same view of the file the parser does; the BOM + # itself stays in `text` so the byte-survival F5 contract still holds + # (codex round 3, F-3-6 — without it, files saved with a Windows BOM + # left the entry in place after Remove). + # The BOM can only ever be byte 0, so it is skipped BEFORE the whitespace + # walk: a file saved as BOM + newline + `{` (common from Windows editors) + # otherwise leaves the cursor on the newline, the root `{` is never + # consumed, and the entry silently survives Remove. + if cursor < text.length() and text[cursor] == "": + cursor += 1 + while cursor < text.length() and _is_json_ws(text[cursor]): + cursor += 1 + if cursor < text.length() and (text[cursor] == "{" or text[cursor] == "["): + cursor += 1 + var container_depth := 0 + for key in key_path: + var key_bytes := JSON.stringify(String(key)) + var match := _find_key_at_container_depth(text, cursor, key_bytes) + if match.is_empty(): + return text + var open_char := text[match["value_start"]] + if open_char != "{" and open_char != "[": + return text # leaf isn't an object/array — path can't reach our entry + container_depth += 1 + cursor = match["value_start"] + 1 + # Now find `"":` at the top level of the container we descended + # into (the helper gates on `inner_depth == 0`). + var entry_bytes := JSON.stringify(server_name) + var entry_match := _find_key_at_container_depth(text, cursor, entry_bytes) + if entry_match.is_empty(): + return text + var key_start: int = entry_match["key_start"] + var value_end: int = entry_match["value_end"] + # Trim the surrounding comma in a mutually exclusive way: the trailing comma + # (after the entry's value) carries the burden when the entry is NOT the last + # in its container, and the leading comma (before the entry's key) carries it + # only when the entry IS the last. Trimming BOTH unconditionally corrupts a + # middle-position entry into `"x"}"y"` with no separator — codex-review + # finding F5 (regression after the initial round). + var remove_end := value_end + # Skip JSON whitespace before checking for the trailing comma — valid + # (if unusual) formats like `"godot-ai":{} ,"other":{}` put whitespace + # between the value and its separator. codex round 4 finding. + var trailing_scan := remove_end + while trailing_scan < text.length() and _is_json_ws(text[trailing_scan]): + trailing_scan += 1 + var had_trailing_comma := trailing_scan < text.length() and text[trailing_scan] == "," + if had_trailing_comma: + # Jump past the comma (and any whitespace before it we just skipped). + remove_end = trailing_scan + 1 + while remove_end < text.length() and _is_json_ws(text[remove_end]): + remove_end += 1 + var remove_start := key_start + if not had_trailing_comma: + while remove_start > 0 and _is_json_ws(text[remove_start - 1]): + remove_start -= 1 + if remove_start > 0 and text[remove_start - 1] == ",": + remove_start -= 1 + while remove_start > 0 and _is_json_ws(text[remove_start - 1]): + remove_start -= 1 + return text.substr(0, remove_start) + text.substr(remove_end) + + +## Look for `key_bytes` (a JSON-encoded string literal including the surrounding +## quotes) at the TOP LEVEL of the container we're inside, scanning `text` from +## `start` onwards. The caller is expected to advance `start` just past the +## container's opening `{` or `[`, so we begin with `inner_depth == 0` and a +## key matches when we hit it while `inner_depth` is still 0. The scan exits +## immediately if `inner_depth` drops below 0 (the target container closed) +## to prevent same-named keys in later sibling containers from being picked +## up. Returns `{key_start, value_start, value_end}` on success or `{}` if +## the key isn't present in that container. +static func _find_key_at_container_depth(text: String, start: int, key_bytes: String) -> Dictionary: + var i := start + var in_string := false + var escape := false + var inner_depth := 0 # depth *inside* the current container + while i < text.length(): + var c := text[i] + if in_string: + if escape: + escape = false + elif c == "\\": + escape = true + elif c == '"': + in_string = false + i += 1 + continue + if c == '"': + if text.substr(i, key_bytes.length()) == key_bytes: + var after_key := i + key_bytes.length() + while after_key < text.length() and _is_json_ws(text[after_key]): + after_key += 1 + if after_key < text.length() and text[after_key] == ":": + var value_start := after_key + 1 + while value_start < text.length() and _is_json_ws(text[value_start]): + value_start += 1 + var value_end := _json_value_span_end(text, value_start) + if value_end >= 0 and inner_depth == 0: + return {"key_start": i, "value_start": value_start, "value_end": value_end} + in_string = true + i += 1 + continue + if c == "{" or c == "[": + inner_depth += 1 + elif c == "}" or c == "]": + inner_depth -= 1 + # Bail out the moment we leave the container we're scanning. Without + # this guard, a later sibling container at the same level (e.g. a + # top-level `extensions` block after `mcpServers` closes) re-balances + # `inner_depth` back to 0 and a same-named key in that sibling would + # match — silently deleting data we never intended to touch + # (codex-review finding F5, regression after the initial round). + if inner_depth < 0: + return {} + i += 1 + return {} + + +## Given `value_start` pointing at the first char of a JSON value, return the +## position AFTER the value's last char. Handles objects, arrays, strings, +## and bare literals (numbers, true/false/null). Returns -1 if the value is +## unterminated. +static func _json_value_span_end(text: String, value_start: int) -> int: + if value_start >= text.length(): + return -1 + var c := text[value_start] + if c == "{" or c == "[": + var open_char := c + var close_char := "}" if c == "{" else "]" + var depth := 1 + var i := value_start + 1 + var in_string := false + var escape := false + while i < text.length() and depth > 0: + var ch := text[i] + if in_string: + if escape: + escape = false + elif ch == "\\": + escape = true + elif ch == '"': + in_string = false + i += 1 + continue + if ch == '"': + in_string = true + elif ch == open_char: + depth += 1 + elif ch == close_char: + depth -= 1 + i += 1 + return i if depth == 0 else -1 + if c == '"': + var i := value_start + 1 + var escape := false + while i < text.length(): + var ch := text[i] + if escape: + escape = false + elif ch == "\\": + escape = true + elif ch == '"': + return i + 1 + i += 1 + return -1 + # Bare literal: number / true / false / null. Walk until a JSON-significant + # delimiter (`,`, `}`, `]`, or whitespace). + var i := value_start + while i < text.length(): + var ch := text[i] + if ch == "," or ch == "}" or ch == "]" or _is_json_ws(ch): + break + i += 1 + return i + + +static func _is_json_ws(c: String) -> bool: + return c == " " or c == "\t" or c == "\n" or c == "\r" diff --git a/addons/godot_ai/clients/_json_strategy.gd.uid b/addons/godot_ai/clients/_json_strategy.gd.uid 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..fef2ffc --- /dev/null +++ b/addons/godot_ai/clients/_manual_command.gd @@ -0,0 +1,353 @@ +@tool +class_name McpManualCommand +extends RefCounted + +const SHELL_POSIX := "posix" +const SHELL_POWERSHELL := "powershell" +## Keep this intersection deliberately small. PowerShell treats a leading `@` +## as splatting syntax and commas as list separators, while POSIX shells accept +## both literally; quoting either is safer than trying to infer token position. +const _SHELL_BARE_SAFE_CHARS := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_+=:./-" + +## Synthesize the "Run this manually" string the dock surfaces when +## auto-configure can't find a CLI / write a file. Generated from the +## descriptor's declarative fields — there is no per-client builder +## Callable. See `_base.gd` for why descriptors are data-only. + + +static func build( + client: McpClient, + server_name: String, + server_url: String, + resolved_path: String, + launch: Dictionary = {}, + project_roots: PackedStringArray = PackedStringArray(), +) -> String: + if client.command_shape == McpClient.CommandShape.NONE: + return "%s has no authenticated v4 configuration path; do not persist the backend URL." % client.display_name + match client.config_type: + "cli": + return _build_cli(client, server_name, server_url, resolved_path, launch, project_roots) + "json": + return _build_json(client, server_name, server_url, resolved_path, launch, project_roots) + "toml": + return _build_toml(client, server_name, server_url, resolved_path, launch) + "yaml": + return _build_yaml(client, server_name, server_url, resolved_path, launch) + "dsh": + return _build_dsh(client, server_name, server_url, resolved_path, launch) + return "" + + +## CLI clients: format the register template against the *short* CLI name so +## the user can paste it into a terminal regardless of where their binary +## lives. (The auto-configure path resolves to an absolute uvx-style path; +## that's noise for a paste-into-terminal hint. The attach launcher path +## inside a command-shape line stays absolute — status verification compares +## the registered command against the resolved launcher verbatim.) +static func _build_cli( + client: McpClient, + server_name: String, + server_url: String, + resolved_path: String = "", + launch: Dictionary = {}, + project_roots: PackedStringArray = PackedStringArray(), +) -> String: + if client.cli_register_template.is_empty() or client.cli_names.is_empty(): + return "" + var shell_kind := _shell_kind_for_platform() + var short_name: String = String(client.cli_names[0]) + # Prefer the non-.exe form for a cross-platform-looking command line. + for n in client.cli_names: + if not String(n).ends_with(".exe"): + short_name = String(n) + break + var cmd := "" + if client.command_shape != McpClient.CommandShape.NONE: + var launch_error := McpCliStrategy.command_launch_error(client, launch) + if not launch_error.is_empty(): + cmd = "Attach launch command unavailable: %s" % launch_error + else: + var args := McpCliStrategy.format_args(client.cli_register_template, server_name, server_url, launch) + var parts: Array[String] = [short_name] + for arg in args: + parts.append(String(arg)) + var lines := _sweep_lines(client, server_name, server_url, short_name, shell_kind) + lines.append(_render_command_line(parts, shell_kind)) + cmd = _format_shell_block(lines, shell_kind) + _sweep_caveat(client) + else: + var args := McpCliStrategy.format_args(client.cli_register_template, server_name, server_url) + var parts: Array[String] = [short_name] + for arg in args: + parts.append(String(arg)) + var lines := _sweep_lines(client, server_name, server_url, short_name, shell_kind) + lines.append(_render_command_line(parts, shell_kind)) + cmd = _format_shell_block(lines, shell_kind) + _sweep_caveat(client) + # #463: a CLI client with a JSON fallback (Claude Code) may have no `claude` + # binary at all — e.g. installed only as a VS Code/Cursor extension. The CLI + # line above is useless to that user, so also show the config-file edit that + # auto-configure falls back to writing. + if client.has_json_fallback() and not resolved_path.is_empty(): + return "%s\n\nNo `%s` CLI (e.g. installed as a VS Code/Cursor extension)? %s" % [ + cmd, short_name, _build_json(client, server_name, server_url, resolved_path, launch, project_roots), + ] + return cmd + + +## #877: Configure's first act is the pre-cleanup — it runs the unregister +## template once per scope the descriptor can write to (`_cli_strategy.gd` +## `configure`) before registering. Rendering only the register line made this +## hint disagree with what the button does, which is the worst property a +## "run this manually" string can have. The removes go in the SAME shell block +## as the register, in the order Configure runs them, so the whole thing stays +## one copy-paste rather than a labelled command plus loose prose. +static func _sweep_lines( + client: McpClient, + server_name: String, + server_url: String, + short_name: String, + shell_kind: String, +) -> Array[String]: + var lines: Array[String] = [] + if client.cli_unregister_template.is_empty(): + return lines + for pre_scope in McpCliStrategy.cleanup_scopes(client): + var args := McpCliStrategy.format_args( + client.cli_unregister_template, server_name, server_url, {}, pre_scope + ) + var parts: Array[String] = [short_name] + for arg in args: + parts.append(String(arg)) + lines.append(_render_command_line(parts, shell_kind)) + return lines + + +## The one thing the commands themselves cannot show: that the project pass +## resolves `.mcp.json` against the CLI's cwd, so it can delete an entry from a +## repository that has nothing to do with this project. Only for `{scope}` +## descriptors — a single implicit-scope pass removes exactly the entry the +## register is about to rewrite, which needs no warning. Kept outside the shell +## block so pasting the block never picks up prose. +static func _sweep_caveat(client: McpClient) -> String: + if client.cli_unregister_template.is_empty() or not McpCliStrategy.uses_scope_token(client): + return "" + return ( + "\n\nThe project-scope remove rewrites the .mcp.json in the directory the" + + " editor was launched from — not necessarily this project — so it drops a" + + " hand-maintained godot-ai entry there. Other servers in that file are left" + + " alone." + ) + + +static func _shell_kind_for_platform() -> String: + return SHELL_POWERSHELL if OS.get_name() == "Windows" else SHELL_POSIX + + +## Render a command for one explicitly named shell. The label is load-bearing: +## POSIX and PowerShell use different escaping for embedded single quotes, so +## presenting the command without its target shell invites a bad copy/paste. +static func _format_shell_command(parts: Array[String], shell_kind: String) -> String: + return _format_shell_block([_render_command_line(parts, shell_kind)], shell_kind) + + +## One label over N command lines. The label is per-block, not per-line: +## repeating "Run in PowerShell:" four times would bury the single line that +## actually registers. +static func _format_shell_block(lines: Array[String], shell_kind: String) -> String: + var label := "Run in PowerShell:" if shell_kind == SHELL_POWERSHELL else "Run in a POSIX shell:" + return "%s\n%s" % [label, "\n".join(lines)] + + +static func _render_command_line(parts: Array[String], shell_kind: String) -> String: + var rendered: Array[String] = [] + for part in parts: + rendered.append(_shell_display_arg(part, shell_kind)) + return " ".join(rendered) + + +## Quote one argv element for the paste-into-terminal hint. Single-quoted +## strings are literal in both supported shells, but embedded single quotes +## have shell-specific spellings. Backslashes, double quotes, dollar signs, +## and PowerShell backticks remain byte-for-byte unchanged inside the quotes. +static func _shell_display_arg(arg: String, shell_kind: String) -> String: + if arg.is_empty(): + return "''" + var stays_bare := true + for index in range(arg.length()): + if _SHELL_BARE_SAFE_CHARS.find(arg.substr(index, 1)) < 0: + stays_bare = false + break + if stays_bare: + return arg + if shell_kind == SHELL_POWERSHELL: + return "'%s'" % arg.replace("'", "''") + return "'%s'" % arg.replace("'", "'\"'\"'") + + +static func _build_json( + client: McpClient, + server_name: String, + server_url: String, + resolved_path: String, + launch: Dictionary = {}, + project_roots: PackedStringArray = PackedStringArray(), +) -> String: + var target := McpJsonStrategy.manual_target_details(client, server_name, resolved_path, project_roots) + var target_note := "" + if not target.get("ok", false): + target_note = "Target inspection failed: %s" % str(target.get("error", "cannot inspect config")) + target = {"path": resolved_path, "key_path": client.server_key_path} + var target_path := str(target.get("path", resolved_path)) + var key_path: PackedStringArray = target.get("key_path", client.server_key_path) + var key := key_path[0] if key_path.size() > 0 else "mcpServers" + if client.command_shape != McpClient.CommandShape.NONE: + var lines: Array[String] = [] + var launch_error := McpJsonStrategy.command_launch_error(client, launch) + if launch_error.is_empty(): + var command_entry := McpJsonStrategy.build_entry(client, server_url, null, launch) + lines.append("Edit %s and add under \"%s\":" % [target_path, key]) + lines.append(" \"%s\": %s" % [server_name, _format_entry_inline(command_entry)]) + else: + lines.append("Attach launch command unavailable: %s" % launch_error) + if not target_note.is_empty(): + lines.append("") + lines.append(target_note) + return "\n".join(lines) + var entry := McpJsonStrategy.build_entry(client, server_url) + var instructions := "Edit %s and add under \"%s\":\n \"%s\": %s" % [target_path, key, server_name, _format_entry_inline(entry)] + if not target_note.is_empty(): + instructions += "\n\n" + target_note + return instructions + + +static func _build_toml( + client: McpClient, + _server_name: String, + server_url: String, + resolved_path: String, + launch: Dictionary = {}, +) -> String: + var header := _toml_header(client) + if client.command_shape != McpClient.CommandShape.NONE: + var lines: Array[String] = [] + var rendered := McpTomlStrategy.render_body(client, server_url, launch) + if bool(rendered.get("ok", false)): + lines.append("Edit %s and add:" % resolved_path) + lines.append(" %s" % header) + for body_line in rendered.get("lines", []): + lines.append(" %s" % str(body_line)) + else: + lines.append("Attach launch command unavailable: %s" % str(rendered.get("error", "no compatible launcher found"))) + 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) + return "\n".join(lines) + var entry := McpYamlStrategy.build_entry(client, server_url) + var lines: Array[String] = [ + "Edit %s and add under '%s':" % [resolved_path, key], + " %s:" % server_name, + ] + for k in entry: + lines.append(" %s: %s" % [k, str(entry[k])]) + return "\n".join(lines) + + +## DeepSeek Harness: the user pastes a loader `insert` row into the home +## patch layer (`$DSH_HOME/cordis.patch.yml`). Auto-configure writes exactly +## these lines, so the manual text matches the file Configure would produce +## byte-for-byte. +static func _build_dsh( + client: McpClient, + server_name: String, + server_url: String, + resolved_path: String, + launch: Dictionary = {}, +) -> String: + var lines: Array[String] = ["Edit %s and add this loader entry (an `insert` row; a plain `- id:` row only overrides an existing bundle id and would be skipped):" % resolved_path] + var entry_id_value := McpDshStrategy.entry_id(server_name) + if client.command_shape != McpClient.CommandShape.NONE: + var launch_error := McpDshStrategy.command_launch_error(client, launch) + if launch_error.is_empty(): + var command_entry := McpDshStrategy.build_entry(client, server_name, server_url, null, launch) + for row_line in McpDshStrategy.render_insert_row(entry_id_value, command_entry): + lines.append(String(row_line)) + else: + lines.append("Attach launch command unavailable: %s" % launch_error) + return "\n".join(lines) + var url_entry := McpDshStrategy.build_url_entry(client, server_name, server_url) + for row_line in McpDshStrategy.render_insert_row(entry_id_value, url_entry): + lines.append(String(row_line)) + return "\n".join(lines) + + +## Mirrors the [section."name"] header `_toml_strategy._primary_header` +## emits, kept here so the manual-command text matches the file we'd write. +static func _toml_header(client: McpClient) -> String: + var parts := client.toml_section_path + if parts.size() < 2: + return "[%s]" % ".".join(parts) + var section := ".".join(McpClient._array_from_packed(McpClient._packed_slice(parts, 0, parts.size() - 1))) + var name := parts[parts.size() - 1] + return "[%s.\"%s\"]" % [section, name] + + +## Format an entry dict as a single inline JSON-ish string, matching the +## pre-refactor manual-command style: `{ "k": v, "k": v }` with spaces. +## Pre-existing manual-command tests assert the exact substring shape; this +## keeps them stable. +## +## Uses `JSON.stringify` for every leaf String (key OR value) so paths +## containing backslashes / quotes / newlines render as syntactically valid +## JSON. A Windows uvx path like `C:\Users\foo\uvx.exe` would otherwise be +## emitted as `"C:\Users\foo\uvx.exe"` — invalid JSON, unsafe to paste. +static func _format_entry_inline(entry: Dictionary) -> String: + var parts: Array[String] = [] + for k in entry: + parts.append("%s: %s" % [JSON.stringify(String(k)), _format_value(entry[k])]) + if parts.is_empty(): + return "{}" + return "{ %s }" % ", ".join(parts) + + +static func _format_value(value: Variant) -> String: + # Strings, bools, numbers, null all round-trip correctly through JSON.stringify + # without spurious quoting of non-string scalars (true → `true`, 5 → `5`). + # Arrays and Dictionaries are formatted manually so the inline ` { k: v } ` + # spacing matches the pre-refactor manual-command output shape that tests + # pin with assert_contains. + if value is Array: + var arr_parts: Array[String] = [] + for v in value: + arr_parts.append(_format_value(v)) + return "[%s]" % ", ".join(arr_parts) + if value is Dictionary: + var d_parts: Array[String] = [] + for k in value: + d_parts.append("%s: %s" % [JSON.stringify(String(k)), _format_value(value[k])]) + if d_parts.is_empty(): + return "{}" + return "{ %s }" % ", ".join(d_parts) + return JSON.stringify(value) diff --git a/addons/godot_ai/clients/_manual_command.gd.uid b/addons/godot_ai/clients/_manual_command.gd.uid 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..8fc9753 --- /dev/null +++ b/addons/godot_ai/clients/_path_template.gd @@ -0,0 +1,242 @@ +@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_details`, `_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. +## +## A token that cannot be resolved — the var is unset and no home-derived +## fallback applies, or a dock worker's env snapshot was never warmed with it — +## is LEFT IN PLACE rather than replaced with "". Substituting "" is what turns +## `$USERPROFILE/godot` into `/godot`: a root-relative path that +## `is_absolute_path()` accepts, so every fail-closed guard downstream waves it +## through and the caller reads or writes a directory the user never named. +## `$USERPROFILE` is the easy repro (unset off Windows, and the only one of +## these vars with no fallback), but `$HOME`/`$XDG_CONFIG_HOME`/`$APPDATA`/ +## `$LOCALAPPDATA` reach the same state whenever `_home()` is empty, which the +## missing-warm-up degradation above makes reachable on a worker thread. +## +## The surviving token keeps the result non-absolute, so those guards catch it, +## and the unexpanded path they report still shows what failed to resolve. +## `~` is left alone for the same reason: collapsing `~/godot` to the relative +## `godot` aims it at the editor's own working directory. +static func expand(template: String) -> String: + if template.is_empty(): + return "" + var out := template + if out.begins_with("~/") or out == "~": + var home := _home() + if not home.is_empty(): + 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: + continue + var value := env_lookup(var_name) + if value.is_empty(): + value = _home_fallback(var_name) + if value.is_empty(): + continue + out = out.replace(token, value) + return out + + +## Home-derived default for a var the environment does not define. Returns "" +## when home itself is unresolvable so `expand` leaves the token alone: rooting +## these at an empty home yields a RELATIVE path, not an absent one — +## `"".path_join(".config")` is `.config`, which reads against the editor's +## working directory rather than the user's config dir. `USERPROFILE` has no +## fallback (it is the thing `_home()` itself falls back to). +static func _home_fallback(var_name: String) -> String: + var home := _home() + if home.is_empty(): + return "" + match var_name: + "XDG_CONFIG_HOME": + return home.path_join(".config") + "APPDATA": + return home.path_join("AppData/Roaming") + "LOCALAPPDATA": + return home.path_join("AppData/Local") + "HOME": + return home + return "" + + +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..000317b --- /dev/null +++ b/addons/godot_ai/clients/_registry.gd @@ -0,0 +1,156 @@ +@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/codebuddy.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/opencode.gd", + "res://addons/godot_ai/clients/qwen_code.gd", + "res://addons/godot_ai/clients/kimi_code.gd", + "res://addons/godot_ai/clients/hermes.gd", + "res://addons/godot_ai/clients/pi.gd", + "res://addons/godot_ai/clients/deepseek_harness.gd", +] + +static var _instances: Array[McpClient] = [] +static var _by_id: Dictionary = {} +## First registry access can come from the dock's client-status refresh +## worker thread while the main thread hits it via a client_* command — +## serialize the one-time load so a racing thread can never observe a +## half-built registry. load() itself is thread-safe via ResourceLoader. +static var _load_mutex := Mutex.new() +## True when even a fresh rebuild yields instances missing base-schema +## fields — the deep stale-script state after an in-session self-update +## (#850; docs/releasing.md release-shape rules). Only an editor restart +## heals it; callers surface RESTART_TO_FINISH_UPDATE instead of erroring +## per client. Never reset within a session: rebuilding again cannot help, +## it would only repeat the load work and warning on every dock sweep. +static var _stale_session := false + +const RESTART_TO_FINISH_UPDATE := ( + "Godot AI was updated in this editor session. Restart the editor to finish the update." +) + + +static func all() -> Array[McpClient]: + _ensure_loaded() + return _instances + + +static func get_by_id(id: String) -> McpClient: + _ensure_loaded() + return _by_id.get(id, null) + + +static func ids() -> PackedStringArray: + var out := PackedStringArray() + for c in all(): + out.append(c.id) + return out + + +static func has_id(id: String) -> bool: + _ensure_loaded() + return _by_id.has(id) + + +## True when this editor session is running a self-update whose script +## reloads left descriptor state unusable. Client operations short-circuit +## with RESTART_TO_FINISH_UPDATE rather than spamming per-field errors. +static func stale_session_detected() -> bool: + _ensure_loaded() + return _stale_session + + +## An instance is coherent when fields added to the CURRENT McpClient schema +## read back with their declared types. After an in-session self-update, +## hot-patched or pre-update instances answer Nil for vars the update added +## (#850: `config_path_candidates` and +## `config_file_env` read as Nil, crashing platform_key / String()). The +## reflected `get()` avoids typed-access errors on such instances. +static func _instance_is_coherent(inst: Object) -> bool: + if inst == null: + return false + return ( + inst.get("config_path_candidates") is Dictionary + and inst.get("config_file_env") is String + and inst.get("path_template") is Dictionary + and inst.get("server_key_path_aliases") is Array + and inst.get("config_merge_path_templates") is Dictionary + and inst.get("config_merge_project_paths") is PackedStringArray + ) + + +static func _cache_is_coherent() -> bool: + return not _instances.is_empty() and _instance_is_coherent(_instances[0]) + + +static func _ensure_loaded() -> void: + if _stale_session: + return + if _cache_is_coherent(): + return + _load_mutex.lock() + ## Re-check under the lock: another thread may have rebuilt (or concluded + ## staleness) while this one waited. + if not _stale_session and not _cache_is_coherent(): + ## Covers both the first load and the post-self-update rebuild: this + ## registry file can survive an update unchanged, so its statics keep + ## serving pre-update instances to freshly reloaded callers. A rebuild + ## instantiates from the reloaded descriptor scripts, which repairs + ## every case except a stale base Script object itself. + _load() + if not _instances.is_empty() and not _cache_is_coherent(): + _stale_session = true + push_warning("MCP | %s" % RESTART_TO_FINISH_UPDATE) + _load_mutex.unlock() + + +static func _load() -> void: + ## Build into locals and publish whole containers last, so the lock-free + ## fast path in _ensure_loaded can never see a partially-filled registry. + var instances: Array[McpClient] = [] + var by_id: Dictionary = {} + for path in _CLIENT_SCRIPT_PATHS: + var script := load(path) as GDScript + if script == null: + push_warning("MCP | failed to load client descriptor %s" % path) + continue + var inst: McpClient = script.new() + if inst.id.is_empty(): + push_warning("MCP | client descriptor %s has empty id" % path) + continue + if by_id.has(inst.id): + push_warning("MCP | duplicate client id: %s" % inst.id) + continue + instances.append(inst) + by_id[inst.id] = inst + _by_id = by_id + _instances = instances diff --git a/addons/godot_ai/clients/_registry.gd.uid b/addons/godot_ai/clients/_registry.gd.uid 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..063f387 --- /dev/null +++ b/addons/godot_ai/clients/_toml_strategy.gd @@ -0,0 +1,754 @@ +@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, _join_lines(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, _join_lines(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 + ## Whether the existing entry launches Godot AI at all: a major migration + ## may rewrite such an entry, never a foreign one (client_job_owner.gd). + ## Only the launch values count; the section header carries our name. + var launch_tokens := PackedStringArray() + for launch_key in ["command", "url"]: + if by_key.has(launch_key): + var decoded := _decode_toml_string(_item_value(by_key[launch_key])) + if bool(decoded.get("ok", false)): + launch_tokens.append(str(decoded.get("value", ""))) + if by_key.has("args"): + var decoded_args := _decode_toml_string_array(_item_value(by_key["args"])) + if bool(decoded_args.get("ok", false)): + for argument in decoded_args.get("value", []): + launch_tokens.append(str(argument)) + var mismatch := { + "status": McpClient.Status.CONFIGURED_MISMATCH, + "error_msg": "", + "owned": McpClient.launch_values_mention_godot_ai(launch_tokens), + } + + 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 mismatch + if not by_key.has("command") or not by_key.has("args"): + return mismatch + 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 mismatch + if str(command_value.get("value", "")) != str(launch.get("command", "")): + return mismatch + if not _string_arrays_equal(args_value.get("value", []), launch.get("args", [])): + return mismatch + if not client.command_transport_key.is_empty(): + var transport_key := client.command_transport_key + if not by_key.has(transport_key): + return mismatch + 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 mismatch + return {"status": McpClient.Status.CONFIGURED, "error_msg": ""} + + if not by_key.has("url"): + return mismatch + 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 mismatch + 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, _join_lines(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 _join_lines(lines: Array[String]) -> String: + var joined := "\n".join(lines) + return joined if joined.is_empty() or joined.ends_with("\n") else joined + "\n" + + +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..becf4ac --- /dev/null +++ b/addons/godot_ai/clients/_yaml_strategy.gd @@ -0,0 +1,553 @@ +@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": "", + "owned": McpClient.launch_values_mention_godot_ai(McpClient.entry_launch_values(entry)), + } + + +static func remove(client: McpClient, server_name: String) -> Dictionary: + var resolution := client.resolved_config_path_details() + var path := str(resolution.get("path", "")) + var path_error := str(resolution.get("error", "")) + if not path_error.is_empty(): + return {"status": "error", "message": path_error} + if path.is_empty() or not FileAccess.file_exists(path): + return {"status": "ok", "message": "Not configured"} + var read := _read(path) + if not read["ok"]: + return {"status": "error", "message": "Refusing to rewrite %s: %s." % [path, read["error"]]} + var text: String = read["data"] + var block := _extract_block(text) + var entries: Dictionary = block["entries"] + if not entries.has(server_name): + return {"status": "ok", "message": "%s configuration removed" % client.display_name} + entries.erase(server_name) + var out := _assemble(text, block["prefix_lines"], entries, block["suffix_lines"]) + if not McpAtomicWrite.write(path, out): + return {"status": "error", "message": "Cannot write to %s" % path} + return {"status": "ok", "message": "%s configuration removed" % client.display_name} + + +## Build the entry dict written under mcp_servers[server_name]. +## +## URL mode (`CommandShape.NONE`): Hermes HTTP entries are transport-inferred +## — { url: } plus whatever user-mutable keys (headers, enabled, tools, +## ...) the existing entry carries. Stdio-bridge keys are the one exception +## (see _STDIO_BRIDGE_KEYS). No `type` field. +## +## Command mode (`CommandShape.FLAT`, #838): flat `command` + `args` keys, +## transport inferred the same way — which is exactly why the descriptor's +## `command_legacy_keys` (url, headers) must be scrubbed: a Hermes entry with +## both a url and a command picks the wrong transport. User keys survive. +static func build_entry( + client: McpClient, + server_url: String, + existing: Variant = null, + launch: Dictionary = {}, +) -> Dictionary: + if client.command_shape == McpClient.CommandShape.FLAT: + var command_entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {} + command_entry["command"] = str(launch.get("command", "")) + command_entry["args"] = _array_copy(launch.get("args", [])) + if not client.command_transport_key.is_empty(): + command_entry[client.command_transport_key] = client.command_transport_value + for key in client.command_initial_fields: + if not command_entry.has(key): + command_entry[key] = client.command_initial_fields[key] + for key in client.command_legacy_keys: + command_entry.erase(String(key)) + return command_entry + if client.command_shape != McpClient.CommandShape.NONE: + ## Every production caller checks command_launch_error first. Keep this + ## builder defensive too so a future unsupported shape cannot silently + ## degrade into a flat YAML entry. + return {} + var entry: Dictionary = {} + if existing is Dictionary: + ## User-mutable keys (headers, enabled, tools, ...) survive a + ## reconfigure — the same preservation contract the JSON strategy's + ## entry_initial_fields split implements. Only the stdio-bridge keys + ## are scrubbed (see _STDIO_BRIDGE_KEYS), then the url is repointed. + entry = (existing as Dictionary).duplicate(true) + for stale_key in _STDIO_BRIDGE_KEYS: + entry.erase(stale_key) + entry[client.entry_url_field] = server_url + return entry + + +## Keys a prior stdio-bridge entry (e.g. `command: uvx mcp-proxy`) may carry. +## These must NOT survive a URL reconfigure: a Hermes entry with both a url +## and a command picks the wrong transport. +const _STDIO_BRIDGE_KEYS := ["command", "args", "env"] + + +## Empty string when this client's launch requirements are satisfied. +## Mirrors `McpJsonStrategy.command_launch_error`; YAML supports FLAT only. +static func command_launch_error(client: McpClient, launch: Dictionary) -> String: + if client.command_shape == McpClient.CommandShape.NONE: + return "" + if client.command_shape != McpClient.CommandShape.FLAT: + return "%s uses a command shape not supported by YAML yet" % client.display_name + if not bool(launch.get("ok", false)): + return str(launch.get("error", "No compatible attach launcher was found.")) + return "" + + +## Verify a stored entry matches. +## +## URL mode: Hermes entries have no transport type pin, so verification is: +## url matches. Extra keys (headers, enabled, tools) are user-mutable and +## intentionally NOT checked (mirrors json entry_initial_fields). +## +## Command mode: every launch-affecting value must match exactly and legacy +## URL-transport keys must be gone — their presence is migration drift. +static func verify_entry( + client: McpClient, + entry: Dictionary, + server_url: String, + launch: Dictionary = {}, +) -> bool: + if client.command_shape != McpClient.CommandShape.NONE: + if client.command_shape != McpClient.CommandShape.FLAT or not bool(launch.get("ok", false)): + return false + for key in client.command_legacy_keys: + if entry.has(String(key)): + return false + if entry.get("command") != launch.get("command"): + return false + if not _arrays_equal(entry.get("args", null), launch.get("args", null)): + return false + if not client.command_transport_key.is_empty(): + if entry.get(client.command_transport_key, null) != client.command_transport_value: + return false + return true + return entry.get(client.entry_url_field, "") == server_url + + +static func _array_copy(value: Variant) -> Array: + if value is Array: + return (value as Array).duplicate(true) + if value is PackedStringArray: + return McpClient._array_from_packed(value) + return [] + + +static func _arrays_equal(left: Variant, right: Variant) -> bool: + if not (left is Array or left is PackedStringArray): + return false + if not (right is Array or right is PackedStringArray): + return false + var left_array := _array_copy(left) + var right_array := _array_copy(right) + if left_array.size() != right_array.size(): + return false + for i in range(left_array.size()): + if left_array[i] != right_array[i]: + return false + return true + + +# --- YAML block handling (scoped to mcp_servers) ------------------------- + +## Parse the file into three regions: +## prefix_lines — everything before `mcp_servers:` (may be empty) +## entries — the map of server_name -> {url, ...} under mcp_servers +## suffix_lines — everything after the mcp_servers block (may be empty) +## This lets us rewrite only the mcp_servers block and keep the rest of the +## user's config.yaml byte-for-byte intact. +static func _extract_block(text: String) -> Dictionary: + ## allow_empty must stay true: dropping empty splits would silently strip + ## the user's blank lines from the preserved prefix/suffix regions on + ## every rewrite. The parse loops below already skip blank lines. + var lines := text.split("\n") + var prefix: PackedStringArray = [] + var entries: Dictionary = {} + var suffix: PackedStringArray = [] + var header_idx := -1 + for i in range(lines.size()): + if lines[i].strip_edges().begins_with("mcp_servers:"): + header_idx = i + break + if header_idx < 0: + # No mcp_servers yet — whole file is prefix; block will be appended. + prefix = lines.duplicate() + return {"prefix_lines": prefix, "entries": entries, "suffix_lines": [], "header_idx": -1} + + for i in range(0, header_idx): + prefix.append(lines[i]) + + # Determine the indent of the first entry so we can tell sibling + # entries (same indent) apart from nested keys (deeper indent) and + # parent-level keys (less indent). All server entries under + # `mcp_servers:` share one indent level; breaking on 0-indent alone + # mis-nests 2-space-indented siblings under the first entry. + var entry_indent := -1 + var probe := header_idx + 1 + while probe < lines.size() and _is_blank_or_comment(lines[probe]): + probe += 1 + if probe < lines.size(): + entry_indent = _indent_of(lines[probe]) + + # Empty block guard: the first nonblank line after the header must sit + # DEEPER than the header itself to be an entry. At or above the header's + # indent it is a sibling/parent key — parsing it as an entry would + # swallow the user's next top-level key and re-emit it nested under + # mcp_servers, corrupting the file. + if probe < lines.size() and entry_indent <= _indent_of(lines[header_idx]): + for j in range(header_idx + 1, lines.size()): + suffix.append(lines[j]) + return {"prefix_lines": prefix, "entries": entries, "suffix_lines": suffix, "header_idx": header_idx} + + var i := header_idx + 1 + while i < lines.size(): + var raw := lines[i] + ## Comment-only lines inside the block are skipped like blanks — + ## treating one as an entry header would re-emit it as a bogus + ## `# comment:` server on rewrite. (Comments INSIDE the rewritten + ## block are consequently dropped; comments outside the block live + ## in prefix/suffix and survive verbatim.) + if _is_blank_or_comment(raw): + i += 1 + continue + # Stop at any line indented less than a sibling entry (parent key + # or a new top-level section), or at the header's own level. + if _indent_of(raw) < entry_indent: + break + var entry := _parse_entry(raw, lines, i, entry_indent) + if not entry["name"].is_empty(): + entries[entry["name"]] = entry["data"] + i = entry["next_idx"] + + for j in range(i, lines.size()): + suffix.append(lines[j]) + + return {"prefix_lines": prefix, "entries": entries, "suffix_lines": suffix, "header_idx": header_idx} + + +## Parse one ` name:` entry starting at `lines[start]`. Consumes all deeper- +## indented sublines (url, headers, etc.) and returns the next sibling index. +static func _parse_entry(raw: String, lines: PackedStringArray, start: int, entry_indent: int) -> Dictionary: + var name := raw.strip_edges().trim_suffix(":").strip_edges() + var data: Dictionary = {} + var i := start + 1 + while i < lines.size(): + var l := lines[i] + ## Comments inside an entry (e.g. ` # auth for CI`) would parse + ## as a `# auth for CI` key — skip them like blanks. + if _is_blank_or_comment(l): + i += 1 + continue + # A line at or above the entry's indent is a sibling/parent key. + if _indent_of(l) <= entry_indent: + break + var stripped := l.strip_edges() + var colon := stripped.find(":") + if colon < 0: + i += 1 + continue + var key := stripped.substr(0, colon).strip_edges() + var val := stripped.substr(colon + 1).strip_edges() + if val.is_empty(): + # Nested block (e.g. headers:). Parse as raw sub-dict lines for + # preservation; we don't introspect deeper than url at the top. + var sub := _parse_subblock(lines, i + 1, entry_indent) + data[key] = sub["value"] + i = sub["next_idx"] + else: + data[key] = coerce_scalar(val) + i += 1 + return {"name": name, "data": data, "next_idx": i} + + +## Parse a nested block (e.g. headers:) as a preserved sub-dictionary of +## scalar key/values. Deeper nesting is flattened into scalar strings — fine +## for Hermes' known shape (headers are flat key: value). +static func _parse_subblock(lines: PackedStringArray, start: int, entry_indent: int) -> Dictionary: + var sub: Dictionary = {} + var i := start + while i < lines.size(): + var l := lines[i] + if _is_blank_or_comment(l): + i += 1 + continue + # A line at or above the parent entry's indent ends the nested block. + if _indent_of(l) <= entry_indent: + break + var stripped := l.strip_edges() + var colon := stripped.find(":") + if colon < 0: + i += 1 + continue + var key := stripped.substr(0, colon).strip_edges() + var val := stripped.substr(colon + 1).strip_edges() + if val.is_empty(): + i += 1 + continue + sub[key] = coerce_scalar(val) + i += 1 + return {"value": sub, "next_idx": i} + + +## Reassemble the full file text from prefix + a freshly built mcp_servers +## block + suffix. If the block didn't exist before, it is appended. +static func _assemble(_text: String, prefix: PackedStringArray, entries: Dictionary, suffix: PackedStringArray) -> String: + var out: PackedStringArray = [] + for l in prefix: + out.append(l) + # Trim trailing blank lines from prefix so we don't stack double blanks. + while out.size() > 0 and out[out.size() - 1].strip_edges().is_empty(): + out.remove_at(out.size() - 1) + + if not _text.contains("mcp_servers:"): + # File existed but had no mcp_servers block — append it. + if out.size() > 0: + out.append("") + out.append("mcp_servers:") + for name in entries: + out.append_array(_emit_entry(name, entries[name])) + else: + out.append("mcp_servers:") + for name in entries: + out.append_array(_emit_entry(name, entries[name])) + + # Suffix: keep as-is. + for l in suffix: + out.append(l) + return "\n".join(out) + + +## Public rendering seam for the dock's manual-instruction text, so the +## pasted YAML matches what Configure would write byte-for-byte. +static func render_entry_lines(name: String, data: Dictionary) -> PackedStringArray: + return _emit_entry(name, data) + + +## Emit one ` name:` entry with its scalar keys (top level only; headers +## sub-dict is re-emitted as nested scalars; arrays — the command entry's +## `args` — are emitted in flow style on one line). +static func _emit_entry(name: String, data: Dictionary) -> PackedStringArray: + var lines: PackedStringArray = [] + lines.append(INDENT + "%s:" % name) + for key in data: + var val = data[key] + if val is Dictionary: + lines.append(INDENT + INDENT + "%s:" % key) + for sk in val: + lines.append(INDENT + INDENT + INDENT + "%s: %s" % [sk, emit_scalar(val[sk])]) + elif val is Array or val is PackedStringArray: + lines.append(INDENT + INDENT + "%s: %s" % [key, emit_flow_array(_array_copy(val))]) + else: + lines.append(INDENT + INDENT + "%s: %s" % [key, emit_scalar(val)]) + return lines + + +## Flow-style sequence with every item double-quoted. JSON string quoting is +## valid YAML double-quote style (shared escape set), so the same encoding +## both writes the file and — via JSON.parse_string in `coerce_scalar` — +## reads it back for verification. Shared with `McpDshStrategy` for the +## DeepSeek Harness patch-list `args` lines. +static func emit_flow_array(values: Array) -> String: + var parts: Array[String] = [] + for v in values: + parts.append(JSON.stringify(str(v))) + return "[%s]" % ", ".join(parts) + + +static func emit_scalar(v: Variant) -> String: + match typeof(v): + TYPE_BOOL: + return "true" if bool(v) else "false" + TYPE_INT: + return str(int(v)) + TYPE_FLOAT: + return str(float(v)) + _: + return emit_string_scalar(str(v)) + + +## Plain YAML scalars cannot safely carry ": ", " #", quotes, flow +## indicators, or leading indicator characters — a Windows launcher path with +## spaces would silently corrupt the entry. Quote exactly when needed so +## existing plain values (urls, bools-as-strings) keep their current +## byte-shape on rewrite. Backslashes are quoted too: a Windows path parses +## either way, but quoting makes the written shape unambiguous for any +## consumer. Shared with `McpDshStrategy` for DeepSeek Harness patch entries. +static func emit_string_scalar(s: String) -> String: + if s.is_empty(): + return "\"\"" + var needs_quote := s.begins_with(" ") or s.ends_with(" ") + if not needs_quote: + for needle in [": ", " #", "\"", "'", "\n", "\t", "\\", "{", "}", "[", "]", ","]: + if s.contains(needle): + needs_quote = true + break + if not needs_quote: + for prefix in ["#", "-", "?", "&", "*", "!", "|", ">", "%", "@", "`"]: + if s.begins_with(prefix): + needs_quote = true + break + return JSON.stringify(s) if needs_quote else s + + +## Blank and comment-only lines carry no structure — every scan loop skips +## them the same way so a `# comment` can never be mistaken for an entry +## header or a key/value line. +static func _is_blank_or_comment(line: String) -> bool: + var stripped := line.strip_edges() + return stripped.is_empty() or stripped.begins_with("#") + + +## Returns the leading-whitespace indent width of a line (spaces + tabs +## counted as 1 each). Used to distinguish sibling entries (same indent) +## from nested keys (deeper indent) and parent-level keys (less indent). +static func _indent_of(line: String) -> int: + var n := 0 + while n < line.length() and (line[n] == " " or line[n] == "\t"): + n += 1 + return n + + +## Minimal scalar coercion for parsed YAML values. Quotes are stripped; +## bare true/false/numbers are typed; double-quoted flow sequences (the +## command entry's `args`) parse back into an Array. Good enough for Hermes' +## url/headers/command/args. A hand-edited args in block style or with +## unquoted items doesn't JSON-parse — it stays a raw string, compares +## unequal, and surfaces as CONFIGURED_MISMATCH, which Reconfigure +## normalizes back to the flow form. Public: the dsh strategy parses its +## patch-list scalars with the same rules, so both YAML dialects share one +## coercion (#867 review). +static func coerce_scalar(s: String) -> Variant: + var t := s.strip_edges() + if t.begins_with("[") and t.ends_with("]"): + var parsed_array: Variant = JSON.parse_string(t) + if parsed_array is Array: + return parsed_array + return t + if t.begins_with("\"") and t.ends_with("\""): + var parsed_string: Variant = JSON.parse_string(t) + if parsed_string is String: + return parsed_string + return t.substr(1, t.length() - 2) + if t.begins_with("'") and t.ends_with("'"): + return t.substr(1, t.length() - 2) + if t == "true": + return true + if t == "false": + return false + if t.is_valid_int(): + return t.to_int() + if t.is_valid_float(): + return t.to_float() + return t + + +## Returns {"ok": true, "data": String} when the file is absent or readable, +## and {"ok": false, "error": String} when unreadable. Callers must NOT fall +## back to an empty string on the error path — doing so blows away the user's +## other config.yaml entries on the next write. +static func _read(path: String) -> Dictionary: + if not FileAccess.file_exists(path): + return {"ok": true, "data": ""} + var f := FileAccess.open(path, FileAccess.READ) + if f == null: + var err := FileAccess.get_open_error() + return {"ok": false, "error": "could not open for reading (%s)" % error_string(err)} + var t := f.get_as_text() + f.close() + return {"ok": true, "data": t} diff --git a/addons/godot_ai/clients/_yaml_strategy.gd.uid b/addons/godot_ai/clients/_yaml_strategy.gd.uid 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..ce82681 --- /dev/null +++ b/addons/godot_ai/clients/antigravity.gd @@ -0,0 +1,38 @@ +@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"]) + ## 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/claude_code.gd b/addons/godot_ai/clients/claude_code.gd new file mode 100644 index 0000000..de4a5db --- /dev/null +++ b/addons/godot_ai/clients/claude_code.gd @@ -0,0 +1,81 @@ +@tool +extends McpClient + + +func _init() -> void: + id = "claude_code" + display_name = "Claude Code" + config_type = "cli" + cli_names = PackedStringArray(["claude", "claude.exe"] if OS.get_name() == "Windows" else ["claude"]) + ## Stdio registration through the client-owned `godot-ai attach` bridge + ## (#838). `--` stops claude's own flag parsing so the attach argv passes + ## through verbatim; stdio is the CLI's default transport. Scope comes from + ## the `godot_ai/mcp_client_scope` EditorSetting via the `{scope}` token; it + ## defaults to `user` — the same ~/.claude.json the pre-attach HTTP entry + ## lived in — and can be set to `project` to write /.mcp.json so the + ## server is not loaded in every unrelated workspace. + cli_register_template = PackedStringArray( + ["mcp", "add", "--scope", "{scope}", "{name}", "--", "{command}", "{args...}"] + ) + ## Explicit scope: an unscoped `mcp remove` deletes from whichever scope + ## matches first, so the flag keeps each removal aimed at one known place. + ## Note what that does and does not protect (#877): the dock's **Remove** + ## button runs this once, at the selected scope only. **Configure** runs it + ## once per scope in `CLIENT_SCOPES` before registering, so a Configure at + ## any setting does delete a `godot-ai` entry from /.mcp.json if one is + ## there — see the cwd caveat below for which directory that actually is. + cli_unregister_template = PackedStringArray(["mcp", "remove", "--scope", "{scope}", "{name}"]) + ## Scope caveat: `--scope project` resolves `.mcp.json` against the + ## *spawned CLI's* working directory, which is whatever the editor process + ## inherited at launch — not necessarily `res://`. Godot has no API to set + ## a child's cwd (`OS.execute_with_pipe` takes none), so this is an + ## accepted limitation rather than something the descriptor can pin. It is + ## self-consistent: `cli_status_args` runs from the same cwd, so the + ## read-back finds whatever the register wrote (see + ## `_scope_diverges_from_json_fallback` in client_configurator.gd). + cli_status_args = PackedStringArray(["mcp", "list"]) + ## `mcp list` prints the resolved entry for the cwd without saying which + ## scope won, so it cannot tell "registered at project scope" from "an old + ## user-scope entry is shadowing an empty project scope". `mcp get` prints + ## both the command and a `Scope: …` line, in the same single subprocess, + ## so scope-token clients probe with it instead (#872). Verified against + ## claude 2.1.241, which prints one of: + ## Scope: User config (available in all your projects) + ## Scope: Project config (shared via .mcp.json) + ## Scope: Local config (private to you in this project) + ## and exits 1 with "No MCP server named …" when the entry is absent. + cli_scope_status_template = PackedStringArray(["mcp", "get", "{name}"]) + ## #463: JSON fallback for when the `claude` binary isn't on PATH — e.g. + ## Claude Code installed only as a VS Code / Cursor extension. The CLI is + ## still preferred for Configure whenever it resolves; this is what gets + ## written otherwise. `claude mcp add --scope user -- ` + ## produces exactly this shape under `mcpServers` in ~/.claude.json + ## (verified live against claude CLI in an isolated CLAUDE_CONFIG_DIR): + ## "godot-ai": { "type": "stdio", "command": "", "args": [...], "env": {} } + ## The fallback writer omits the empty `env`; the verifier accepts both. + ## Status reads this file only while the selected scope is still `user` — + ## it is the CLI's own store for that scope, and file reads give exact + ## launch-drift detection that `mcp list` stdout scanning cannot. At + ## `project`/`local` the entry is somewhere `path_template` cannot see, so + ## `_scope_diverges_from_json_fallback` (client_configurator.gd) routes + ## status to `cli_scope_status_template` instead and this file is not read. + path_template = {"unix": "~/.claude.json", "windows": "~/.claude.json"} + server_key_path = PackedStringArray(["mcpServers"]) + ## Legacy HTTP entries used this shape; command rendering repins it to stdio. + 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"]) + ## 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..9e827b6 --- /dev/null +++ b/addons/godot_ai/clients/cline.gd @@ -0,0 +1,42 @@ +@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"]) 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/codebuddy.gd b/addons/godot_ai/clients/codebuddy.gd new file mode 100644 index 0000000..3d043f0 --- /dev/null +++ b/addons/godot_ai/clients/codebuddy.gd @@ -0,0 +1,25 @@ +@tool +extends McpClient + +## CodeBuddy IDE: https://www.codebuddy.ai/docs/zh/ide/User-guide/MCP +## Official docs specify mcpServers + type:stdio + command/args/env. +## Global ~/.codebuddy/mcp.json was verified by the reporter in #941. +## This descriptor uses the global user scope, +## matching the registry's absolute user-path contract; project configuration +## remains available manually in CodeBuddy's MCP settings. + + +func _init() -> void: + id = "codebuddy" + display_name = "CodeBuddy" + config_type = "json" + path_template = { + "unix": "~/.codebuddy/mcp.json", + "windows": "$USERPROFILE/.codebuddy/mcp.json", + } + server_key_path = PackedStringArray(["mcpServers"]) + command_shape = McpClient.CommandShape.FLAT + command_transport_key = "type" + command_transport_value = "stdio" + command_legacy_keys = PackedStringArray(["url", "headers"]) + command_user_fields = PackedStringArray(["env", "description"]) diff --git a/addons/godot_ai/clients/codebuddy.gd.uid b/addons/godot_ai/clients/codebuddy.gd.uid new file mode 100644 index 0000000..88506e6 --- /dev/null +++ b/addons/godot_ai/clients/codebuddy.gd.uid @@ -0,0 +1 @@ +uid://cutvvrwaef0rk diff --git a/addons/godot_ai/clients/codex.gd b/addons/godot_ai/clients/codex.gd new file mode 100644 index 0000000..c80f7f3 --- /dev/null +++ b/addons/godot_ai/clients/codex.gd @@ -0,0 +1,44 @@ +@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_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..9dd4ea0 --- /dev/null +++ b/addons/godot_ai/clients/cursor.gd @@ -0,0 +1,20 @@ +@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"]) 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/deepseek_harness.gd b/addons/godot_ai/clients/deepseek_harness.gd new file mode 100644 index 0000000..db11ffc --- /dev/null +++ b/addons/godot_ai/clients/deepseek_harness.gd @@ -0,0 +1,44 @@ +@tool +extends McpClient + + +func _init() -> void: + id = "deepseek_harness" + display_name = "DeepSeek Harness" + ## DeepSeek Harness (dsh) has no `mcp` CLI verb — MCP servers register as + ## `@deepseek-ai/dsh-mcp-client` plugin entries in the HOME patch layer + ## `$DSH_HOME/cordis.patch.yml`, which dsh applies over every profile's + ## own patch layer (the web GUI included). config_type "dsh" routes to + ## `McpDshStrategy`. Verified live against dsh 0.1.0-rc.6: new entries + ## must be `insert` rows; plain rows only override existing bundle ids. + config_type = "dsh" + ## Home patch layer, not a per-profile file: one entry covers the web + ## GUI, headless, tui, and any custom profile. + path_template = { + "unix": "~/.dsh/cordis.patch.yml", + "windows": "~/.dsh/cordis.patch.yml", + } + ## Documented: `$DSH_HOME` relocates the whole harness home, and the + ## loader reads `$DSH_HOME/cordis.patch.yml` (homePatchPath) — same + ## false-success-write class as CODEX_HOME (#617), so the override must + ## be declared. + config_home_env = "DSH_HOME" + config_home_env_subpath = "cordis.patch.yml" + ## Installed detection: the harness home directory itself (dsh GUI and + ## CLI both live there), or the config file once one exists. + detect_paths = PackedStringArray(["~/.dsh"]) + ## Attach migration (#838). The loader entry nests the launch under + ## `config`; the mcp-client plugin requires `transport` next to + ## command/args, and a `url` must never coexist with command fields. + command_shape = McpClient.CommandShape.FLAT + command_transport_key = "transport" + command_transport_value = "stdio" + command_legacy_keys = PackedStringArray(["url"]) + ## Fields the mcp-client plugin documents as user-owned; preserved on + ## reconfigure and never drift-checked. + command_user_fields = PackedStringArray([ + "env", "cwd", "toolCallTimeoutMs", "failOnStartupError", "reconnect", + ]) + command_timeout_fields = PackedStringArray(["toolCallTimeoutMs"]) + ## The bridge accepts `transport: streamable-http` with a `url`, so the + ## manual instructions may offer the HTTP alternative. diff --git a/addons/godot_ai/clients/deepseek_harness.gd.uid b/addons/godot_ai/clients/deepseek_harness.gd.uid new file mode 100644 index 0000000..8cc2ed3 --- /dev/null +++ b/addons/godot_ai/clients/deepseek_harness.gd.uid @@ -0,0 +1 @@ +uid://cw3pghlevtql8 diff --git a/addons/godot_ai/clients/gemini_cli.gd b/addons/godot_ai/clients/gemini_cli.gd new file mode 100644 index 0000000..34257b6 --- /dev/null +++ b/addons/godot_ai/clients/gemini_cli.gd @@ -0,0 +1,24 @@ +@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"]) 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..4c54216 --- /dev/null +++ b/addons/godot_ai/clients/grok.gd @@ -0,0 +1,34 @@ +@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"]) + 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..2e9f7bc --- /dev/null +++ b/addons/godot_ai/clients/hermes.gd @@ -0,0 +1,45 @@ +@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"]) + + # 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..b904837 --- /dev/null +++ b/addons/godot_ai/clients/kilo_code.gd @@ -0,0 +1,37 @@ +@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"]) 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..04354a4 --- /dev/null +++ b/addons/godot_ai/clients/kimi_code.gd @@ -0,0 +1,33 @@ +@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"]) 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..d7d6b72 --- /dev/null +++ b/addons/godot_ai/clients/kiro.gd @@ -0,0 +1,22 @@ +@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"]) 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..eab6518 --- /dev/null +++ b/addons/godot_ai/clients/opencode.gd @@ -0,0 +1,54 @@ +@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" + ## OpenCode loads BOTH opencode.json and opencode.jsonc from its config + ## directory and merges them, later file winning per key + ## (anomalyco/opencode#32447). Writing only opencode.json therefore + ## leaves a stale `godot-ai` entry in an existing opencode.jsonc in + ## charge (#1011). Declaring the merge order makes Configure update the + ## effective last definition, status verify it, and Remove clear both. + config_merge_path_templates = { + "unix": PackedStringArray([ + "~/.config/opencode/opencode.json", + "~/.config/opencode/opencode.jsonc", + ]), + "windows": PackedStringArray([ + "$HOME/.config/opencode/opencode.json", + "$HOME/.config/opencode/opencode.jsonc", + ]), + } + 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"]) 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/pi.gd b/addons/godot_ai/clients/pi.gd new file mode 100644 index 0000000..0862cc6 --- /dev/null +++ b/addons/godot_ai/clients/pi.gd @@ -0,0 +1,61 @@ +@tool +extends McpClient + + +func _init() -> void: + id = "pi" + display_name = "Pi Agent" + config_type = "json" + # pi-codemode-mcp reads MCP server definitions from ~/.pi/agent/mcp.json + # (first merge tier; ~/.pi/agent/.mcp.json, .pi/mcp.json, and + # .mcp.json merge after, project-scope last). Documented at + # github.com/mitsuhiko/pi-codemode-mcp README "Configuration files" + # section. Windows path mirrors antigravity's $USERPROFILE choice so + # the descriptor round-trips on every supported platform. + path_template = { + "unix": "~/.pi/agent/mcp.json", + "windows": "$USERPROFILE/.pi/agent/mcp.json", + } + # Pi merges these files in order; later definitions of the same server win. + # Keep this separate from config_path_candidates, whose entries are alternatives + # rather than merge tiers. + config_merge_path_templates = { + "unix": PackedStringArray([ + "~/.pi/agent/mcp.json", + "~/.pi/agent/.mcp.json", + ]), + "windows": PackedStringArray([ + "$USERPROFILE/.pi/agent/mcp.json", + "$USERPROFILE/.pi/agent/.mcp.json", + ]), + } + config_merge_project_paths = PackedStringArray([".pi/mcp.json", ".mcp.json"]) + server_key_path = PackedStringArray(["mcpServers"]) + # pi-codemode-mcp resolves these with nullish precedence: + # mcpServers ?? mcp-servers ?? servers. Reuse whichever map already exists + # so Configure cannot shadow and deactivate the user's existing servers. + var aliases: Array[PackedStringArray] = [ + PackedStringArray(["mcp-servers"]), + PackedStringArray(["servers"]), + ] + server_key_path_aliases = aliases + entry_url_field = "url" + # pi-codemode-mcp selects stdio from `command` and remote transport from + # `url`; it ignores `type`. Keep generated stdio entries minimal and typeless. + entry_extra_fields = {} + entry_initial_fields = {} + # Attach migration (#838). Pi stdio entries are flat command/args/env. + # When Configure replaces a legacy URL entry, remove `url`, `headers`, and + # any redundant `type` field so the generated stdio entry stays canonical. + command_shape = McpClient.CommandShape.FLAT + command_legacy_keys = PackedStringArray(["url", "headers", "type"]) + # `env` is the only user-mutable field pi's docs surface. Future + # fields still survive — the strategy deep-copies unknown keys. + command_user_fields = PackedStringArray(["env"]) + # Pi's loader also accepts pure-URL entries (the example mcp.json in + # the pi-codemode-mcp repo ships stdio + URL entries side-by-side). + # ~/.pi/agent/mcp.json is created by the first pi launch, so its + # presence is the strongest install signal. Mirror antigravity's + # `detect_paths = path_template.values()` pattern so the dock shows + # the installed badge before Configure has run. + detect_paths = PackedStringArray(path_template.values()) diff --git a/addons/godot_ai/clients/pi.gd.uid b/addons/godot_ai/clients/pi.gd.uid new file mode 100644 index 0000000..53aeb7c --- /dev/null +++ b/addons/godot_ai/clients/pi.gd.uid @@ -0,0 +1 @@ +uid://it00ohwkm2nd diff --git a/addons/godot_ai/clients/qwen_code.gd b/addons/godot_ai/clients/qwen_code.gd new file mode 100644 index 0000000..bed4492 --- /dev/null +++ b/addons/godot_ai/clients/qwen_code.gd @@ -0,0 +1,25 @@ +@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"]) 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..5a1e697 --- /dev/null +++ b/addons/godot_ai/clients/roo_code.gd @@ -0,0 +1,41 @@ +@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"]) 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..68f66de --- /dev/null +++ b/addons/godot_ai/clients/trae.gd @@ -0,0 +1,22 @@ +@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"]) 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..9b97c53 --- /dev/null +++ b/addons/godot_ai/clients/vscode.gd @@ -0,0 +1,29 @@ +@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"]) 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..0fc9427 --- /dev/null +++ b/addons/godot_ai/clients/vscode_insiders.gd @@ -0,0 +1,22 @@ +@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"]) 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..e069557 --- /dev/null +++ b/addons/godot_ai/clients/windsurf.gd @@ -0,0 +1,28 @@ +@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"]) 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..e189064 --- /dev/null +++ b/addons/godot_ai/clients/zed.gd @@ -0,0 +1,35 @@ +@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" + ## Zed settings are JSONC. Owning a second JSON tokenizer solely to preserve + ## comments would make the shared JSON strategy much harder to trust. Keep + ## status checks read-only and give users the exact manual entry instead. + automatic_config_edits = false + ## Reads still have to cope with the `//` header Zed ships, or a stock + ## install shows a parse error in the row instead of a status. + config_allows_comments = true + 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"]) 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..85529bf --- /dev/null +++ b/addons/godot_ai/clients/zoo_code.gd @@ -0,0 +1,35 @@ +@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"]) 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..5877356 --- /dev/null +++ b/addons/godot_ai/connection.gd @@ -0,0 +1,1321 @@ +@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 +const MAX_QUEUED_PACKETS := 64 +const WS_PROTOCOL_VERSION := 2 +const HANDSHAKE_TIMEOUT_MSEC := 5000 +const MAX_HANDSHAKE_FRAME_BYTES := 8 * 1024 +const CLOSE_CODE_DUPLICATE_SESSION := 4001 +const CLOSE_CODE_PROTOCOL_MISMATCH := 4002 +const CLOSE_CODE_AUTH_FAILED := 4003 +const _SERVER_PROOF_DOMAIN := "godot-ai-ws-v2/server-proof" +const _CLIENT_PROOF_DOMAIN := "godot-ai-ws-v2/client-proof" +const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd") +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Emitted whenever the authenticated v4 editor channel becomes usable/unusable. +## Subscribers (e.g. the plugin-side telemetry helper) use this to drain +## events that were enqueued before authentication completed. +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 editor capability. Root supplies the copied value only after +## lifecycle proof has read the child-published private capability record. +## It never crosses the wire or enters EditorSettings; empty/malformed values +## fail closed and require a fresh lifecycle episode. +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 := "" +var _handshake_started_msec := 0 +var _client_nonce := "" +var _server_nonce := "" +var _challenged_server_version := "" +var _server_verified := false +var _auth_response_sent := false +var _handshake_complete := false +## Godot-AI Python package version reported by the server in its `handshake_ack` +## reply. Empty until the authenticated v4 handshake completes. +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 + ## Stay under the pre-auth byte ceiling until the server proof validates; + ## only then may the peer allocate the normal 4 MiB command buffer. + _peer.inbound_buffer_size = MAX_HANDSHAKE_FRAME_BYTES + _peer.max_queued_packets = MAX_QUEUED_PACKETS + 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 + if log_buffer: + log_buffer.log("connected to server; authenticating") + _send_auth_hello() + + _drain_inbound_packets(_peer) + if not _handshake_complete: + if ( + _handshake_started_msec > 0 + and Time.get_ticks_msec() - _handshake_started_msec >= HANDSHAKE_TIMEOUT_MSEC + ): + _fail_handshake(CLOSE_CODE_AUTH_FAILED, "v4 editor handshake timed out") + return + + _check_state_changes() + + if dispatcher: + for response in dispatcher.tick(): + _send_json(response) + + WebSocketPeer.STATE_CLOSED: + if _connected: + _connected = false + var was_authenticated := _handshake_complete + var server_was_verified := _server_verified + ## 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() + if _should_regenerate_session_id(code, server_was_verified, was_authenticated): + _session_id = _make_session_id(ProjectSettings.globalize_path("res://")) + var open_elapsed_sec := float(transition.get("previous_elapsed_sec", 0.0)) + var close_diagnostic := _handshake_close_diagnostic(code, was_authenticated) + 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, + ), + maxi(1, _reconnect_attempt), + true, + ) + if was_authenticated: + connection_state_changed.emit(false) + elif not _preopen_failure_logged_for_peer: + _preopen_failure_logged_for_peer = true + ## A failed attempt never reached OPEN, so any post-OPEN reason + ## belongs to the previous peer and must not describe this one. + _transient_diagnostic.clear() + ## Initial failure is attempt 1 for diagnostics. Later transition + ## summaries are time-throttled so a missing listener stays + ## observable without tying log volume to attempt duration. + var failed_attempt := maxi(1, _reconnect_attempt) + var connecting_elapsed_sec := float( + transition.get("previous_elapsed_sec", 0.0) + ) + _log_reconnect_transition( + _preopen_failure_diagnostic( + failed_attempt, + connecting_elapsed_sec, + _reconnect_timer, + _peer.get_close_code(), + _peer.get_close_reason(), + _url + ), + failed_attempt, + ) + _reconnect_timer -= delta + if _reconnect_timer <= 0.0: + _attempt_reconnect() + + WebSocketPeer.STATE_CLOSING: + pass + WebSocketPeer.STATE_CONNECTING: + pass + +func get_session_id() -> String: + return _session_id + +## Drain up to PACKET_DRAIN_CAP_PER_TICK inbound packets and dispatch each +## via `_handle_message`. Anything past the cap stays in the peer's queue +## and gets picked up next tick. The cumulative spillover count is logged +## (via `log_buffer`) only when the cap was actually hit AND packets remain +## — sustained flood thus emits one log line per tick with the running +## total, while a normal-traffic frame stays silent. +## +## `peer` is untyped (Variant) so tests can inject a duck-typed fake with +## `get_available_packet_count()` + `get_packet()`. Production passes the +## real `_peer: WebSocketPeer`. +func _drain_inbound_packets(peer) -> Dictionary: + var drained := 0 + while peer.get_available_packet_count() > 0 and drained < PACKET_DRAIN_CAP_PER_TICK: + var raw: String = peer.get_packet().get_string_from_utf8() + _handle_message(raw) + drained += 1 + + var spilled := 0 + if drained >= PACKET_DRAIN_CAP_PER_TICK and peer.get_available_packet_count() > 0: + spilled = peer.get_available_packet_count() + _packet_spillover_total += spilled + if log_buffer: + log_buffer.log( + ( + "[backpressure] inbound drain capped at %d/tick;" + + " %d packets spilled to next frame (cumulative %d)" + ) + % [PACKET_DRAIN_CAP_PER_TICK, spilled, _packet_spillover_total] + ) + + return {"drained": drained, "spilled": spilled} + + +var is_connected: bool: + get: return _connected and _handshake_complete + + +func disconnect_from_server(reason := "Plugin unloading") -> void: + var was_authenticated := _handshake_complete + if _peer.get_ready_state() != WebSocketPeer.STATE_CLOSED: + _peer.close(1000, reason) + ## Drop the peer reference immediately instead of depending on a future + ## `_process()` poll to finish a closing handshake. Authority revocation + ## disables processing, so retaining a CLOSING peer here would keep the + ## underlying channel alive beyond the revocation boundary. + _peer = WebSocketPeer.new() + _observed_peer_state = WebSocketPeer.STATE_CLOSED + _peer_state_entered_msec = Time.get_ticks_msec() + _connected = false + ## A deliberate close owns all cleanup synchronously. Waiting for a later + ## CLOSED poll would leave authenticated commands dispatchable after root + ## revoked the transport authority. + _preopen_failure_logged_for_peer = true + _clear_on_disconnect() + if was_authenticated: + connection_state_changed.emit(false) + + +## Revoke a copied lifecycle authority immediately. This is stronger than +## `connect_blocked`: it also terminates an existing channel and erases every +## queued/deferred command before returning to the caller. +func revoke_transport(reason: String) -> void: + auth_token = "" + connect_blocked = true + connect_block_reason = reason + set_process(false) + disconnect_from_server("Transport authority revoked") + + +## Apply one proven lifecycle grant and dial immediately. Keeping the copied +## authority transition here makes revoke/authorize symmetric and avoids +## depending on a later CLOSED-peer frame after a plugin reload. +func authorize_transport(p_ws_port: int, p_auth_token: String) -> void: + ws_port = p_ws_port + auth_token = p_auth_token + connect_blocked = false + connect_block_reason = "" + server_version = "" + _reconnect_timer = 0.0 + set_process(true) + if is_inside_tree(): + _attempt_reconnect() + + +## 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 = "" + _handshake_started_msec = 0 + _client_nonce = "" + _server_nonce = "" + _challenged_server_version = "" + _server_verified = false + _auth_response_sent = false + _handshake_complete = false + ## 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 + _peer.inbound_buffer_size = MAX_HANDSHAKE_FRAME_BYTES + _peer.max_queued_packets = MAX_QUEUED_PACKETS + _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 +) -> String: + return ( + "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] + ) + + +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)] + + +func _handshake_close_diagnostic(code: int, authenticated := false) -> Dictionary: + if code == CLOSE_CODE_PROTOCOL_MISMATCH: + return { + "reason_code": "ws_protocol_mismatch", + "reason": "The editor and server use different Godot AI major protocols; update/restart both sides.", + } + if code == CLOSE_CODE_AUTH_FAILED: + return { + "reason_code": "ws_auth_failed", + "reason": "The v4 editor capability was rejected; restart the managed server from this Godot editor.", + } + if not authenticated: + return { + "reason_code": "ws_handshake_failed", + "reason": "The v4 handshake closed before authentication; update/restart both editor and server.", + } + return {} + + +## 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 _connected and not _handshake_complete: + snapshot["phase"] = "authenticating" + snapshot.erase("retry_in_sec") + 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_auth_hello() -> void: + _handshake_started_msec = maxi(1, Time.get_ticks_msec()) + if not _is_lower_hex_64(auth_token): + _fail_handshake( + CLOSE_CODE_AUTH_FAILED, + "missing v4 editor capability; restart the managed server from Godot", + ) + return + var nonce_bytes := Crypto.new().generate_random_bytes(32) + if nonce_bytes.size() != 32: + _fail_handshake(CLOSE_CODE_AUTH_FAILED, "could not generate v4 client nonce") + return + _client_nonce = nonce_bytes.hex_encode() + if not _send_json(_build_auth_hello(_client_nonce), true): + _fail_handshake(CLOSE_CODE_AUTH_FAILED, "could not send v4 auth hello") + + +static func _build_auth_hello(client_nonce: String) -> Dictionary: + return { + "type": "auth_hello", + "protocol_version": WS_PROTOCOL_VERSION, + "client_nonce": client_nonce, + } + + +func _handle_auth_challenge(parsed: Dictionary) -> void: + if _server_verified or parsed.get("protocol_version") != WS_PROTOCOL_VERSION: + _fail_handshake( + CLOSE_CODE_PROTOCOL_MISMATCH, + "Godot AI v4 WebSocket protocol %d required" % WS_PROTOCOL_VERSION, + ) + return + var expected_keys: Array[String] = [ + "type", + "protocol_version", + "client_nonce", + "server_nonce", + "server_version", + "server_proof", + ] + if ( + not _has_exact_keys(parsed, expected_keys) + or parsed.get("type") != "auth_challenge" + or parsed.get("client_nonce") != _client_nonce + or not _is_lower_hex_64(parsed.get("server_nonce")) + or not _is_lower_hex_64(parsed.get("server_proof")) + or not _is_bounded_text(parsed.get("server_version"), 64) + ): + _fail_handshake(CLOSE_CODE_AUTH_FAILED, "invalid v4 server challenge") + return + var expected_proof := _server_proof( + auth_token, + _client_nonce, + str(parsed["server_nonce"]), + str(parsed["server_version"]), + ) + if not _constant_time_equal(str(parsed["server_proof"]), expected_proof): + _fail_handshake(CLOSE_CODE_AUTH_FAILED, "v4 server proof failed") + return + + ## Only this proven transition unlocks project/session metadata. + _server_nonce = str(parsed["server_nonce"]) + _challenged_server_version = str(parsed["server_version"]) + _server_verified = true + _peer.inbound_buffer_size = OUTBOUND_BUFFER_LIMIT_BYTES + _last_readiness = get_readiness() + var response := _build_auth_response() + if response.is_empty() or not _send_json(response, true): + _fail_handshake(CLOSE_CODE_AUTH_FAILED, "could not send v4 editor proof") + return + _auth_response_sent = true + + +func _build_auth_response() -> Dictionary: + var godot_version := str(Engine.get_version_info().get("string", "unknown")) + var project_path := ProjectSettings.globalize_path("res://") + var plugin_version := ClientConfigurator.get_plugin_version() + var launch_mode := ClientConfigurator.get_server_launch_mode() + var editor_pid := OS.get_process_id() + if ( + not _is_bounded_text(_session_id, 128) + or not _is_bounded_text(godot_version, 64) + or not _is_bounded_text(project_path, 4096) + or not _is_bounded_text(plugin_version, 64) + or not _is_bounded_text(launch_mode, 32) + or _last_readiness not in ["ready", "importing", "playing", "no_scene"] + or editor_pid < 0 + ): + return {} + var client_proof := _client_proof( + auth_token, + _client_nonce, + _server_nonce, + _challenged_server_version, + _session_id, + godot_version, + project_path, + plugin_version, + _last_readiness, + editor_pid, + launch_mode, + ) + if not _is_lower_hex_64(client_proof): + return {} + return { + "type": "auth_response", + "protocol_version": WS_PROTOCOL_VERSION, + "client_nonce": _client_nonce, + "server_nonce": _server_nonce, + "client_proof": client_proof, + "session_id": _session_id, + "godot_version": godot_version, + "project_path": project_path, + "plugin_version": plugin_version, + "readiness": _last_readiness, + "editor_pid": editor_pid, + "server_launch_mode": launch_mode, + } + + +## 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: +## "challenge", "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 websocket JSON") + return {"kind": "ignore", "parsed": null} + if not (parsed is Dictionary): + return {"kind": "ignore", "parsed": null} + if parsed.get("type", "") == "auth_challenge": + return {"kind": "challenge", "parsed": parsed} + 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: + if not _handshake_complete and raw.to_utf8_buffer().size() > MAX_HANDSHAKE_FRAME_BYTES: + _fail_handshake(CLOSE_CODE_PROTOCOL_MISMATCH, "v4 handshake frame too large") + return + var classified := _classify_message(raw) + if not _handshake_complete: + match classified["kind"]: + "challenge": + _handle_auth_challenge(classified["parsed"]) + "ack": + _handle_handshake_ack(classified["parsed"]) + _: + _fail_handshake( + CLOSE_CODE_PROTOCOL_MISMATCH, + "Godot AI v4 expected auth_challenge or handshake_ack", + ) + return + match classified["kind"]: + "challenge", "ack": + _fail_handshake(CLOSE_CODE_PROTOCOL_MISMATCH, "unexpected v4 handshake frame") + "command": + if dispatcher: + dispatcher.enqueue(classified["parsed"]) + "malformed_command": + _reply_malformed_command(classified["parsed"]) + + +func _handle_handshake_ack(parsed: Dictionary) -> void: + var expected_keys: Array[String] = ["type", "protocol_version", "server_version"] + if ( + _handshake_complete + or not _server_verified + or not _auth_response_sent + or not _has_exact_keys(parsed, expected_keys) + or parsed.get("type") != "handshake_ack" + or parsed.get("protocol_version") != WS_PROTOCOL_VERSION + or parsed.get("server_version") != _challenged_server_version + ): + _fail_handshake(CLOSE_CODE_AUTH_FAILED, "invalid v4 handshake ACK") + return + server_version = _challenged_server_version + _handshake_complete = true + _reconnect_attempt = 0 + ## Re-emit non-default scene/play state only after the server has published + ## this authenticated peer and normal frames are legal. + _last_scene_path = "" + _last_play_state = false + _transient_diagnostic.clear() + if log_buffer: + log_buffer.log("authenticated with server") + connection_state_changed.emit(true) + + +func _fail_handshake(code: int, reason: String) -> void: + if log_buffer: + log_buffer.log("v4 websocket handshake failed: %s" % reason) + _peer.close(code, reason) + + +static func _has_exact_keys(value: Dictionary, expected: Array[String]) -> bool: + if value.size() != expected.size(): + return false + for key in expected: + if not value.has(key): + return false + return true + + +static func _is_bounded_text(value: Variant, max_length: int) -> bool: + return value is String and not String(value).is_empty() and String(value).length() <= max_length + + +static func _is_lower_hex_64(value: Variant) -> bool: + if not (value is String) or String(value).length() != 64: + return false + for character in String(value): + if not (character >= "0" and character <= "9") and not ( + character >= "a" and character <= "f" + ): + return false + return true + + +static func _proof_message(domain: String, values: Array) -> PackedByteArray: + var message := domain.to_utf8_buffer() + for value in values: + var encoded := str(value).to_utf8_buffer() + message.append_array(("\n%d:" % encoded.size()).to_utf8_buffer()) + message.append_array(encoded) + return message + + +static func _hmac_proof(capability: String, domain: String, values: Array) -> String: + if not _is_lower_hex_64(capability): + return "" + var context := HMACContext.new() + if context.start(HashingContext.HASH_SHA256, capability.to_utf8_buffer()) != OK: + return "" + if context.update(_proof_message(domain, values)) != OK: + return "" + return context.finish().hex_encode() + + +static func _server_proof( + capability: String, + client_nonce: String, + server_nonce: String, + server_version_value: String, +) -> String: + return _hmac_proof( + capability, + _SERVER_PROOF_DOMAIN, + [WS_PROTOCOL_VERSION, client_nonce, server_nonce, server_version_value], + ) + + +static func _client_proof( + capability: String, + client_nonce: String, + server_nonce: String, + server_version_value: String, + session_id: String, + godot_version: String, + project_path: String, + plugin_version: String, + readiness: String, + editor_pid: int, + server_launch_mode: String, +) -> String: + return _hmac_proof( + capability, + _CLIENT_PROOF_DOMAIN, + [ + WS_PROTOCOL_VERSION, + client_nonce, + server_nonce, + server_version_value, + session_id, + godot_version, + project_path, + plugin_version, + readiness, + editor_pid, + server_launch_mode, + ], + ) + + +static func _constant_time_equal(left: String, right: String) -> bool: + var difference := left.length() ^ right.length() + var length := maxi(left.length(), right.length()) + for index in length: + var left_code := left.unicode_at(index) if index < left.length() else 0 + var right_code := right.unicode_at(index) if index < right.length() else 0 + difference |= left_code ^ right_code + return difference == 0 + + +## 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 +## (`DEFAULT_KEEPALIVE_PING_INTERVAL_SECONDS` / `_PING_TIMEOUT_SECONDS` in +## `transport/websocket.py` — 20s interval, 60s pong deadline since #958) +## 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"]: + "challenge", "ack": + _fail_handshake(CLOSE_CODE_PROTOCOL_MISMATCH, "unexpected v4 handshake frame") + "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) + + +## Stopped↔playing edge handler for game-run bookkeeping. Handles both the +## stopped→playing edge (adopting editor-started runs) and the playing→stopped +## edge (ending runs). 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 debugger_plugin != null: + if playing: + ## #891: adopt a play the user started from the editor. The + ## debugger session's own adoption in `_setup_session` only fires + ## when `is_playing_scene()` is already true when the session + ## attaches, which an F5/F6 launch routinely misses; this edge is + ## the other half of that race. Polled every tick regardless of + ## socket state, same as the stop edge below. + debugger_plugin.note_editor_play_started() + else: + 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, allow_pre_auth := false) -> bool: + if not _connected or (not _handshake_complete and not allow_pre_auth): + return false + var text := JSON.stringify(data) + if allow_pre_auth and text.to_utf8_buffer().size() > MAX_HANDSHAKE_FRAME_BYTES: + return false + 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 "@<16hex>" 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 random_bytes := Crypto.new().generate_random_bytes(8) + if random_bytes.size() != 8: + return "" + var suffix := random_bytes.hex_encode() + 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 _should_regenerate_session_id( + close_code: int, + server_verified: bool, + handshake_complete: bool, +) -> bool: + ## A duplicate rejection arrives after the server proved possession of the + ## capability but before its final ACK. Ignore unauthenticated close codes so + ## an unrelated listener cannot churn the editor identity. + return ( + close_code == CLOSE_CODE_DUPLICATE_SESSION + and server_verified + and not handshake_complete + ) 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/custom_tools/custom_tool_wrapper.gd b/addons/godot_ai/custom_tools/custom_tool_wrapper.gd new file mode 100644 index 0000000..b4e13c7 --- /dev/null +++ b/addons/godot_ai/custom_tools/custom_tool_wrapper.gd @@ -0,0 +1,80 @@ +@tool +extends RefCounted + +## bridge between dispatcher and addon handler + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +var _spec: McpCustomToolSpec +var _locator: McpServiceLocator +var _handler_instance = null # lazily loaded from _spec.script_path + +func _init(spec: McpCustomToolSpec, locator: McpServiceLocator) -> void: + _spec = spec + _locator = locator + + +func quiesce_for_script_swap() -> Dictionary: + if _handler_instance == null: + return {"ok": true} + if not _handler_instance.has_method("quiesce_for_script_swap"): + return { + "ok": false, + "error": "Custom tool '%s' must provide quiesce_for_script_swap() before hot updates." % _spec.name, + } + var result: Variant = _handler_instance.call("quiesce_for_script_swap") + if not result is Dictionary or result.get("ok") != true: + return {"ok": false, "error": "Custom tool '%s' still owns active work." % _spec.name} + return {"ok": true} + +## Invoked by dispatcher._call_handler as .call(params) — SINGLE ARG. +## Internally splits into (clean_params, ctx) for the addon handler. +func invoke(params: Dictionary) -> Dictionary: + ## Dock enable/disable gate: a disabled tool is dropped from the + ## catalog push, but a client holding a stale list (or batch_execute) + ## can still name it — reject at dispatch too. + var registry := McpToolRegistry.get_instance() + if registry != null and not registry.is_tool_enabled(_spec.name): + return ErrorCodes.make(ErrorCodes.CUSTOM_TOOL_DISABLED, + "Custom tool '%s' is disabled in the Godot AI dock" % _spec.name) + ## Readiness gate (https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #1): block writes during play/import. + ## Checked BEFORE the lazy load so a gated tool's handler script (and any + ## _init side effects) never runs while the editor is busy. + var _readiness := McpConnection.get_readiness() + if _spec.requires_writable and (_readiness == "importing" or _readiness == "playing"): + return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "Editor is '%s' — write blocked for custom tool '%s'" % [_readiness, _spec.name]) + if _handler_instance == null: + var script := load(_spec.script_path) as GDScript + if script == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Cannot load %s" % _spec.script_path) + _handler_instance = script.new() # no-arg; per-call context arrives via ctx + if not _handler_instance.has_method(_spec.method): + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "%s has no method '%s' for custom tool '%s'" % [_spec.script_path, _spec.method, _spec.name]) + ## Extract _request_id (dispatcher injected it) and strip from params + ## so the addon sees clean params matching its declared schema (https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #2). + var request_id: String= params.get("_request_id", "") + var clean_params := params.duplicate() + clean_params.erase("_request_id") + ## Construct ctx with transport metadata + live-object locator. + var ctx := McpCallContext.new() + ctx.request_id = request_id + ctx.session_id = _locator.get_connection().get_session_id() + ctx.spec = _spec + ctx.attach_locator(_locator) + ctx.deadline_msec = Time.get_ticks_msec() + _spec.timeout_ms + var result: Dictionary = _handler_instance.call(_spec.method, clean_params, ctx) + if result.get("_deferred", false): + ## Enforce the declared contract: batch_execute and the server's + ## timeout budget both trust spec.deferred, so a non-deferred spec + ## whose handler defers anyway would report success while its real + ## reply arrives uncorrelated later. + if not _spec.deferred: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, + "Custom tool '%s' returned a deferred response but its spec declares deferred=false" % _spec.name) + if _spec.timeout_ms > 0: + ## Handlers return the SHARED McpDispatcher.DEFERRED_RESPONSE + ## const, which Godot makes read-only — stamping the per-spec + ## budget on it directly is a script error that aborts the call. + result = result.duplicate() + result["_deferred_timeout_ms"] = _spec.timeout_ms + return result diff --git a/addons/godot_ai/custom_tools/custom_tool_wrapper.gd.uid b/addons/godot_ai/custom_tools/custom_tool_wrapper.gd.uid new file mode 100644 index 0000000..ff02cb9 --- /dev/null +++ b/addons/godot_ai/custom_tools/custom_tool_wrapper.gd.uid @@ -0,0 +1 @@ +uid://b68uel6lsb7jr diff --git a/addons/godot_ai/custom_tools/mcp_call_context.gd b/addons/godot_ai/custom_tools/mcp_call_context.gd new file mode 100644 index 0000000..6e40d86 --- /dev/null +++ b/addons/godot_ai/custom_tools/mcp_call_context.gd @@ -0,0 +1,44 @@ +@tool +class_name McpCallContext +extends RefCounted + +## Per-call context handed to addon handlers. +## +## STABLE addon-facing surface (see AGENTS.md — published class_names are +## permanent compat surface): `request_id`, `session_id`, `deadline_msec`, +## `spec` (read-only), `is_expired()`, `send_deferred()`. Anything +## underscore-prefixed is internal wiring owned by the wrapper and may +## change between releases — addons must not reach into it. Keeping this +## surface narrow now is deliberate: it is much cheaper than breaking +## published addons later (#875 review). + +var request_id: String = "" +var session_id: String = "" +var deadline_msec: int = 0 +var spec: McpCustomToolSpec = null + +## Internal — injected by custom_tool_wrapper via attach_locator(). Do not +## use from addon code; the capability methods below are the contract. +var _locator: McpServiceLocator = null + + +## Internal — called by the wrapper during ctx construction. +func attach_locator(locator: McpServiceLocator) -> void: + _locator = locator + + +func is_expired() -> bool: + if deadline_msec == 0: + return false + return Time.get_ticks_msec() > deadline_msec + + +func send_deferred(payload: Dictionary) -> void: + if _locator == null: + push_error("McpCallContext: cannot send deferred response, locator is null") + return + var conn := _locator.get_connection() + if conn == null: + push_error("McpCallContext: cannot send deferred response, connection is null") + return + conn.send_deferred_response(request_id, payload) diff --git a/addons/godot_ai/custom_tools/mcp_call_context.gd.uid b/addons/godot_ai/custom_tools/mcp_call_context.gd.uid new file mode 100644 index 0000000..e37765b --- /dev/null +++ b/addons/godot_ai/custom_tools/mcp_call_context.gd.uid @@ -0,0 +1 @@ +uid://bdgqe4gyian2o diff --git a/addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd b/addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd new file mode 100644 index 0000000..8c5a551 --- /dev/null +++ b/addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd @@ -0,0 +1,66 @@ +@tool +class_name McpCustomToolSpec +extends RefCounted + +## Descriptor for one custom tool registered by an addon. Mirrors the +## clients/_base.gd data-only descriptor pattern: fields are set by the +## caller, NO Callables (handler instance is materialized lazily by the +## dispatcher from script_path + method, so hot-reload can't SEGV a +## worker mid-call — same rationale as McpClient, issue #229). +## +## Construction: instantiate and set fields, then pass to +## McpToolRegistry.register(spec). Do NOT subclass — custom tools are +## dynamic (one addon may register several), not file-system-scanned +## like clients. + +# --- identity --- +var name: String = "" ## tool name, e.g. "gdunit_run". Must not shadow a built-in. +var description: String = "" ## shown to the agent by the MCP server +var params_schema: Dictionary = {} ## JSON Schema; advertised to the agent for shaping calls. Params are forwarded UNVALIDATED — the handler must validate its own input. + +# --- handler resolution (lazy materialization by dispatcher) --- +var script_path: String = "" ## "res://addons/.../handler.gd"; load()ed on first call +var method: StringName = &"" ## method on the handler; signature: (params: Dictionary, ctx: McpCallContext) -> Dictionary + +# --- source identity (https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #8) --- +var source_path: String = "" ## "plugin.cfg" path: "res://addons/gdunit4_mcp/plugin.cfg". Same path = same addon (replace allowed); different path colliding = reject + dock warning. NOTE: self-declared — a collision/ownership policy for cooperating addons, NOT a security boundary (any in-editor code can claim any path). +var source: String = "" ## display: "gdunit4_mcp". If empty, registry reads [plugin] name from source_path. + +# --- exposure --- +var promoted: bool = false ## opt-in: ask the server to ALSO register this tool as a first-class MCP tool ("custom_") with params_schema attached, so agents get native schemas/validation instead of the custom_manage indirection. The server caps promoted count; overflow stays reachable via custom_manage. + +# --- execution contract --- +var timeout_ms: int = 4500 ## deferred timeout → DEFERRED_TIMEOUT_MS_BY_COMMAND["custom:"] +var deferred: bool = false ## true → handler may return DEFERRED_RESPONSE, push later via ctx.send_deferred(payload) +var requires_writable: bool = false ## https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #1: readiness gate. false → reads run any time; true → blocks during play/import +var undoable: bool = false ## https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #6: must be true to participate in undo=true batch_execute + +# --- budgets (https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #5, enforced at register time) --- +const MAX_DESCRIPTION_CHARS := 600 +const MAX_SCHEMA_BYTES := 8192 +const MAX_TIMEOUT_MS := 120000 +const MIN_TIMEOUT_MS := 500 + + +## Returns an array of human-readable validation errors (empty = valid). +## Called by McpToolRegistry.register() and usable standalone for early +## feedback in addon _enter_tree before the registry is even live. +func validate() -> Array[String]: + var errors: Array[String] = [] + if name.is_empty(): + errors.append("name is empty\n") + if not name.is_valid_ascii_identifier(): + errors.append("name '%s' is not a valid identifier\n" % name) + if description.length() > MAX_DESCRIPTION_CHARS: + errors.append("description exceeds %d chars\n" % MAX_DESCRIPTION_CHARS) + if JSON.stringify(params_schema).to_utf8_buffer().size() > MAX_SCHEMA_BYTES: + errors.append("params_schema exceeds %d bytes\n" % MAX_SCHEMA_BYTES) + if script_path.is_empty() or not ResourceLoader.exists(script_path): + errors.append("script_path '%s' does not exist" % script_path) + if method.is_empty(): + errors.append("method is empty") + if timeout_ms < MIN_TIMEOUT_MS or timeout_ms > MAX_TIMEOUT_MS: + errors.append("timeout_ms %d out of range [%d, %d]" % [timeout_ms, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS]) + if source_path.is_empty() or not source_path.ends_with("plugin.cfg") or not FileAccess.file_exists(source_path): + errors.append("source_path '%s' must be an existing plugin.cfg path" % source_path) + return errors diff --git a/addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd.uid b/addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd.uid new file mode 100644 index 0000000..42fe408 --- /dev/null +++ b/addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd.uid @@ -0,0 +1 @@ +uid://h7k8j5tpscwy diff --git a/addons/godot_ai/custom_tools/mcp_service_locator.gd b/addons/godot_ai/custom_tools/mcp_service_locator.gd new file mode 100644 index 0000000..4e50ac5 --- /dev/null +++ b/addons/godot_ai/custom_tools/mcp_service_locator.gd @@ -0,0 +1,16 @@ +@tool +class_name McpServiceLocator +extends RefCounted + +var _connection: McpConnection = null +var _log_buffer: McpLogBuffer = null + +func setup(connection: McpConnection, log_buffer: McpLogBuffer) -> void: + _connection = connection + _log_buffer = log_buffer + +func get_connection() -> McpConnection: + return _connection + +func get_log_buffer() -> McpLogBuffer: + return _log_buffer diff --git a/addons/godot_ai/custom_tools/mcp_service_locator.gd.uid b/addons/godot_ai/custom_tools/mcp_service_locator.gd.uid new file mode 100644 index 0000000..1815007 --- /dev/null +++ b/addons/godot_ai/custom_tools/mcp_service_locator.gd.uid @@ -0,0 +1 @@ +uid://dfvphmvo7en3i diff --git a/addons/godot_ai/custom_tools/mcp_tool_registry.gd b/addons/godot_ai/custom_tools/mcp_tool_registry.gd new file mode 100644 index 0000000..407d405 --- /dev/null +++ b/addons/godot_ai/custom_tools/mcp_tool_registry.gd @@ -0,0 +1,277 @@ +@tool +class_name McpToolRegistry +extends RefCounted + +## Central registry for custom tools. INSTANCE SINGLETON (not static) — +## unlike McpClientRegistry which is read-only/loaded-once/query-only (static +## is fine there), this registry is MUTABLE, has LIFECYCLE (clear on teardown), +## must EMIT SIGNALS to multiple consumers, and needs DEPENDENCY INJECTION +## (dispatcher, locator). Static funcs can't emit instance signals in GDScript, +## and static vars create #46 freed-instance regressions across reloads. +## +## plugin.gd owns the instance (new + setup + clear). Addons access it via +## get_instance() so they don't hold a reference. Source identity is +## spec.source_path (explicit plugin.cfg path, https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #8) — same source_path +## = same addon (hot-reload replace); different source_path colliding on +## a name = reject + dock warning. + +## singleton instance +static var _instance: McpToolRegistry = null + +## Singleton accessor for addons. Returns null if Godot AI isn't loaded. +static func get_instance() -> McpToolRegistry: + return _instance + + +## Instance fields (NOT static — lifecycle tied to plugin.gd) +var _specs: Dictionary = {} # name -> McpCustomToolSpec +var _by_source_path: Dictionary = {} # source_path -> Array[McpCustomToolSpec] (batch unregister index) +var _dispatcher: McpDispatcher = null # injected by plugin.gd via setup() +var _locator: McpServiceLocator = null # injected by plugin.gd via setup() +var _ready: bool = false +var _promoted_cnt = 0 + +## Instance signals (LEGAL — instance funcs can emit these). Multiple consumers +## (plugin.gd, dock, addons) can connect. +signal tools_changed # triggers custom_tools_changed push to server +signal registry_ready # https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #8: emit after Godot AI reload so addons can re-register + + +## Called by plugin.gd in _enter_tree. Injects live dependencies and registers +## the singleton. Addons can now reach the registry via get_instance(). +func setup(dispatcher: McpDispatcher, locator: McpServiceLocator) -> void: + _dispatcher = dispatcher + _locator = locator + _load_disabled_tools() + _instance = self + + +func register(spec: McpCustomToolSpec) -> bool: + if not _validate_one(spec): + return false + _commit_one(spec) + tools_changed.emit() + return true + + +## Atomic batch registration: validate ALL specs first (phase 1, zero +## mutation), then commit ALL (phase 2, cannot fail), then emit ONCE. +## A phase-1 failure leaves the registry AND dispatcher untouched — a +## per-spec register-then-bail loop would leave earlier specs committed +## and dispatchable while the server's tool list never hears about them. +func batch_register(specs: Array[McpCustomToolSpec]) -> bool: + if specs.is_empty(): + return true + ## Phase 1 — validate only. Also reject duplicate names WITHIN the + ## batch: _validate_one checks against committed state (nothing from + ## this batch is committed yet), and silently letting the last + ## duplicate win would hide an addon packaging bug. + var batch_names := {} + for spec in specs: + if not _validate_one(spec): + return false + if batch_names.has(spec.name): + push_warning("McpToolRegistry: duplicate name '%s' within batch — rejected" % spec.name) + return false + batch_names[spec.name] = true + ## Phase 2 — commit everything, emit once. + for spec in specs: + _commit_one(spec) + tools_changed.emit() + return true + + +## Phase 1 helper: validation + collision policy. NO registry/dispatcher +## mutation — the only side effect is filling the display-source fallback +## on the spec itself, which is harmless on reject. +func _validate_one(spec: McpCustomToolSpec) -> bool: + var errors := spec.validate() + if not errors.is_empty(): + push_error("McpToolRegistry: rejecting spec '%s': %s" % [spec.name, ", ".join(errors)]) + return false + ## Fill display source from plugin.cfg if addon left it empty. + if spec.source.is_empty(): + spec.source = _read_plugin_name(spec.source_path) + ## reject-on-collision: same source_path (hot-reload) replaces, different rejects + var existing: McpCustomToolSpec= _specs.get(spec.name) + if existing != null and existing.source_path != spec.source_path: + push_warning("McpToolRegistry: name '%s' collision between %s and %s — rejected" % [spec.name, existing.source_path, spec.source_path]) + return false + return true + + +## Phase 2 helper: commit one ALREADY-VALIDATED spec. Handles the +## hot-reload replace (same source_path — guaranteed by _validate_one). +## Never fails, never emits — callers own the tools_changed emit. +func _commit_one(spec: McpCustomToolSpec) -> void: + var existing: McpCustomToolSpec = _specs.get(spec.name) + if existing != null: + ## Replace: purge the OLD registration from all four dispatcher + ## dicts. Command name is "custom_tool:" — passing the bare + ## name would leave a materialized Callable in _handlers and the + ## hot-reloaded spec would silently never take effect. + _dispatcher.unregister("custom_tool:" + existing.name, "custom:" + existing.name) + _erase_from_source_path_index_locked(existing) + _specs[spec.name] = spec + _by_source_path.get_or_add(spec.source_path, []).append(spec) + ## Bridge to dispatcher lazy registration: register_lazy_handler + register_lazy. + ## handler_key = "custom:"; wrapper is CustomToolWrapper (https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #2). + + var handler_key := "custom:" + spec.name + var wrapper_path := "res://addons/godot_ai/custom_tools/custom_tool_wrapper.gd" + _dispatcher.register_lazy_handler(handler_key, wrapper_path, [spec, _locator]) + _dispatcher.register_lazy("custom_tool:" + spec.name, handler_key, &"invoke") + + +func unregister(name: String) -> bool: + var spec: McpCustomToolSpec = _specs.get(name) + if spec == null: + return false + if _dispatcher != null: + _dispatcher.unregister("custom_tool:" + name, "custom:" + name) + _specs.erase(name) + _erase_from_source_path_index_locked(spec) + tools_changed.emit() + return true + + +## https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #8: batch-unregister all tools from one addon. Pass the source_path +## (plugin.cfg path) — the registry removes every spec whose source_path matches. +func unregister_source(source_path: String) -> int: + var specs: Array = _by_source_path.get(source_path, []) + var count := specs.size() + for spec in specs.duplicate(): + if _dispatcher != null: + _dispatcher.unregister("custom_tool:" + spec.name, "custom:" + spec.name) + _specs.erase(spec.name) + _by_source_path.erase(source_path) + if count > 0: + tools_changed.emit() + return count + + +func all() -> Array[McpCustomToolSpec]: + var out: Array[McpCustomToolSpec] = [] + for spec in _specs.values(): + out.append(spec) + return out + + +## Enabled specs only — the catalog-push source. Disabled tools stay +## registered (the dock still lists them for re-enabling) but are never +## advertised to the server and are rejected at dispatch. +func enabled() -> Array[McpCustomToolSpec]: + var out: Array[McpCustomToolSpec] = [] + for spec in _specs.values(): + if is_tool_enabled(spec.name): + out.append(spec) + return out + + +# --- per-tool enable state (dock UI) --- +## Persisted per-project via EditorSettings project metadata (the same +## store the editor uses for per-project UI state — no project.godot +## churn, no cross-project bleed). Applies LIVE: toggling re-emits +## tools_changed, which re-pushes the filtered catalog to the server. + +const _META_SECTION := "godot_ai" +const _META_KEY_DISABLED := "disabled_custom_tools" + +var _disabled_tools: Dictionary = {} # name -> true + + +func is_tool_enabled(name: String) -> bool: + return not _disabled_tools.has(name) + + +func set_tool_enabled(name: String, tool_enabled: bool) -> void: + if tool_enabled: + if not _disabled_tools.has(name): + return + _disabled_tools.erase(name) + else: + if _disabled_tools.has(name): + return + _disabled_tools[name] = true + _save_disabled_tools() + tools_changed.emit() + + +func _load_disabled_tools() -> void: + _disabled_tools.clear() + var es := _editor_settings() + if es == null: + return + var stored: Variant = es.get_project_metadata(_META_SECTION, _META_KEY_DISABLED, PackedStringArray()) + for name in PackedStringArray(stored): + _disabled_tools[String(name)] = true + + +func _save_disabled_tools() -> void: + var es := _editor_settings() + if es == null: + return + var names := PackedStringArray() + for name in _disabled_tools.keys(): + names.append(String(name)) + es.set_project_metadata(_META_SECTION, _META_KEY_DISABLED, names) + + +static func _editor_settings() -> EditorSettings: + if not Engine.is_editor_hint(): + return null + return EditorInterface.get_editor_settings() + + +func get_spec(name: String) -> McpCustomToolSpec: + var out: McpCustomToolSpec = _specs.get(name) + return out + + +func is_ready() -> bool: + return _ready + + +## Called from plugin.gd teardown — wipes everything so stale specs/Callables +## don't survive a Godot AI reload (#46 regression class). Signals die with +## the instance (consumers disconnect via plugin.gd's own teardown). +func clear() -> void: + _specs.clear() + _by_source_path.clear() + _disabled_tools.clear() # in-memory only; persistence reloads on next setup() + _ready = false + _dispatcher = null + _locator = null + _instance = null + + +## Called from plugin.gd _enter_tree after setup(). Addons that missed +## _enter_tree during a Godot AI reload listen for registry_ready (via +## get_instance().registry_ready.connect(...)) and re-register deterministically. +func mark_ready() -> void: + _ready = true + registry_ready.emit() + + +# --- internal helpers --- + +func _erase_from_source_path_index_locked(spec: McpCustomToolSpec) -> void: + var specs: Array = _by_source_path.get(spec.source_path, []) + specs.erase(spec) + if specs.is_empty(): + _by_source_path.erase(spec.source_path) + + +## Read [plugin] name from a plugin.cfg for display purposes. +## Returns "" on any error — display source is best-effort. +static func _read_plugin_name(plugin_cfg_path: String) -> String: + var cfg := ConfigFile.new() + if cfg.load(plugin_cfg_path) != OK: + return "" + return cfg.get_value("plugin", "name", "") + +static func _get_command_name(spec: McpCustomToolSpec) -> String: + return "custom_tool:" + spec.name + +static func _get_handler_name(spec: McpCustomToolSpec) -> String: + return "custom:" + spec.name diff --git a/addons/godot_ai/custom_tools/mcp_tool_registry.gd.uid b/addons/godot_ai/custom_tools/mcp_tool_registry.gd.uid new file mode 100644 index 0000000..23b9822 --- /dev/null +++ b/addons/godot_ai/custom_tools/mcp_tool_registry.gd.uid @@ -0,0 +1 @@ +uid://bq2btxtm8flhb 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..0bf9320 --- /dev/null +++ b/addons/godot_ai/debugger/mcp_debugger_plugin.gd @@ -0,0 +1,2088 @@ +@tool +class_name McpDebuggerPlugin +extends EditorDebuggerPlugin + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") +const ScriptWork := preload("res://addons/godot_ai/utils/script_work.gd") + +## Editor-side half of the game-process capture bridge. +## +## The game-side counterpart (`plugin/addons/godot_ai/runtime/game_helper.gd`, +## registered as autoload `_mcp_game_helper`) listens on EngineDebugger's +## message channel. This plugin sends "mcp:take_screenshot" requests and +## routes the replies back through the WebSocket McpConnection using the +## request_id the MCP dispatcher threaded through params. +## +## Why this exists: the game always runs as a separate OS process. Even +## "Embed Game Mode" on Windows/Linux (and macOS 4.5+) just reparents the +## game's window into the editor — the game's framebuffer is never reachable +## from the editor's Viewport. The debugger channel is the engine's own +## supported IPC and works identically regardless of embed mode. + +const CAPTURE_PREFIX := "mcp" +## CI runners under xvfb can be slow to spin up the game subprocess and +## register the autoload's capture. 8s keeps the message responsive for +## interactive users while still covering slow-CI startup. +const DEFAULT_TIMEOUT_SEC := 8.0 +## How long to wait for the game-side autoload to beacon mcp:hello +## before sending the screenshot request. Godot's debugger drops +## messages whose prefix has no registered capture, so sending +## take_screenshot before the game registers its "mcp" capture is a +## silent black hole. On CI the game subprocess has been observed +## taking ~15s to boot + register. +const GAME_READY_WAIT_SEC := 20.0 +## #500: how long to wait for the game-side autoload to beacon mcp:hello before +## issuing a game_eval. This is deliberately MUCH shorter than the 20s +## screenshot wait above: the eval path's total editor-side budget is this wait +## plus the 250ms liveness probe and 10s eval backstop +## (request_game_eval's timeout_sec), and that total MUST stay below the 15s +## game_eval timeout enforced at two layers: the Python server's send_command +## budget (src/godot_ai/handlers/editor.py::game_eval) and this plugin's own +## deferred budget (dispatcher.gd's 15000ms game_eval entry, editor/plugin-side +## — not server-side). Either firing produces the opaque tail. +## With the 20s screenshot wait, a not-yet-ready game made the editor poll past +## the 15s deadline, so the server gave up first with an opaque +## ~15s TimeoutError instead of the actionable "Is the game actually running?" +## error below ever reaching the client (#500's residual TimeoutError bucket). +## 3s wait + 0.25s probe + 10s backstop = 13.25s, comfortably under the 15s +## server timeout, so the actionable error always wins. A game launched moments +## before the eval still has the 3s grace to register; if it needs longer, the +## user gets a fast, clear "is it running?" rather than a 15s hang. +const EVAL_READY_WAIT_SEC := 3.0 +## #859: once the helper has registered, confirm its game loop is still +## advancing before dispatching an eval. The helper replies synchronously from +## the debugger capture, so 250ms is ample while keeping not-ready failures +## below the telemetry target of 0.5s. +const EVAL_LIVENESS_WAIT_SEC := 0.25 +## #490: how long to wait for the game's mcp:eval_compiled beacon before +## concluding the eval source failed to compile. A parse error aborts the +## game-side handler before it can reply, so without this we'd wait the +## full eval timeout for a syntax mistake. reload() of valid source is +## sub-millisecond, so 3s is comfortably clear of false positives. +const EVAL_COMPILE_GRACE_SEC := 3.0 +## #490: once an eval compiles, the editor polls the game every this many +## seconds with mcp:eval_check. A backgrounded play-in-editor game has a +## frozen idle loop (no _process / SceneTreeTimer ticks) so it can't +## self-report a runtime error that aborted the eval — but its debugger +## capture callback still answers a probe. The editor's own loop keeps +## ticking, so it drives the poll. 0.35s keeps detection well under a second +## without flooding the channel; most evals reply before the first probe. +const EVAL_PROBE_INTERVAL_SEC := 0.35 +const GAME_DEBUG_CONTROL_TIMEOUT_SEC := 5.0 +const EMBED_SUSPEND_TOGGLE := 0 +const EMBED_NEXT_FRAME := 1 + +const VisionRoutingScript := preload("res://addons/godot_ai/vision_routing.gd") + +var _log_buffer: McpLogBuffer +var _game_log_buffer: McpGameLogBuffer +var _editor_log_buffer: McpEditorLogBuffer +var _surfaced_error_tracker +var vision_routing: VisionRoutingScript = null + +## Pending request_id -> phase-specific data. Every eval phase carries kind +## and connection; timer-backed phases also retain their bound timeout lambda +## so `_clear_pending` can disconnect it on success/error. Otherwise the +## SceneTreeTimer pins the captured request_id until its timeout elapses. +var _pending: Dictionary = {} + +## Testing seam for the readiness wait below. Production leaves this invalid +## and awaits SceneTree.process_frame; editor tests inject a synchronous +## callable because their runner does not pump frames while a test is running. +var _eval_ready_frame_waiter: Callable = Callable() + +## Flipped true when the game-side autoload sends its "mcp:hello" boot +## beacon for the current project_run. Reset as soon as a new run is +## requested, before Godot has attached the fresh debugger session, so +## editor_state cannot leak readiness from the previous game process. +var _game_ready := false +var _game_run_token := 0 +var _ready_run_token := -1 +var _game_session_id := -1 +var _game_run_active := false +## Debugger session id of an `mcp:hello` that arrived before the run was +## adopted, or -1 when none is held (#891). A manually started play can beat +## its own adoption: the helper's boot beacon is a one-shot, so discarding it +## left the runtime bridge dead for the whole run with no retry. Buffer it and +## replay on adoption instead. Cleared whenever a run ends, so a beacon can +## never carry across into a later run. +var _pending_hello_session_id := -1 +## True when holding a beacon already rotated the game-log run id, so the +## adoption that consumes it must not rotate again (#891 review). +var _pre_adoption_log_rotated := 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 quiesce_for_script_swap() -> Dictionary: + if not _pending.is_empty(): + return {"ok": false, "error": "Wait for pending game debugger requests before updating."} + return ScriptWork.quiescence() + + +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, + keep_session_id: int = -1, +) -> void: + _game_run_token += 1 + _game_run_active = true + _manual_run_armed = manual_armed + _game_ready = false + _ready_run_token = -1 + ## Adopting a run whose game is ALREADY attached keeps that session id + ## (#891 review): clearing it would leave every staleness guard comparing + ## against -1, so a foreign session's `stopped` could end this live run — + ## `_on_debugger_session_stopped` lets a manual-armed run end on any + ## session while the id is unknown. Spawn-first callers pass -1 and keep + ## the historical clear, since their game has not attached yet. + _game_session_id = keep_session_id + 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: + if _pre_adoption_log_rotated: + ## Already rotated when this game's beacon was held, so its boot + ## output is tagged with the run id this adoption is about to + ## announce. Rotating again here would orphan those lines under a + ## superseded id and `logs_read(source="game")` — which returns the + ## CURRENT run only — would report an empty log for a game whose + ## only output happens in `_ready()` (#891 review). + run_id = _game_log_buffer.run_id() + else: + run_id = _game_log_buffer.clear_for_new_run() + _pre_adoption_log_rotated = false + 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) + ## #891: a beacon that beat this adoption is held rather than dropped — + ## consume it now so the bridge comes up without waiting for a second + ## hello the game will never send. + _replay_pending_hello() + + +func _editor_log_cursor() -> int: + return _editor_log_buffer.appended_total() if _editor_log_buffer != null else 0 + + +func end_game_run() -> void: + _fail_pending_evals_not_ready( + "The game run ended before game_eval completed — the game stopped, crashed, or is restarting. Confirm it is running and retry." + ) + _fail_pending_game_debug_controls_not_ready( + "The game run ended before runtime control completed — confirm it is running and retry." + ) + ## Never carry a held beacon across a run boundary (#891). + _pending_hello_session_id = -1 + _pre_adoption_log_rotated = false + _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() + + +## Editor play-state edge, stopped -> playing (#891). `_setup_session` adopts a +## manually started play only when `EditorInterface.is_playing_scene()` is +## ALREADY true at the instant Godot attaches the debugger session — an F5/F6 +## launch routinely loses that race, and the run was then never adopted at all, +## leaving game_eval, game logs and runtime inspection silently dead for its +## whole lifetime. This closes the race from the other side: whichever of the +## two edges lands second performs the adoption. No-op for MCP-started runs, +## which `project_run` already adopted via `begin_game_run`. +func note_editor_play_started() -> void: + if _game_run_active: + return + ## State belonging to the game that is already talking must survive the + ## adoption that is about to reset run bookkeeping (#891 review). + var attached_session := _game_session_id + var had_break := _break_active + var break_can_debug := _break_can_debug + var break_reason := _break_reason + _begin_game_run_tracking( + _editor_log_cursor(), true, true, true, true, true, attached_session + ) + if had_break: + ## A boot parse error can break the game before the play-state edge + ## lands. `_begin_game_run_tracking` clears break state, which would + ## drop the actionable #645 diagnosis and leave game_status reporting + ## "not_live" instead of "break". Re-record it against the new run so + ## `_break_pre_live` and the synthetic-error scan are computed for THIS + ## run — the beacon will never arrive for a game parked at a break. + note_debug_break(break_can_debug, break_reason) + + +## 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: + ## A beacon held for an adoption that never happened must not survive + ## into the next run (#891). + _pending_hello_session_id = -1 + _pre_adoption_log_rotated = false + return + end_game_run() + + +## Apply the game helper's boot beacon: the game has registered its "mcp" +## capture and is safe to send take_screenshot / eval requests to — before +## this, Godot's debugger drops our messages silently. Shared by the live +## `mcp:hello` branch in `_capture` and by the buffered replay in +## `_replay_pending_hello`, so a held beacon produces exactly the same state +## and side effects as one that arrived after adoption (#891). +func _accept_game_hello(session_id: int) -> void: + _pending_hello_session_id = -1 + ## Bind the run to the game that actually announced itself. Adoption + ## clears `_game_session_id`, so without this a replayed beacon would + ## leave the run bound to nothing and the staleness checks toothless + ## until Godot's next `_setup_session` (#891 review). + if session_id != -1: + _game_session_id = session_id + _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") + + +## Consume a beacon that arrived before this run was adopted. Called at the end +## of run-tracking setup; no-op when nothing is held (#891). +func _replay_pending_hello() -> void: + if _pending_hello_session_id == -1: + return + var held := _pending_hello_session_id + if _game_session_id != -1 and held != _game_session_id: + ## Belongs to a different debugger session than the one this run is + ## bound to — drop it rather than declare the wrong game live. + _pending_hello_session_id = -1 + if _log_buffer: + _log_buffer.log("[debug] dropped held mcp:hello from debugger session %d (current %d)" % [held, _game_session_id]) + return + if _log_buffer: + _log_buffer.log("[debug] replaying held mcp:hello from debugger session %d" % held) + _accept_game_hello(held) + + +func _connect_session_stopped(session_id: int) -> void: + var session = get_session(session_id) + if session == null: + return + var stopped := Callable(self, "_on_debugger_session_stopped").bind(session_id) + if not session.stopped.is_connected(stopped): + session.stopped.connect(stopped) + + +func _on_debugger_session_stopped(session_id: int) -> void: + if _game_session_id != -1 and session_id != _game_session_id: + return + ## MCP-started runs normally end via project_manage(op="stop"), but a game + ## that exits on its own (get_tree().quit(), crash) emits only this signal. + ## Without ending the run here, game_status stays "live" until the next + ## run's bookkeeping rewrites it (#642 live smoke). Before the game session + ## attaches (_game_session_id == -1) only manual runs may end on this + ## signal — a foreign session's stop must not cancel a launching MCP run. + if not _manual_run_armed and _game_session_id == -1: + return + end_game_run() + + +## #859: a closed debugger session is authoritative. Do not leave evals on +## their 10s backstop after the game process has already gone away. Other +## pending request kinds keep their existing ownership and timeout behavior. +func _fail_pending_evals_not_ready(message: String) -> void: + for request_id in _pending.keys(): + var pending: Dictionary = _pending.get(request_id, {}) + if str(pending.get("kind", "")) not in [ + "eval_ready_wait", "eval_liveness", "eval" + ]: + continue + var connection: McpConnection = pending.get("connection") + _clear_pending(str(request_id)) + if connection != null and is_instance_valid(connection): + _send_error(connection, str(request_id), ErrorCodes.EVAL_GAME_NOT_READY, message) + + +func _is_current_game_run(run_token: int) -> bool: + return _game_run_active and _game_run_token == run_token + + +func _fail_eval_not_ready(request_id: String, message: String) -> void: + var pending: Dictionary = _pending.get(request_id, {}) + var connection: McpConnection = pending.get("connection") + _clear_pending(request_id) + if connection != null and is_instance_valid(connection): + _send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY, message) + + +## --- #645: boot-time debugger breaks --------------------------------------- + +func _connect_session_break_signals(session_id: int) -> void: + var session = get_session(session_id) + if session != null: + var breaked_cb := Callable(self, "_on_debugger_session_breaked").bind(session_id) + if not session.breaked.is_connected(breaked_cb): + session.breaked.connect(breaked_cb) + var continued_cb := Callable(self, "_on_debugger_session_continued").bind(session_id) + if not session.continued.is_connected(continued_cb): + session.continued.connect(continued_cb) + _connect_script_debugger_breaked() + + +## The session-level `breaked` signal carries only can_debug; the underlying +## ScriptEditorDebugger's own `breaked` also carries the human-readable break +## reason ("Parser Error: ..."), which is otherwise visible only in the +## Debugger UI. EditorDebuggerSession does not expose its debugger node, so +## locate ScriptEditorDebugger instances by walking the editor UI — the same +## approach as the tracker's Errors-tab scrape. +func _connect_script_debugger_breaked() -> void: + var base := EditorInterface.get_base_control() + if base == null: + return + var debuggers: Array[Node] = [] + _collect_nodes_of_class(base, "ScriptEditorDebugger", debuggers) + for dbg in debuggers: + if not dbg.has_signal("breaked"): + continue + var cb := Callable(self, "_on_script_debugger_breaked") + if not dbg.is_connected("breaked", cb): + dbg.connect("breaked", cb) + + +static func _collect_nodes_of_class(node: Node, klass: String, out: Array[Node]) -> void: + if node.get_class() == klass: + out.append(node) + for child in node.get_children(): + _collect_nodes_of_class(child, klass, out) + + +func _on_debugger_session_breaked(can_debug: bool, session_id: int) -> void: + if _game_session_id != -1 and session_id != _game_session_id: + return + note_debug_break(can_debug, "") + + +func _on_debugger_session_continued(session_id: int) -> void: + if _game_session_id != -1 and session_id != _game_session_id: + return + clear_debug_break() + + +## reallydid=false is Godot's "left the break" notification (debug_exit). +func _on_script_debugger_breaked(reallydid: bool, can_debug: bool, reason: String, _has_stackdump: bool) -> void: + if not reallydid: + clear_debug_break() + return + note_debug_break(can_debug, reason) + + +## Record that the game process is parked in a remote-debugger break. Fires +## once per break from the session signal (no reason text) and again moments +## later from the ScriptEditorDebugger signal (with reason) — the notices +## merge into one break. Public so tests can drive break state directly. +func note_debug_break(can_debug: bool, reason: String) -> void: + var first_notice := not _break_active + _break_active = true + _break_can_debug = can_debug + if not reason.is_empty(): + _break_reason = reason + if not first_notice: + return + _break_run_token = _game_run_token + _break_pre_live = _game_run_active and not is_game_capture_ready() + _break_record_synthesized = false + if _log_buffer: + _log_buffer.log("[debug] debugger break (pre_live=%s can_debug=%s)" % [str(_break_pre_live), str(can_debug)]) + if _break_pre_live: + _schedule_break_record_synthesis() + + +func clear_debug_break() -> void: + _break_active = false + _break_can_debug = false + _break_reason = "" + _break_pre_live = false + _break_run_token = -1 + _break_record_synthesized = false + + +## Stack frames land in the Stack Trace panel a few frames after the break +## signal (the editor requests the stack dump separately), so the record is +## synthesized on short timers rather than at signal time. +func _schedule_break_record_synthesis() -> void: + var tree := Engine.get_main_loop() as SceneTree + if tree == null: + return + var token := _break_run_token + for i in BREAK_FRAME_SCRAPE_DELAYS_SEC.size(): + var final := i == BREAK_FRAME_SCRAPE_DELAYS_SEC.size() - 1 + var timer := tree.create_timer(BREAK_FRAME_SCRAPE_DELAYS_SEC[i]) + var work := ScriptWork.begin("debugger_break_scrape") + timer.timeout.connect(func() -> void: + _on_break_scrape_tick(token, final) + ScriptWork.finish(work)) + + +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": + ## Staleness is checked FIRST, before the buffering branch below: + ## `_begin_game_run_tracking` clears `_game_session_id`, so a beacon + ## validated only at replay time could never be rejected (#891 + ## review). A beacon from a session other than the attached one is + ## some other game's — never hold it, never apply it. + 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 + if not _game_run_active: + ## #891: the run is not adopted YET — a manually started play + ## whose debugger session attached before the editor flipped + ## `is_playing_scene()` lands here. The beacon is a one-shot, + ## so hold it for the imminent adoption instead of dropping it + ## (which left game_eval waiting forever for a hello that had + ## already been thrown away). + _pending_hello_session_id = session_id + ## The beacon is this game's first word, and its boot output + ## follows immediately (the helper flushes on its first + ## `_process`). Rotate the run id NOW so those lines are tagged + ## with the identity adoption will announce, instead of landing + ## under the previous run and vanishing from + ## `logs_read(source="game")` (#891 review). + if _game_log_buffer and not _pre_adoption_log_rotated: + _game_log_buffer.clear_for_new_run() + _pre_adoption_log_rotated = true + if _log_buffer: + _log_buffer.log("[debug] holding mcp:hello from debugger session %d until the run is adopted" % session_id) + return true + _accept_game_hello(session_id) + return true + "mcp:eval_liveness_response": + _on_eval_liveness_response(data) + return true + "mcp:debug_status_response": + _on_debug_status_response(data) + return true + "mcp:eval_response": + _on_eval_response(data) + return true + "mcp:eval_error": + _on_eval_error(data) + return true + "mcp:eval_ack": + _on_eval_ack(data) + return true + "mcp:eval_compiled": + _on_eval_compiled(data) + return true + "mcp:eval_runtime_error": + _on_eval_runtime_error(data) + return true + "mcp:game_command_response": + _on_game_command_response(data) + return true + "mcp:game_command_error": + _on_game_command_error(data) + return true + return false + + +func _on_log_batch(data: Array) -> void: + if _game_log_buffer == null: + return + ## data layout: [[[level, text, details?], ...]] + if data.is_empty() or not (data[0] is Array): + return + var entries: Array = data[0] + for entry in entries: + if entry is Dictionary: + var dict_details: Dictionary = {} + var raw_dict_details = entry.get("details", {}) + if raw_dict_details is Dictionary: + dict_details = raw_dict_details + _game_log_buffer.append(str(entry.get("level", "info")), str(entry.get("text", "")), dict_details) + continue + if not (entry is Array) or entry.size() < 2: + continue + var details: Dictionary = {} + if entry.size() > 2 and entry[2] is Dictionary: + details = entry[2] + _game_log_buffer.append(str(entry[0]), str(entry[1]), details) + + +## Request a game-process framebuffer capture over the debugger channel. +## Reply is pushed back through `connection` out-of-band because the MCP +## dispatcher has already returned a deferred-response marker for this +## request_id. Synchronous from the caller's perspective — if the +## game-side autoload hasn't beaconed yet, the wait + send run as a +## fire-and-forget coroutine kicked off from here. Structured this way +## so the call site in EditorHandler stays a plain non-await invocation. +func request_game_screenshot( + request_id: String, + max_resolution: int, + connection: McpConnection, + timeout_sec: float = DEFAULT_TIMEOUT_SEC, +) -> void: + if request_id.is_empty(): + push_warning("MCP debugger: screenshot request missing request_id") + return + + var tree := Engine.get_main_loop() as SceneTree + if tree == null: + _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, + "Editor main loop is not a SceneTree — cannot schedule capture") + return + + if is_game_capture_ready(): + _send_take_screenshot(tree, request_id, max_resolution, connection, timeout_sec) + return + + ## Not ready yet — run the wait-then-send flow as a detached + ## coroutine. It keeps itself alive via the signal subscription on + ## tree.process_frame; the caller doesn't need to (and shouldn't) + ## await this entrypoint. + if _log_buffer: + _log_buffer.log("[debug] waiting for game_helper hello (%s)" % request_id) + _wait_then_send(tree, request_id, max_resolution, connection, timeout_sec) + + +## Coroutine: poll each editor frame until the mcp:hello beacon arrives +## (flipping _game_ready true) or the deadline elapses. Once resolved, +## either dispatch the capture or return an actionable timeout error. +func _wait_then_send( + tree: SceneTree, + request_id: String, + max_resolution: int, + connection: McpConnection, + timeout_sec: float, +) -> void: + var work := ScriptWork.begin("game_screenshot_ready") + await _settle_capture_ready(tree, request_id, max_resolution, connection, timeout_sec) + ScriptWork.finish(work) + + +func _settle_capture_ready( + tree: SceneTree, request_id: String, max_resolution: int, + connection: McpConnection, timeout_sec: float, +) -> void: + var deadline := Time.get_ticks_msec() + int(GAME_READY_WAIT_SEC * 1000.0) + ## #645: always yield at least one frame — the dispatcher registers the + ## deferred request only after the handler returns DEFERRED_RESPONSE, so a + ## same-frame error reply would be dropped as an expired request. The break + ## check then bails out with the actionable break error instead of waiting + ## out the full window (a game parked in a debugger break never beacons). + await tree.process_frame + while not is_game_capture_ready() and not _break_active and Time.get_ticks_msec() < deadline: + await tree.process_frame + if not is_game_capture_ready(): + _send_error_response(connection, request_id, + _explain_not_live(get_game_status(-1, GAME_READY_WAIT_SEC), ErrorCodes.INTERNAL_ERROR)) + return + _send_take_screenshot(tree, request_id, max_resolution, connection, timeout_sec) + + +## Send the mcp:take_screenshot message and arm the reply timeout. +## Assumes _game_ready is true. +func _send_take_screenshot( + tree: SceneTree, + request_id: String, + max_resolution: int, + connection: McpConnection, + timeout_sec: float, +) -> void: + var session: EditorDebuggerSession = _first_active_session() + if session == null: + _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, + "No active debugger session — is the game actually running and started from this editor?") + return + + var timer: SceneTreeTimer = tree.create_timer(timeout_sec) + var timeout_callable := func() -> void: _on_timeout(request_id) + timer.timeout.connect(timeout_callable) + _pending[request_id] = { + "connection": connection, + "timer": timer, + "timeout_callable": timeout_callable, + } + + session.send_message("mcp:take_screenshot", [request_id, max_resolution]) + if _log_buffer: + _log_buffer.log("[debug] -> mcp:take_screenshot (%s)" % request_id) + + +func _first_active_session() -> EditorDebuggerSession: + for s in get_sessions(): + if s is EditorDebuggerSession and s.is_active(): + return s + return null + + +func _on_screenshot_response(data: Array) -> void: + if data.size() < 6: + push_warning("MCP debugger: malformed screenshot response (expected 6 fields, got %d)" % data.size()) + return + var request_id: String = data[0] + var pending = _pending.get(request_id) + if pending == null: + ## Timed out or unknown — silently drop. + return + _clear_pending(request_id) + + var connection: McpConnection = pending.connection + if connection == null or not is_instance_valid(connection): + return + + var payload := { + "source": "game", + "width": int(data[2]), + "height": int(data[3]), + "original_width": int(data[4]), + "original_height": int(data[5]), + "format": "png", + "image_base64": data[1], + } + ## #777: game helpers append frames_drawn + a stale flag so a capture + ## taken while the game's main loop is frozen (backgrounded window) is + ## honestly labeled instead of timing out. Older helpers send six fields + ## — leave the keys absent rather than guessing. + if data.size() >= 8: + payload["frames_drawn"] = int(data[6]) + payload["stale_frame"] = bool(data[7]) + if bool(data[7]): + payload["note"] = ("The game window appears backgrounded or its main loop is " + + "stalled; returning the last rendered frame. Focus the game window and " + + "retry for a current frame.") + ## Vision Routing: when enabled, describe the frame through the configured + ## vision provider on a + ## worker thread and reply with the text description instead of the raw + ## image (see vision_routing.gd). The router replies for us in that case. + if vision_routing != null and vision_routing.is_routing_enabled(): + if vision_routing.route_game_payload(connection, request_id, {"data": payload}): + return + connection.send_deferred_response(request_id, {"data": payload}) + if _log_buffer: + _log_buffer.log("[debug] <- mcp:screenshot_response (%s)" % request_id) + + +func _on_screenshot_error(data: Array) -> void: + if data.size() < 2: + return + var request_id: String = data[0] + var message: String = data[1] + var pending = _pending.get(request_id) + if pending == null: + return + _clear_pending(request_id) + var connection: McpConnection = pending.connection + if connection == null or not is_instance_valid(connection): + return + _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, message) + + +## #777: the 8s reply timer fired — the screenshot request reached (or should +## have reached) the game helper and no reply came back. Mirror the eval +## timeout split (#518): a not-live game gets the attributed +## _explain_not_live payload; a live game gets GAME_HELPER_TIMEOUT instead of +## the former opaque INTERNAL_ERROR. With the game side's stalled-loop +## stale-frame fallback, a live game only lands here when it has nothing +## rendered to fall back on, its debugger servicing is itself wedged, or the +## helper died mid-run. +func _on_timeout(request_id: String) -> void: + var pending = _pending.get(request_id) + if pending == null: + return + _pending.erase(request_id) + var connection: McpConnection = pending.connection + if connection == null or not is_instance_valid(connection): + return + var status := get_game_status(-1, GAME_READY_WAIT_SEC) + var err: Dictionary + if status.get("status", "") != "live": + err = _explain_not_live(status, ErrorCodes.INTERNAL_ERROR) + else: + err = ErrorCodes.make(ErrorCodes.GAME_HELPER_TIMEOUT, + "The game process did not return a frame. The game window may be backgrounded or its main loop blocked — focus the game window and retry, or use game_command to confirm liveness.") + _send_error_response(connection, request_id, err) + if _log_buffer: + _log_buffer.log("[debug] !! screenshot timeout (%s)" % request_id) + + +func _send_error(connection: McpConnection, request_id: String, code: String, message: String) -> void: + _send_error_response(connection, request_id, ErrorCodes.make(code, message)) + + +func _send_error_response(connection: McpConnection, request_id: String, err: Dictionary) -> void: + if connection == null or not is_instance_valid(connection): + return + connection.send_deferred_response(request_id, err) + + +func _clear_pending(request_id: String) -> void: + var pending: Dictionary = _pending.get(request_id, {}) + var timer: SceneTreeTimer = pending.get("timer") + var cb: Callable = pending.get("timeout_callable", Callable()) + if timer != null and timer.timeout.is_connected(cb): + timer.timeout.disconnect(cb) + ## #490: eval requests also carry a compile-grace timer and a runtime probe. + var grace: SceneTreeTimer = pending.get("grace_timer") + var gcb: Callable = pending.get("grace_callable", Callable()) + if grace != null and grace.timeout.is_connected(gcb): + grace.timeout.disconnect(gcb) + var probe: SceneTreeTimer = pending.get("probe_timer") + var pcb: Callable = pending.get("probe_callable", Callable()) + if probe != null and probe.timeout.is_connected(pcb): + probe.timeout.disconnect(pcb) + _pending.erase(request_id) + + +## --- game_eval: execute arbitrary GDScript in the running game --- + +## Editor-side fallback timer for game_eval. MUST stay above the game-side +## EVAL_TIMEOUT_SEC (8.0) in runtime/game_helper.gd and below the dispatcher's +## game_eval budget (15000 ms) in dispatcher.gd — i.e. game 8s < editor 10s < +## dispatcher 15s. This timer only fires when the game never replies at all; +## _on_eval_timeout then attributes the failure (game not live vs never-acked +## vs started-and-hung, #518). Drop timeout_sec at/below 8s and it pre-empts +## the game's more specific "Eval exceeded 8s" message — see the TIMEOUT +## ORDERING note on EVAL_TIMEOUT_SEC. +## +## #500/#859: the *not-ready* path adds EVAL_READY_WAIT_SEC (3s) and the +## EVAL_LIVENESS_WAIT_SEC probe (0.25s) on top of this 10s backstop. That sum +## (13.25s) must also stay below the dispatcher/server 15s budget, or a +## not-yet-ready game makes the server time out opaquely before the editor's +## actionable error returns — exactly the residual ~15s TimeoutError bucket +## #500 tracked down. The cross-file contract is enforced by +## tests/unit/test_game_eval_timeout_ordering.py. +func request_game_eval( + code: String, + request_id: String, + connection: McpConnection, + timeout_sec: float = 10.0, +) -> void: + if request_id.is_empty(): + push_warning("MCP debugger: eval request missing request_id") + return + + var tree := Engine.get_main_loop() as SceneTree + if tree == null: + _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, + "Editor main loop is not a SceneTree — cannot schedule eval") + return + + var run_token := _game_run_token + if is_game_capture_ready(): + _probe_then_eval(tree, code, request_id, connection, timeout_sec, run_token) + return + + if _log_buffer: + _log_buffer.log("[debug] waiting for game_helper hello before eval (%s)" % request_id) + _wait_then_eval(tree, code, request_id, connection, timeout_sec, run_token) + + +func _wait_then_eval( + tree: SceneTree, + code: String, + request_id: String, + connection: McpConnection, + timeout_sec: float, + run_token: int, +) -> void: + var work := ScriptWork.begin("game_eval_ready") + await _settle_eval_ready(tree, code, request_id, connection, timeout_sec, run_token) + ScriptWork.finish(work) + + +func _settle_eval_ready( + tree: SceneTree, code: String, request_id: String, + connection: McpConnection, timeout_sec: float, run_token: int, +) -> void: + ## #500: eval uses EVAL_READY_WAIT_SEC (not the 20s GAME_READY_WAIT_SEC) so + ## the not-ready path returns its actionable error before the 15s server-side + ## command timeout fires an opaque TimeoutError. See EVAL_READY_WAIT_SEC. + _pending[request_id] = { + "kind": "eval_ready_wait", + "connection": connection, + "run_token": run_token, + } + var deadline := Time.get_ticks_msec() + int(EVAL_READY_WAIT_SEC * 1000.0) + ## #645: the leading yield guarantees the dispatcher has registered the + ## deferred request before any reply (a same-frame reply is dropped as + ## expired); the break check bails out early because a parked game never + ## registers its capture. + if _eval_ready_frame_waiter.is_valid(): + await _eval_ready_frame_waiter.call() + else: + await tree.process_frame + while ( + _pending.has(request_id) + and _is_current_game_run(run_token) + and not is_game_capture_ready() + and not _break_active + and Time.get_ticks_msec() < deadline + ): + if _eval_ready_frame_waiter.is_valid(): + await _eval_ready_frame_waiter.call() + else: + await tree.process_frame + ## end_game_run() owns the reply once it has cleared this tracked wait. + if not _pending.has(request_id): + return + if not _is_current_game_run(run_token): + _fail_eval_not_ready(request_id, + "The game run changed before game_eval could start — the game stopped or restarted. Retry against the current run.") + return + if not is_game_capture_ready(): + ## #518: EVAL_GAME_NOT_READY (not INTERNAL_ERROR) — the play session is up + ## but the game-side capture didn't register within the short wait. Fast + ## and caller-actionable; classifying it apart from the opaque 10s hang + ## keeps the INTERNAL_ERROR telemetry bucket meaning "the eval truly hung". + _clear_pending(request_id) + _send_error_response(connection, request_id, + _explain_not_live(get_game_status(-1, EVAL_READY_WAIT_SEC), ErrorCodes.EVAL_GAME_NOT_READY)) + return + _clear_pending(request_id) + _probe_then_eval(tree, code, request_id, connection, timeout_sec, run_token) + + +## #859: registration is run-scoped but not a liveness guarantee. Ping the +## helper's debugger capture and let it report whether its _process beacon is +## still advancing before arming the much longer eval backstop. +func _probe_then_eval( + tree: SceneTree, + code: String, + request_id: String, + connection: McpConnection, + timeout_sec: float, + run_token: int, +) -> void: + if not _is_current_game_run(run_token) or not is_game_capture_ready(): + _send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY, + "The game run changed before game_eval could be checked — the game stopped or restarted. Retry against the current run.") + return + var session: EditorDebuggerSession = _first_active_session() + if session == null: + _send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY, + "Game-side capture registered but its debugger session is no longer active — the game likely just stopped or is restarting. Confirm it is running and retry.") + return + + var timer := tree.create_timer(EVAL_LIVENESS_WAIT_SEC) + var timeout_callable := func() -> void: _on_eval_liveness_timeout(request_id) + timer.timeout.connect(timeout_callable) + _pending[request_id] = { + "kind": "eval_liveness", + "connection": connection, + "timer": timer, + "timeout_callable": timeout_callable, + "code": code, + "eval_timeout_sec": timeout_sec, + "run_token": run_token, + } + session.send_message("mcp:eval_liveness", [request_id]) + if _log_buffer: + _log_buffer.log("[debug] -> mcp:eval_liveness (%s)" % request_id) + + +func _on_eval_liveness_timeout(request_id: String) -> void: + var pending_entry = _pending.get(request_id) + if pending_entry == null or str(pending_entry.get("kind", "")) != "eval_liveness": + return + var connection: McpConnection = pending_entry.get("connection") + _clear_pending(request_id) + if connection == null or not is_instance_valid(connection): + return + _send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY, + ("The game helper did not answer the liveness probe within %.0fms — the game may be backgrounded, frozen, stopped, or restarting. Focus the game window (or relaunch it) and retry." + % (EVAL_LIVENESS_WAIT_SEC * 1000.0))) + + +func _on_eval_liveness_response(data: Array) -> void: + if data.size() < 2: + push_warning("MCP debugger: malformed eval liveness response") + return + var request_id := str(data[0]) + var pending_entry = _pending.get(request_id) + if pending_entry == null or str(pending_entry.get("kind", "")) != "eval_liveness": + return + var connection: McpConnection = pending_entry.get("connection") + var code := str(pending_entry.get("code", "")) + var timeout_sec := float(pending_entry.get("eval_timeout_sec", 10.0)) + var run_token := int(pending_entry.get("run_token", -1)) + var loop_live := bool(data[1]) + _clear_pending(request_id) + if connection == null or not is_instance_valid(connection): + return + if not _is_current_game_run(run_token) or not is_game_capture_ready(): + _send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY, + "The game run changed while game_eval liveness was being checked — the game stopped or restarted. Retry against the current run.") + return + if not loop_live: + _send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY, + "The game helper is registered but its main loop is not advancing — the game window may be backgrounded or the game may be frozen. Focus the game window (or relaunch it) and retry.") + return + var current_tree := Engine.get_main_loop() as SceneTree + if current_tree == null: + _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, + "Editor main loop is not a SceneTree — cannot schedule eval") + return + _send_eval(current_tree, code, request_id, connection, timeout_sec) + + +func _send_eval( + tree: SceneTree, + code: String, + request_id: String, + connection: McpConnection, + timeout_sec: float, +) -> void: + var session: EditorDebuggerSession = _first_active_session() + if session == null: + ## #518: capture reported ready but the debugger session is no longer live + ## (the game just stopped / is restarting) — a not-ready race, so the same + ## caller-actionable EVAL_GAME_NOT_READY rather than the opaque hang bucket. + _send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY, + "Game-side capture registered but its debugger session is no longer active — the game likely just stopped or is restarting. Confirm it's running and retry.") + return + + var timer: SceneTreeTimer = tree.create_timer(timeout_sec) + var timeout_callable := func() -> void: _on_eval_timeout(request_id, timeout_sec) + timer.timeout.connect(timeout_callable) + + ## #490: arm the compile-grace timer. _on_eval_grace concludes a parse error + ## only when the game acked the eval (it received the message and started + ## reload()) but never sent mcp:eval_compiled — see there for why a missing + ## ack must NOT be read as a compile error. + var grace: SceneTreeTimer = tree.create_timer(EVAL_COMPILE_GRACE_SEC) + var grace_callable := func() -> void: _on_eval_grace(request_id) + grace.timeout.connect(grace_callable) + + _pending[request_id] = { + "kind": "eval", + "connection": connection, + "run_token": _game_run_token, + "timer": timer, + "timeout_callable": timeout_callable, + "grace_timer": grace, + "grace_callable": grace_callable, + "acked": false, + "compiled": false, + } + + session.send_message("mcp:eval", [request_id, code]) + if _log_buffer: + _log_buffer.log("[debug] -> mcp:eval (%s)" % request_id) + + +## #518: the 10s editor-side backstop fired — the game never replied at all. +## Attribute the failure instead of emitting a one-size-fits-all INTERNAL_ERROR: +## +## - game not live anymore (parked in a debugger break, stopped, crashed): +## the eval couldn't run/finish for a *game-state* reason. Reply with the +## same caller-actionable EVAL_GAME_NOT_READY + `_explain_not_live` payload +## the pre-hello break path already uses — a break freezes the game's idle +## loop, so any awaiting eval parks here even though sync evals still work. +## - game live but never acked the eval: its main thread never serviced the +## debugger message (long frame/load, CPU-bound prior eval, or a +## backgrounded window whose idle loop is frozen). +## - game live, acked, compiled: the eval genuinely started and never +## finished, and the game couldn't even self-report via its own 8s guard +## (which needs a ticking idle loop) — hung await, CPU-bound loop, or a +## reply the debugger channel dropped. +## +## The live branches reply EVAL_HUNG: the eval code never finished. That code +## plus the game-side 8s guard (also EVAL_HUNG, via mcp:eval_error's code +## element) empties the former INTERNAL_ERROR bucket on this path. +func _on_eval_timeout(request_id: String, timeout_sec: float) -> void: + var pending_entry = _pending.get(request_id) + if pending_entry == null: + return + var run_token := int(pending_entry.get("run_token", _game_run_token)) + _clear_pending(request_id) + var conn: McpConnection = pending_entry.connection + if conn == null or not is_instance_valid(conn): + return + if run_token != _game_run_token: + _send_error(conn, request_id, ErrorCodes.EVAL_GAME_NOT_READY, + "The game run changed before game_eval completed — the original game stopped or restarted. Retry against the current run.") + if _log_buffer: + _log_buffer.log("[debug] !! eval timeout from prior run (%s, run=%d, current=%d)" + % [request_id, run_token, _game_run_token]) + return + var status := get_game_status(-1, EVAL_READY_WAIT_SEC) + if str(status.get("status", "")) != "live": + _send_error_response(conn, request_id, + _explain_not_live(status, ErrorCodes.EVAL_GAME_NOT_READY)) + if _log_buffer: + _log_buffer.log("[debug] !! eval timeout, game not live (%s, status=%s)" + % [request_id, str(status.get("status", ""))]) + return + var message: String + if not bool(pending_entry.get("acked", false)): + message = ("Game eval was sent but the game never picked it up within %.0fs — " + + "its main thread is busy or frozen (a long frame/load, a CPU-bound " + + "prior eval, or a backgrounded game window whose loop is throttled). " + + "Check logs_read(source='game') and retry.") % timeout_sec + else: + message = ("Game eval compiled and started running but never returned within " + + "%.0fs — the code is likely stuck in an infinite loop or awaiting a " + + "signal/timer that never fires (a backgrounded game window also freezes " + + "awaits). Check logs_read(source='game').") % timeout_sec + _send_error(conn, request_id, ErrorCodes.EVAL_HUNG, message) + if _log_buffer: + _log_buffer.log("[debug] !! eval timeout (%s)" % request_id) + + +func _on_eval_response(data: Array) -> void: + if data.size() < 2: + push_warning("MCP debugger: malformed eval response (expected 2 fields, got %d)" % data.size()) + return + var request_id: String = data[0] + var pending_entry = _pending.get(request_id) + if pending_entry == null: + return + _clear_pending(request_id) + + var connection: McpConnection = pending_entry.connection + if connection == null or not is_instance_valid(connection): + return + + var result_json: String = data[1] if data.size() > 1 else "null" + var json := JSON.new() + var parse_err := json.parse(result_json) + connection.send_deferred_response(request_id, { + "data": { + "result": json.data if parse_err == OK else result_json, + "source": "game", + } + }) + if _log_buffer: + _log_buffer.log("[debug] <- mcp:eval_response (%s)" % request_id) + + +## #518: codes the game side may attach as mcp:eval_error's optional third +## payload element. Allowlisted so a game process can't mint arbitrary +## top-level error codes over the debugger channel; anything else (including +## the legacy two-element payload from an older game helper mid-update) +## falls back to INTERNAL_ERROR exactly as before. +const _GAME_EVAL_ERROR_CODES: Array[String] = [ + ErrorCodes.EVAL_HUNG, + ErrorCodes.EVAL_RESULT_TOO_LARGE, +] + + +func _on_eval_error(data: Array) -> void: + if data.size() < 2: + return + var request_id: String = data[0] + var message: String = data[1] + var pending_entry = _pending.get(request_id) + if pending_entry == null: + return + _clear_pending(request_id) + var connection: McpConnection = pending_entry.connection + if connection == null or not is_instance_valid(connection): + return + var code := ErrorCodes.INTERNAL_ERROR + if data.size() > 2 and str(data[2]) in _GAME_EVAL_ERROR_CODES: + code = str(data[2]) + _send_error(connection, request_id, code, message) + if _log_buffer: + _log_buffer.log("[debug] <- mcp:eval_error (%s): %s" % [request_id, message]) + + +## #490: the game sends this at the top of _handle_eval, BEFORE reload() (so it +## survives a parse-error abort). It positively signals "the game received this +## eval and started compiling it" — letting _on_eval_grace tell a real parse +## error (acked, never compiled) apart from a message the game hasn't serviced +## yet (never acked — main thread blocked by a long frame/load or a CPU-bound +## prior eval). +func _on_eval_ack(data: Array) -> void: + if data.is_empty(): + return + var request_id: String = data[0] + var pending_entry = _pending.get(request_id) + if pending_entry == null: + return + pending_entry["acked"] = true + if _log_buffer: + _log_buffer.log("[debug] <- mcp:eval_ack (%s)" % request_id) + + +## #490: compile-grace timer fired. Conclude a parse error ONLY when the game +## acked the eval (started reload()) but never sent mcp:eval_compiled. If it +## never acked, the game simply hasn't serviced the message yet — NOT a parse +## error — so leave _pending intact and let the normal eval timeout handle it +## rather than false-failing a valid eval and dropping its eventual real reply. +func _on_eval_grace(request_id: String) -> void: + var pending_entry = _pending.get(request_id) + if pending_entry == null or pending_entry.get("compiled", false): + return + if not pending_entry.get("acked", false): + if _log_buffer: + _log_buffer.log("[debug] eval grace: no ack yet, deferring to timeout (%s)" % request_id) + return + _clear_pending(request_id) + var conn: McpConnection = pending_entry.connection + if conn == null or not is_instance_valid(conn): + return + _send_error(conn, request_id, ErrorCodes.EVAL_COMPILE_ERROR, + "Game eval failed to compile — likely a GDScript syntax/parse error. The parse error text is in the editor's Output/Debugger panel; it is not capturable from the running game. Check your eval code's syntax.") + if _log_buffer: + _log_buffer.log("[debug] !! eval compile error (%s)" % request_id) + + +## #490: the game sends this the instant reload() of the eval source +## succeeds. Flips the pending entry's `compiled` flag so the compile-grace +## timer won't fire a false EVAL_COMPILE_ERROR. +func _on_eval_compiled(data: Array) -> void: + if data.is_empty(): + return + var request_id: String = data[0] + var pending_entry = _pending.get(request_id) + if pending_entry == null: + return + pending_entry["compiled"] = true + if _log_buffer: + _log_buffer.log("[debug] <- mcp:eval_compiled (%s)" % request_id) + ## #490: compiled OK — start polling for a runtime error that may have + ## aborted execute(). A backgrounded game can't self-report it, so the + ## editor probes via mcp:eval_check until the eval resolves. + _arm_eval_probe(request_id) + + +## #490: the game reported a runtime error that aborted the eval — either +## from its _process fast path (focused game) or in answer to an editor +## eval_check probe (backgrounded game). Reply fast with the real error text +## instead of waiting for the hang timeout. +func _on_eval_runtime_error(data: Array) -> void: + if data.size() < 2: + return + var request_id: String = data[0] + var message: String = data[1] + var pending_entry = _pending.get(request_id) + if pending_entry == null: + return + _clear_pending(request_id) + var connection: McpConnection = pending_entry.connection + if connection == null or not is_instance_valid(connection): + return + var msg := "Game eval raised a runtime error: %s" % message if not message.is_empty() else "Game eval raised a runtime error (no message captured). Check logs_read(source='game')." + _send_error(connection, request_id, ErrorCodes.EVAL_RUNTIME_ERROR, msg) + if _log_buffer: + _log_buffer.log("[debug] <- mcp:eval_runtime_error (%s): %s" % [request_id, message]) + + +## #490: arm one probe tick for an in-flight eval. Re-arms itself each tick +## until the request resolves — eval_response / eval_runtime_error / +## eval_compile_error / hang-timeout all call _clear_pending, which erases the +## entry and stops the chain. Uses the editor's own SceneTreeTimer because the +## editor loop keeps ticking even while a backgrounded game's loop is frozen. +func _arm_eval_probe(request_id: String) -> void: + var pending_entry = _pending.get(request_id) + if pending_entry == null: + return + var tree := Engine.get_main_loop() as SceneTree + if tree == null: + return + var probe_timer: SceneTreeTimer = tree.create_timer(EVAL_PROBE_INTERVAL_SEC) + var probe_callable := func() -> void: _on_eval_probe_tick(request_id) + pending_entry["probe_timer"] = probe_timer + pending_entry["probe_callable"] = probe_callable + probe_timer.timeout.connect(probe_callable) + + +## #490: poke the game for a runtime-error verdict, then re-arm. The game's +## _handle_eval_check answers with mcp:eval_runtime_error if a script error +## aborted this eval, else stays silent and we poll again next interval. +func _on_eval_probe_tick(request_id: String) -> void: + if not _pending.has(request_id): + return ## resolved — stop probing + var session: EditorDebuggerSession = _first_active_session() + if session != null and session.is_active(): + session.send_message("mcp:eval_check", [request_id]) + _arm_eval_probe(request_id) + + +## --- native embedded Game View runtime control (#939) --- + +static func game_debug_verification( + action: String, state_before: Dictionary, state_after: Dictionary +) -> Dictionary: + var before_ticks := int(state_before.get("process_ticks", -1)) + var after_ticks := int(state_after.get("process_ticks", -1)) + var ticks_advanced := after_ticks - before_ticks if before_ticks >= 0 and after_ticks >= 0 else -1 + match action: + "suspend": + return {"status": "verified" if bool(state_after.get("suspended", false)) else "pending", "ticks_advanced": ticks_advanced} + "resume": + return {"status": "verified" if not bool(state_after.get("suspended", true)) else "pending", "ticks_advanced": ticks_advanced} + "next_frame": + if before_ticks < 0 or after_ticks < 0: + return {"status": "failed", "reason": "missing_process_ticks", "ticks_advanced": ticks_advanced} + if ticks_advanced > 1: + return {"status": "failed", "reason": "multiple_process_ticks", "ticks_advanced": ticks_advanced} + if ticks_advanced == 1 and bool(state_after.get("suspended", false)): + return {"status": "verified", "ticks_advanced": 1} + return {"status": "pending", "ticks_advanced": ticks_advanced} + return {"status": "failed", "reason": "unsupported_action", "ticks_advanced": ticks_advanced} + + +func _active_game_debug_mutation() -> Dictionary: + for raw_request_id in _pending.keys(): + var pending_entry: Dictionary = _pending.get(raw_request_id, {}) + if str(pending_entry.get("kind", "")) != "game_debug_control": + continue + var active_action := str(pending_entry.get("action", "")) + if active_action in ["suspend", "resume", "next_frame"]: + return {"request_id": str(raw_request_id), "action": active_action} + return {} + + +func request_game_debug_control( + action: String, + request_id: String, + connection: McpConnection, + timeout_sec: float = GAME_DEBUG_CONTROL_TIMEOUT_SEC, +) -> void: + if request_id.is_empty(): + push_warning("MCP debugger: game debug control missing request_id") + return + ## The dispatcher registers the deferred request only after the handler returns. + ## Start the entire guard/control flow deferred so even fast failures cannot race + ## that registration and get dropped as an expired response. + _begin_game_debug_control.call_deferred(action, request_id, connection, timeout_sec) + + +func _begin_game_debug_control( + action: String, + request_id: String, + connection: McpConnection, + timeout_sec: float, +) -> void: + if action not in ["suspend", "resume", "next_frame", "debug_status"]: + _send_error(connection, request_id, ErrorCodes.VALUE_OUT_OF_RANGE, + "Unsupported game debug action: %s" % action) + 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 debug control") + return + 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 + if action != "debug_status": + var active_mutation := _active_game_debug_mutation() + if not active_mutation.is_empty(): + var busy := ErrorCodes.make( + ErrorCodes.EDITOR_NOT_READY, + "Another game runtime-control mutation is already in flight — retry after it completes." + ) + busy["error"]["data"] = { + "action": action, + "active_action": str(active_mutation.get("action", "")), + "retryable": true, + } + _send_error_response(connection, request_id, busy) + return + var session := _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 := tree.create_timer(timeout_sec) + var timeout_callable := func() -> void: + var pending_entry: Dictionary = _pending.get(request_id, {}) + if pending_entry.is_empty(): + return + _pending.erase(request_id) + var conn: McpConnection = pending_entry.get("connection") + if conn != null and is_instance_valid(conn): + var err := ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, + "Game debug action '%s' timed out after %.0fs" % [action, timeout_sec]) + err["error"]["data"] = { + "action": action, + "state_before": pending_entry.get("state_before", {}), + "state_after": pending_entry.get("state_after", {}), + "focus_handoff": pending_entry.get("focus_handoff", {}), + "path": pending_entry.get("path", ""), + "game_view_ui_synced": pending_entry.get("game_view_ui_synced", false), + } + _send_error_response(conn, request_id, err) + timer.timeout.connect(timeout_callable) + _pending[request_id] = { + "kind": "game_debug_control", + "phase": "before", + "action": action, + "connection": connection, + "timer": timer, + "timeout_callable": timeout_callable, + "run_token": _game_run_token, + } + session.send_message("mcp:debug_status", [request_id]) + + +func _on_debug_status_response(data: Array) -> void: + if data.size() < 2 or not (data[1] is Dictionary): + push_warning("MCP debugger: malformed debug_status response") + return + var request_id := str(data[0]) + var pending_entry: Dictionary = _pending.get(request_id, {}) + if str(pending_entry.get("kind", "")) != "game_debug_control": + return + var connection: McpConnection = pending_entry.get("connection") + if not _is_current_game_run(int(pending_entry.get("run_token", -1))): + _clear_pending(request_id) + if connection != null and is_instance_valid(connection): + _send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY, + "The game run changed while runtime control was in flight — retry against the current run.") + return + var state: Dictionary = (data[1] as Dictionary).duplicate(true) + pending_entry["state_after"] = state + match str(pending_entry.get("phase", "before")): + "before": + _handle_game_debug_before(request_id, pending_entry, state) + "verify": + _handle_game_debug_verify(request_id, pending_entry, state) + _: + _clear_pending(request_id) + if connection != null and is_instance_valid(connection): + _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, + "Invalid game debug control phase") + + +func _default_focus_handoff() -> Dictionary: + return {"attempted": false, "main_screen": "", "embedded_process_found": false, "focused": false} + + +func _handle_game_debug_before( + request_id: String, pending_entry: Dictionary, state: Dictionary +) -> void: + var action := str(pending_entry.get("action", "")) + var connection: McpConnection = pending_entry.get("connection") + var focus_handoff := _default_focus_handoff() + if action == "debug_status": + _clear_pending(request_id) + if connection != null and is_instance_valid(connection): + connection.send_deferred_response(request_id, {"data": { + "action": action, "state": state, "focus_handoff": focus_handoff, + }}) + return + var suspended := bool(state.get("suspended", false)) + if action in ["suspend", "resume"]: + var desired := action == "suspend" + if suspended == desired: + _clear_pending(request_id) + if connection != null and is_instance_valid(connection): + connection.send_deferred_response(request_id, {"data": { + "action": action, "changed": false, "verified": true, + "state_before": state, "state_after": state, + "focus_handoff": focus_handoff, + }}) + return + elif action == "next_frame" and not suspended: + _clear_pending(request_id) + if connection != null and is_instance_valid(connection): + _send_error(connection, request_id, ErrorCodes.INVALID_PARAMS, + "next_frame requires a suspended game — call suspend first") + return + if action == "next_frame": + focus_handoff = _focus_embedded_game_view() + var desired_suspended := action == "suspend" + var control := _emit_game_debug_runtime_action(action, desired_suspended) + if not bool(control.get("ok", false)): + var err := ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, + "No active debugger path accepted the native game runtime action") + err["error"]["data"] = { + "action": action, + "reason": control.get("reason", "runtime_control_unavailable"), + "focus_handoff": focus_handoff, + } + _clear_pending(request_id) + if connection != null and is_instance_valid(connection): + _send_error_response(connection, request_id, err) + return + pending_entry["phase"] = "verify" + pending_entry["state_before"] = state + pending_entry["focus_handoff"] = focus_handoff + pending_entry["path"] = str(control.get("path", "")) + pending_entry["game_view_ui_synced"] = bool(control.get("game_view_ui_synced", false)) + _request_game_debug_verify.call_deferred(request_id) + + +func _request_game_debug_verify(request_id: String) -> void: + var tree := Engine.get_main_loop() as SceneTree + if tree == null: + return + await tree.process_frame + var pending_entry: Dictionary = _pending.get(request_id, {}) + if str(pending_entry.get("phase", "")) != "verify": + return + var session := _first_active_session() + if session == null: + var connection: McpConnection = pending_entry.get("connection") + _clear_pending(request_id) + if connection != null and is_instance_valid(connection): + _send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY, + "The debugger session ended before runtime control could be verified") + return + session.send_message("mcp:debug_status", [request_id]) + + +func _handle_game_debug_verify( + request_id: String, pending_entry: Dictionary, state_after: Dictionary +) -> void: + var action := str(pending_entry.get("action", "")) + var connection: McpConnection = pending_entry.get("connection") + var state_before: Dictionary = pending_entry.get("state_before", {}) + var verdict := game_debug_verification(action, state_before, state_after) + match str(verdict.get("status", "failed")): + "pending": + _request_game_debug_verify.call_deferred(request_id) + return + "verified": + pass + _: + var err := ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, + "Game debug action '%s' violated its verification contract" % action) + err["error"]["data"] = { + "action": action, + "reason": verdict.get("reason", "verification_failed"), + "ticks_advanced": verdict.get("ticks_advanced", -1), + "state_before": state_before, + "state_after": state_after, + "focus_handoff": pending_entry.get("focus_handoff", {}), + "path": pending_entry.get("path", ""), + "game_view_ui_synced": pending_entry.get("game_view_ui_synced", false), + } + _clear_pending(request_id) + if connection != null and is_instance_valid(connection): + _send_error_response(connection, request_id, err) + return + var payload := { + "action": action, + "changed": true, + "verified": true, + "state_before": state_before, + "state_after": state_after, + "focus_handoff": pending_entry.get("focus_handoff", {}), + "path": pending_entry.get("path", ""), + "game_view_ui_synced": pending_entry.get("game_view_ui_synced", false), + } + if action == "next_frame": + payload["ticks_advanced"] = int(verdict.get("ticks_advanced", -1)) + _clear_pending(request_id) + if connection != null and is_instance_valid(connection): + connection.send_deferred_response(request_id, {"data": payload}) + + +func _focus_embedded_game_view() -> Dictionary: + var result := { + "attempted": true, + "main_screen": "Game", + "embedded_process_found": false, + "focused": false, + } + EditorInterface.set_main_screen_editor("Game") + var base := EditorInterface.get_base_control() + if base == null: + return result + var embedded_nodes: Array[Node] = [] + _collect_nodes_of_class(base, "EmbeddedProcess", embedded_nodes) + for node in embedded_nodes: + if node is Control: + var embedded := node as Control + result["embedded_process_found"] = true + embedded.grab_focus() + result["focused"] = embedded.has_focus() + break + return result + + +static func direct_game_debug_message(action: String, desired_suspended: bool) -> Dictionary: + match action: + "suspend", "resume": + return {"message": "scene:suspend_changed", "data": [desired_suspended]} + "next_frame": + return {"message": "scene:next_frame", "data": []} + return {} + + +func _emit_game_debug_runtime_action(action: String, desired_suspended: bool) -> Dictionary: + var embed_action := EMBED_NEXT_FRAME if action == "next_frame" else EMBED_SUSPEND_TOGGLE + var base := EditorInterface.get_base_control() + if base != null: + var debuggers: Array[Node] = [] + _collect_nodes_of_class(base, "ScriptEditorDebugger", debuggers) + for debugger in debuggers: + if not debugger.has_signal("embed_shortcut_requested"): + continue + if debugger.get_signal_connection_list("embed_shortcut_requested").is_empty(): + continue + debugger.emit_signal("embed_shortcut_requested", embed_action) + return {"ok": true, "path": "embed_signal", "game_view_ui_synced": true} + + var session := _first_active_session() + if session == null or not session.is_active(): + return {"ok": false, "reason": "no_active_debugger_session"} + var direct := direct_game_debug_message(action, desired_suspended) + if direct.is_empty(): + return {"ok": false, "reason": "unsupported_action"} + session.send_message(str(direct.message), direct.data as Array) + ## The direct debugger-session path works without embedding, but bypasses + ## Game View's suspend button, so its visual pressed state is not synchronized. + return {"ok": true, "path": "direct_session", "game_view_ui_synced": false} + + +func _fail_pending_game_debug_controls_not_ready(message: String) -> void: + for request_id in _pending.keys(): + var pending_entry: Dictionary = _pending.get(request_id, {}) + if str(pending_entry.get("kind", "")) != "game_debug_control": + continue + var connection: McpConnection = pending_entry.get("connection") + _clear_pending(str(request_id)) + if connection != null and is_instance_valid(connection): + _send_error(connection, str(request_id), ErrorCodes.EVAL_GAME_NOT_READY, message) + + +## --- 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 work := ScriptWork.begin("game_command_ready") + await _settle_game_command_ready(tree, op, params, request_id, connection, timeout_sec) + ScriptWork.finish(work) + + +func _settle_game_command_ready( + 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..252fcb6 --- /dev/null +++ b/addons/godot_ai/dispatcher.gd @@ -0,0 +1,529 @@ +@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 _tick_active := false +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, + ## The editor-side runtime-control timer owns 5s; keep the dispatcher + ## outside it so the actionable control result wins before DEFERRED_TIMEOUT. + "game_debug_control": 6500, + "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") +const PluginReload := preload("res://addons/godot_ai/utils/plugin_reload.gd") + + +func _init(log_buffer: McpLogBuffer, surfaced_error_tracker = null) -> void: + _log_buffer = log_buffer + _surfaced_error_tracker = surfaced_error_tracker + + +## Register a command handler. The callable receives (params: Dictionary) -> Dictionary. +func register(command_name: String, handler: Callable) -> void: + _handlers[command_name] = handler + + +## Declare a lazily-constructed handler (#736). `script_path` is load()ed +## and instantiated with `ctor_args` at the first dispatch of any command +## registered against `handler_key` via register_lazy. `ctor_args` may hold +## plugin-lifetime objects (connection, buffers, the dispatcher itself for +## batch); clear() drops them so teardown ordering matches the old eager +## registration (#46). +func register_lazy_handler(handler_key: String, script_path: String, ctor_args: Array) -> void: + _lazy_handler_specs[handler_key] = {"path": script_path, "args": ctor_args} + + +## Register a command that resolves to `method` on the lazily-constructed +## handler declared under `handler_key`. Same dispatch semantics as +## register(); only construction timing differs. +func register_lazy(command_name: String, handler_key: String, method: StringName) -> void: + _lazy_commands[command_name] = {"handler": handler_key, "method": method} + +func unregister(command_name: String, handler_key: String) -> void: + _handlers.erase(command_name) + _lazy_commands.erase(command_name) + ## Drop the lazy spec/cache only when no remaining command routes to + ## this handler key. Custom tools use 1:1 keys ("custom:") so this + ## always erases for them, but built-in keys serve many commands — + ## erasing the shared spec would strand the siblings on + ## "No lazy handler declared" at dispatch time. + for entry in _lazy_commands.values(): + if entry.get("handler", "") == handler_key: + return + _lazy_handler_cache.erase(handler_key) + _lazy_handler_specs.erase(handler_key) + +## Synchronously realize handler-owned work before any add-on script can be +## replaced. A handler that cannot prove quiescence keeps the dispatcher live +## and makes the caller fail closed. +func quiesce_for_script_swap() -> Dictionary: + # Deferred timeouts are not proof that the underlying work has returned; + # the independent ledger also covers an old composition after a reload. + var work: Dictionary = preload("res://addons/godot_ai/utils/script_work.gd").quiescence() + if not bool(work.get("ok", false)): + return work + if not _pending_deferred.is_empty(): + return {"ok": false, "error": "Wait for pending tool responses before updating."} + for handler_key in _lazy_handler_cache: + var instance: Variant = _lazy_handler_cache[handler_key] + if not is_instance_valid(instance) or not instance.has_method("quiesce_for_script_swap"): + return { + "ok": false, + "error": "Command handler '%s' cannot prove quiescence for script replacement." % handler_key, + } + var result: Variant = instance.call("quiesce_for_script_swap") + if not result is Dictionary or not bool(result.get("ok", false)): + return { + "ok": false, + "error": "Command handler '%s' could not quiesce for script replacement: %s" % [ + handler_key, result, + ], + } + return {"ok": true} + + +## 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 a successful clear(), the dispatcher +## is inert. A failed quiesce leaves every reference intact. +func clear() -> Dictionary: + var quiesced := quiesce_for_script_swap() + if not bool(quiesced.get("ok", false)): + return quiesced + release_after_teardown() + return {"ok": true} + + +## Ordinary plugin teardown is not permission to replace scripts. The root +## first stops transport and joins its client/vision workers, then drops this +## graph even when a handler cannot certify hot script replacement. Requiring +## that stronger certificate here leaks the dispatcher <-> handler cycles at +## every editor exit. Hot-update callers must still use clear(), which refuses +## to release anything until every materialized handler proves quiescence. +func release_after_teardown() -> void: + _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]: + ## Editor filesystem operations can pump another frame before the handler + ## returns. That frame must not dispatch the still-queued command again. + if _tick_active or PluginReload.is_reload_pending(): + return [] + _tick_active = true + 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 PluginReload.is_reload_pending(): + break + + if idx > 0: + _command_queue = _command_queue.slice(idx) + + _tick_active = false + 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..150912d --- /dev/null +++ b/addons/godot_ai/dock_panels/log_viewer.gd @@ -0,0 +1,86 @@ +@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. It +## receives copied `{sequence, reset, lines}` values; neither this view nor its +## parent Dock retains the root-owned log buffer. +## +## 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") +const Settings := preload("res://addons/godot_ai/utils/settings.gd") + +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() -> void: + 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) + + +func sequence() -> int: + return _last_log_seq + + +func present_snapshot(snapshot: Dictionary) -> void: + if _log_display == null: + return + var seq := int(snapshot.get("sequence", _last_log_seq)) + if bool(snapshot.get("reset", false)) or seq < _last_log_seq: + _log_display.clear() + _last_log_seq = 0 + var lines: Array[String] = [] + lines.assign(snapshot.get("lines", [])) + for line in 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..ea935b4 --- /dev/null +++ b/addons/godot_ai/dock_panels/port_picker_panel.gd @@ -0,0 +1,106 @@ +@tool +extends VBoxContainer + +## Dock subpanel — port-change escape hatch surfaced inside the spawn-failure +## crash panel when a port is contested or an incompatible server cannot be reclaimed. +## +## It moves BOTH ports. A godot-ai server binds its HTTP and WebSocket ports +## together, so moving only the HTTP port onto a free number lands the next +## launch on a WebSocket port the previous server still holds, and the server +## dies at preflight ("WebSocket port 19630 is already in use"). Emits +## `port_apply_requested(new_http_port, new_ws_port)` after range-validation; +## the dock writes the EditorSettings and reloads 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") +const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd") + +signal port_apply_requested(new_http_port: int, new_ws_port: int) + +var _spinbox: SpinBox +var _ws_spinbox: SpinBox +## Bind probe used when seeding; tests substitute a deterministic one. +var port_in_use_probe: Callable = Callable(PortResolver, "is_port_in_use") + + +## 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 = _port_spinbox(ClientConfigurator.http_port(), "Effective HTTP port") + picker_row.add_child(_spinbox) + _ws_spinbox = _port_spinbox(ClientConfigurator.ws_port(), "Effective WebSocket port") + picker_row.add_child(_ws_spinbox) + + var apply_btn := Button.new() + apply_btn.text = "Apply + Reload" + apply_btn.tooltip_text = ( + "Saves the effective HTTP and WebSocket ports to Editor Settings and reloads" + + " the plugin so the server spawns on the new ports. Reconfigure your AI" + + " clients afterwards so their bridges use the new ports." + ) + apply_btn.pressed.connect(_on_apply_pressed) + picker_row.add_child(apply_btn) + + add_child(picker_row) + + +static func _port_spinbox(value: int, tooltip: String) -> SpinBox: + var spinbox := SpinBox.new() + spinbox.min_value = ClientConfigurator.MIN_PORT + spinbox.max_value = ClientConfigurator.MAX_PORT + spinbox.step = 1 + spinbox.value = value + spinbox.tooltip_text = tooltip + spinbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL + return spinbox + + +## Seed both boxes every time the panel surfaces, so a stale value from a +## previous spawn-failure round cannot carry over. The diagnosed +## `conflict_port` and any port that is simply in use get a fresh free +## suggestion; a port that is free keeps its value, so clients whose entries +## carry it keep working. Note that this OVERWRITES unsaved user input — fine +## in practice because the dock only calls this on `server_status` +## transitions (`if server_status == _last_server_status: return`). +func seed_suggested_ports(conflict_port := 0) -> void: + if _spinbox == null: + return + var http := ClientConfigurator.http_port() + var ws := ClientConfigurator.ws_port() + if conflict_port == http or bool(port_in_use_probe.call(http)): + http = ClientConfigurator.suggest_free_port(http + 1) + if conflict_port == ws or ws == http or bool(port_in_use_probe.call(ws)): + ws = ClientConfigurator.suggest_free_port(ws + 1) + if ws == http: + ws = ClientConfigurator.suggest_free_port(ws + 1) + _spinbox.value = http + _ws_spinbox.value = ws + + +func _on_apply_pressed() -> void: + var new_http: int = int(_spinbox.value) + var new_ws: int = int(_ws_spinbox.value) + for port in [new_http, new_ws]: + if port < ClientConfigurator.MIN_PORT or port > ClientConfigurator.MAX_PORT: + return + if new_http == new_ws: + return + port_apply_requested.emit(new_http, new_ws) 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..13e6777 --- /dev/null +++ b/addons/godot_ai/handlers/animation_handler.gd @@ -0,0 +1,825 @@ +@tool +extends "res://addons/godot_ai/handlers/command_handler.gd" + +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..ab6a1e9 --- /dev/null +++ b/addons/godot_ai/handlers/animation_values.gd @@ -0,0 +1,446 @@ +@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: + ## Canonical scalar parser (#964): int/float passthrough plus + ## strictly-numeric strings. Null falls through to the generic + ## passthrough below, matching the old behavior for garbage. + var parsed: Variant = McpJsonValues.parse_float(value) + if parsed != null: + return {"ok": parsed} + 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..35fcd14 --- /dev/null +++ b/addons/godot_ai/handlers/api_handler.gd @@ -0,0 +1,89 @@ +@tool +extends "res://addons/godot_ai/handlers/command_handler.gd" + +## 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..ba7bd59 --- /dev/null +++ b/addons/godot_ai/handlers/audio_handler.gd @@ -0,0 +1,363 @@ +@tool +extends "res://addons/godot_ai/handlers/command_handler.gd" + +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 and +## strictly-numeric strings are allowed so JSON integers and stringified +## floats (#964) 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: + var parsed: Variant = McpJsonValues.parse_float(value) + if parsed != null: + return parsed + 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..790521b --- /dev/null +++ b/addons/godot_ai/handlers/autoload_handler.gd @@ -0,0 +1,91 @@ +@tool +extends "res://addons/godot_ai/handlers/command_handler.gd" + +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..3db1392 --- /dev/null +++ b/addons/godot_ai/handlers/batch_handler.gd @@ -0,0 +1,237 @@ +@tool +extends "res://addons/godot_ai/handlers/command_handler.gd" + +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. +## - reload_plugin: schedules teardown; later batch operations could pump +## its callback while the batch handler is still executing. +## - 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 := { + "reload_plugin": "reload_plugin must be called directly so no batch continues during reload", + "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", + "configure_client": + "configure_client is deferred and has no completion channel inside a batch — " + + "run it as its own tool call", + "remove_client": + "remove_client is deferred and has no completion channel inside a batch — " + + "run it as its own tool call", +} + +## The whole batch executes synchronously inside one dispatcher tick, +## outside the 4ms frame budget — an unbounded array freezes the editor +## for the batch's full duration. 500 is far above any legitimate scene +## edit while keeping worst-case stalls in check. +const MAX_BATCH_COMMANDS := 500 + +var _dispatcher: McpDispatcher +var _undo_redo: EditorUndoRedoManager + + +func _init(dispatcher: McpDispatcher, undo_redo: EditorUndoRedoManager) -> void: + _dispatcher = dispatcher + _undo_redo = undo_redo + + +func batch_execute(params: Dictionary) -> Dictionary: + var commands = params.get("commands", null) + if typeof(commands) != TYPE_ARRAY: + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "commands must be a list") + if commands.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "commands must not be empty") + if commands.size() > MAX_BATCH_COMMANDS: + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "commands exceeds the %d-command batch cap (got %d) — split into multiple batches" % [MAX_BATCH_COMMANDS, commands.size()] + ) + + var undo: bool = params.get("undo", true) + + for idx in range(commands.size()): + var item = commands[idx] + if typeof(item) != TYPE_DICTIONARY: + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "commands[%d] must be a dict" % idx) + var cmd_name: String = item.get("command", "") + if cmd_name.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "commands[%d] missing 'command' field" % idx) + if FORBIDDEN_SUBCOMMANDS.has(cmd_name): + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, + "commands[%d]: %s" % [idx, FORBIDDEN_SUBCOMMANDS[cmd_name]]) + if not _dispatcher.has_command(cmd_name): + return _unknown_command_error(idx, cmd_name) + ## Pre-validate params type: the execution loop's typed Dictionary + ## local would hard-error on a non-dict mid-batch, aborting AFTER + ## earlier mutations committed. Catching it here keeps the + ## all-or-nothing contract for malformed input. + if typeof(item.get("params", {})) != TYPE_DICTIONARY: + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "commands[%d].params must be a dict" % idx) + if cmd_name.begins_with("custom_tool:"): + var _custom_tools := McpToolRegistry.get_instance() + if _custom_tools: + var spec := _custom_tools.get_spec(cmd_name.trim_prefix("custom_tool:")) + ## A deferred tool's real reply arrives via ctx.send_deferred + ## AFTER the batch has already returned its synchronous + ## results — the sub-command would report an opaque + ## "_deferred" placeholder and the actual payload would be + ## orphaned. Reject up-front, matching the FORBIDDEN_SUBCOMMANDS + ## contract for run_tests. + if spec != null and spec.deferred: + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "commands[%d] custom_tool:%s is deferred — its reply outlives the batch response; call it directly instead" % [idx, cmd_name.trim_prefix("custom_tool:")] + ) + if undo and spec != null and not spec.undoable: + return ErrorCodes.make( + ErrorCodes.CUSTOM_TOOL_NOT_UNDOABLE, + "commands[%d] custom_tool:%s is not undoable" % [idx, cmd_name.trim_prefix("custom_tool:")] + ) + + var results: Array = [] + var succeeded := 0 + var stopped_at = null + var all_undoable := true + ## One entry per action a sub-command actually committed, in commit order, + ## naming the UndoRedo that received it. Rollback replays this in reverse. + ## + ## Measured rather than inferred. Counting *successes* over-undoes, because + ## a sub-command can succeed without committing anything (`create_script` + ## and `write_text_file` write straight to disk) — the extra undos then eat + ## the user's own pre-batch edits. Trusting the response's `undoable` flag + ## instead under-undoes, because `custom_tool:` sub-commands never carry one + ## (`custom_tool_wrapper.gd` returns the addon handler's dict verbatim) even + ## when their spec declares `undoable = true` and they did commit. Comparing + ## `UndoRedo.get_version()` across the call is the only reading that can't + ## disagree with what the editor actually recorded. + var committed: 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 tracked := _tracked_histories() + var before: Array = [] + for ur in tracked: + before.append(ur.get_version()) + + var raw_result: Dictionary = _dispatcher.dispatch_direct(cmd_name, sub_params) + ## Record before reading status. A handler can commit_action() and still + ## return an error; those actions must be in `committed` so a later + ## (or this) failure rolls them back. + _record_committed(tracked, before, committed) + 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 + + var rolled_back := false + if stopped_at != null and undo and not committed.is_empty(): + rolled_back = _rollback(committed) + + 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} + + +## The histories a batch sub-command can commit into. +## +## Most write handlers pin their action to the edited scene, but not all: +## handlers that `create_action(label)` without a context object bind it to the +## handler `RefCounted` instead, and it lands in `GLOBAL_HISTORY` (see +## `animation_handler.gd`'s note on mismatched histories, and +## `testing/test_suite.gd`, which watches both for the same reason). Watching +## only the scene history would leave those actions un-rolled-back. +func _tracked_histories() -> Array: + var out: Array = [] + var scene_root := EditorInterface.get_edited_scene_root() + if scene_root != null: + var scene_ur := _undo_redo.get_history_undo_redo( + _undo_redo.get_object_history_id(scene_root) + ) + 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 + + +## Append one entry to `committed` per action the just-finished sub-command +## pushed, by diffing each watched history's version against `before`. +## `UndoRedo.get_version()` advances once per `commit_action`, so the delta is +## the action count — a handler that commits twice is recorded twice, and one +## that commits nothing is recorded not at all. +func _record_committed(tracked: Array, before: Array, committed: Array) -> void: + for i in range(tracked.size()): + var delta: int = tracked[i].get_version() - int(before[i]) + for _n in range(delta): + committed.append(tracked[i]) + + +## 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 every action the batch committed, newest first, against the history +## that actually received it. Returns true iff every undo() reported success. +## +## Strict LIFO across histories matters: undoing a scene action before a global +## one committed after it would replay them out of order. Walking `committed` +## in reverse preserves commit order regardless of which history each landed in. +func _rollback(committed: Array) -> bool: + if committed.is_empty(): + return false + var ok := true + for i in range(committed.size() - 1, -1, -1): + if not committed[i].undo(): + ok = false + return ok 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..5675037 --- /dev/null +++ b/addons/godot_ai/handlers/camera_handler.gd @@ -0,0 +1,1131 @@ +@tool +extends "res://addons/godot_ai/handlers/command_handler.gd" + +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 and then verify the +# viewport slot synchronously. CI used to report `is_current() == false` +# immediately after a committed make_current (#140 / #278 / #301 / #316); that +# was never engine lag — Camera2D's current state is a synchronous viewport +# read — but `Camera2D.make_current()` being silently dropped by the engine's +# group-call skip set. See `_broadcast_make_current_2d` for the mechanism and +# the deterministic repair. +# +# 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` and verify the viewport slot. Registered as the +# do/undo callable by `_add_make_current_to_action`. Safe against a freed +# camera node — short-circuits if the node is gone or not in the tree. +# +# Everything here is synchronous: `Camera2D.is_current()` is literally +# `viewport->get_camera_2d() == this` and `Camera3D.is_current()` reads the +# node's own flag while it is part of the edited scene, so there is no engine +# state that a delay or a retry could wait for. The one way `make_current()` +# fails is the group-call skip described on `_broadcast_make_current_2d`, and +# that is repaired in place. +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) + cam.make_current() + if cam is Camera2D and not _is_current_settled(cam): + _broadcast_make_current_2d(cam as Camera2D, scene_root) + _force_camera_refresh(cam) + # In the editor a displaced Camera3D keeps its own `current` flag (the + # viewport's LOST_CURRENT notification does not clear it), so it still + # answers is_current() == true. Sweep same-class siblings and clear them. + _force_clear_other_currents(cam, type_str, scene_root) + + +# `Camera2D.make_current()` does not touch the viewport itself: it calls +# `SceneTree.call_group("__cameras_", "_make_current", self)` and +# every camera in the group sets or releases `Viewport.camera_2d` in +# `_make_current(which)`. `call_group` skips any node whose pointer is in +# `SceneTree::nodes_removed_on_group_call` — a raw-pointer set of every node +# removed from the tree since the current `_process()` pass (or the outermost +# group call) began, cleared only when that pass unwinds. A camera allocated +# into the address of a node removed and freed earlier in the same frame — a +# `memdelete`d undo reference, a `free()`d node, an editor Control rebuild — +# is therefore silently skipped: `make_current()` returns, the slot never +# changes, and `is_current()` stays false while the handler's own bookkeeping +# says current. Reproduced deterministically on 4.7.2 (49/50 iterations with +# a same-frame `free()`); this is the "engine-state lag" behind #140 / #278 / +# #301 / #316. Camera3D is not affected: it writes the viewport directly. +# +# Repair by replicating the broadcast with the same bound `_make_current` +# method the engine's group call invokes: every other Camera2D in the scene +# releases the slot, then the target claims it. `_make_current` is engine +# API, not documented — guard on `has_method` so a future engine that drops +# it degrades to the plain `make_current()` result instead of erroring. +func _broadcast_make_current_2d(cam: Camera2D, scene_root: Node) -> void: + if not cam.has_method("_make_current"): + return + if scene_root != null: + for other in _list_cameras_in_scene(scene_root, "2d"): + if other == cam or not is_instance_valid(other) or not other.is_inside_tree(): + continue + if other.has_method("_make_current"): + other.call("_make_current", cam) + cam.call("_make_current", cam) + + +# 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 + + +# 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..2ee4865 --- /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: + ## Canonical scalar parser (#964): numeric strings coerce too. + var parsed: Variant = McpJsonValues.parse_float(value) + if parsed != null: + return {"ok": true, "value": parsed} + 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..f6df09c --- /dev/null +++ b/addons/godot_ai/handlers/client_handler.gd @@ -0,0 +1,58 @@ +@tool +extends "res://addons/godot_ai/handlers/command_handler.gd" + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Handles MCP client configuration commands. + +var _client_jobs + + +func _init(client_jobs = null) -> void: + _client_jobs = client_jobs + + +func configure_client(params: Dictionary) -> Dictionary: + return _request_client_action(params, "configure") + + +func remove_client(params: Dictionary) -> Dictionary: + return _request_client_action(params, "remove") + + +func _request_client_action(params: Dictionary, action: String) -> 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 request_id: String = params.get("_request_id", "") + if _client_jobs == null or request_id.is_empty(): + return ErrorCodes.make( + ErrorCodes.INTERNAL_ERROR, + "Client mutations must run as top-level deferred commands.", + ) + var started: Dictionary = _client_jobs.request_mcp_action(request_id, client_id, action) + if not bool(started.get("ok", false)): + return ErrorCodes.make( + ErrorCodes.INTERNAL_ERROR, + str(started.get("error", "Client worker is unavailable.")), + ) + return { + "_deferred": true, + "_deferred_timeout_ms": int(started.get("deferred_timeout_ms", 0)), + } + + +func check_client_status(params: Dictionary) -> Dictionary: + var request_id: String = params.get("_request_id", "") + if _client_jobs == null or request_id.is_empty(): + return ErrorCodes.make( + ErrorCodes.INTERNAL_ERROR, + "Client status requires a deferred request context.", + ) + if not _client_jobs.request_mcp_status(request_id): + return ErrorCodes.make( + ErrorCodes.INTERNAL_ERROR, + "Client status worker is unavailable.", + ) + return McpDispatcher.DEFERRED_RESPONSE 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/command_handler.gd b/addons/godot_ai/handlers/command_handler.gd new file mode 100644 index 0000000..f7096d4 --- /dev/null +++ b/addons/godot_ai/handlers/command_handler.gd @@ -0,0 +1,13 @@ +@tool +extends RefCounted + +## Built-in handlers execute synchronously, or register every detached +## coroutine with ScriptWork. Client/vision OS workers belong to the root's +## worker owners and are joined separately before any scripts are replaced. +## New async entry points MUST retain their ledger entry until they return, +## including early returns; a request timeout/disconnect must not clear it. +const ScriptWork := preload("res://addons/godot_ai/utils/script_work.gd") + + +func quiesce_for_script_swap() -> Dictionary: + return ScriptWork.quiescence() diff --git a/addons/godot_ai/handlers/command_handler.gd.uid b/addons/godot_ai/handlers/command_handler.gd.uid new file mode 100644 index 0000000..0e99878 --- /dev/null +++ b/addons/godot_ai/handlers/command_handler.gd.uid @@ -0,0 +1 @@ +uid://xx6r8yaedry5 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..2076d67 --- /dev/null +++ b/addons/godot_ai/handlers/control_draw_recipe_handler.gd @@ -0,0 +1,318 @@ +@tool +extends "res://addons/godot_ai/handlers/command_handler.gd" + +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..b85143d --- /dev/null +++ b/addons/godot_ai/handlers/csg_handler.gd @@ -0,0 +1,118 @@ +@tool +extends "res://addons/godot_ai/handlers/command_handler.gd" + +## 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..fb7c593 --- /dev/null +++ b/addons/godot_ai/handlers/curve_handler.gd @@ -0,0 +1,243 @@ +@tool +extends "res://addons/godot_ai/handlers/command_handler.gd" + +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..ec889c1 --- /dev/null +++ b/addons/godot_ai/handlers/editor_handler.gd @@ -0,0 +1,1069 @@ +@tool +extends "res://addons/godot_ai/handlers/command_handler.gd" + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") +const Telemetry := preload("res://addons/godot_ai/telemetry.gd") +const PluginReload := preload("res://addons/godot_ai/utils/plugin_reload.gd") +const VisionRoutingScript := preload("res://addons/godot_ai/vision_routing.gd") + +## Handles editor state, selection, log, screenshot, and performance commands. + +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)), + } + 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 on every v4-supported engine. During + ## partial initialization the buffer can still 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: + var work := PluginReload.reserve_reload() + if work == 0: + return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "A plugin reload is already pending.") + _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(work) + 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. +# The deferred entry captures no handler. The helper owns the bounded native +# signal handoff: is_scanning() can become false before resource reloads and +# filesystem_changed run on the main thread. No suspended GDScript frame may +# span a scan which can recompile that very frame's script. +static func _do_reload_plugin(work: int = 0) -> void: + PluginReload.reload_after_scan(work) + + +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_debug_control(params: Dictionary) -> Dictionary: + var action := str(params.get("action", "")) + if action not in ["suspend", "resume", "next_frame", "debug_status"]: + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "Invalid game debug action '%s' — use suspend, resume, next_frame, or debug_status" % action, + ) + 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 := str(params.get("_request_id", "")) + if request_id.is_empty(): + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, + "Missing internal _request_id — cannot correlate deferred response") + _debugger_plugin.request_game_debug_control(action, 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..3722c3e --- /dev/null +++ b/addons/godot_ai/handlers/environment_handler.gd @@ -0,0 +1,181 @@ +@tool +extends "res://addons/godot_ai/handlers/command_handler.gd" + +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..16c46f5 --- /dev/null +++ b/addons/godot_ai/handlers/filesystem_handler.gd @@ -0,0 +1,320 @@ +@tool +extends "res://addons/godot_ai/handlers/command_handler.gd" + +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: + var work := ScriptWork.begin("scan_filesystem") + await _settle_scan(connection, request_id, efs) + ScriptWork.finish(work) + + +static func _settle_scan( + 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..8d8544e --- /dev/null +++ b/addons/godot_ai/handlers/gridmap_handler.gd @@ -0,0 +1,192 @@ +@tool +extends "res://addons/godot_ai/handlers/command_handler.gd" + +## 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..d423ee7 --- /dev/null +++ b/addons/godot_ai/handlers/input_handler.gd @@ -0,0 +1,462 @@ +@tool +extends "res://addons/godot_ai/handlers/command_handler.gd" + +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..bd617ef --- /dev/null +++ b/addons/godot_ai/handlers/material_handler.gd @@ -0,0 +1,809 @@ +@tool +extends "res://addons/godot_ai/handlers/command_handler.gd" + +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..466c8bb --- /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: + ## Canonical scalar parser (#964): numeric strings coerce too. + var parsed: Variant = McpJsonValues.parse_float(value) + if parsed != null: + return {"ok": true, "value": parsed} + 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..9a969e6 --- /dev/null +++ b/addons/godot_ai/handlers/node_handler.gd @@ -0,0 +1,1418 @@ +@tool +extends "res://addons/godot_ai/handlers/command_handler.gd" + +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() + ## Ported from upstream PR #927 at + ## 1a95bcca51d81d29de925c2f636814eaa037c1c2 (issue #904). Snapshot + ## descendants before commit: remove_child clears owner on any child whose + ## owner sits outside the pruned subtree, so both do and undo must restore + ## those owners as part of the recorded action. + var descendants := _collect_descendants(node) + + _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) + for child in descendants: + _undo_redo.add_do_method(child, "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) + for child in descendants: + ## Keep a null owner as null. Substituting scene_root would make an + ## intentionally unowned descendant scene-owned on undo (#904). + var prior_owner: Node = child.owner + _undo_redo.add_undo_method(child, "set_owner", prior_owner) + _undo_redo.add_undo_reference(node) + _undo_redo.commit_action() + + 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 + + ## Ported from upstream PR #927 (issue #904). Record descendant owners + ## inside the action so redo restores them. Undo is just remove_child of + ## the copy; descendants live on `dup` via add_do_reference and do not need + ## their own undo set_owner. + var descendants := _collect_descendants(dup) + + _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) + for child in descendants: + _undo_redo.add_do_method(child, "set_owner", scene_root) + _undo_redo.add_do_reference(dup) + _undo_redo.add_undo_method(parent, "remove_child", dup) + _undo_redo.commit_action() + + 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", + } + } + + +## All descendants of `node` (not including `node` itself), depth-first. +## Used to record per-child set_owner inside an undo action without targeting +## the handler as an UndoRedo receiver (upstream PR #927 / issue #904). +static func _collect_descendants(node: Node) -> Array[Node]: + var out: Array[Node] = [] + for child in node.get_children(): + out.append(child) + out.append_array(_collect_descendants(child)) + return out + + +## 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) + if value is String: + ## #964: some MCP clients stringify float arguments ("4.0"). + ## Accept strictly-numeric strings; unparseable ones flow + ## through unchanged so _check_coerced raises the typed + ## WRONG_TYPE error instead of a silent zero/null write. + var parsed: Variant = McpJsonValues.parse_float(value) + if parsed != null: + return parsed + 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..d994bad --- /dev/null +++ b/addons/godot_ai/handlers/particle_handler.gd @@ -0,0 +1,860 @@ +@tool +extends "res://addons/godot_ai/handlers/command_handler.gd" + +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..b958abb --- /dev/null +++ b/addons/godot_ai/handlers/physics_shape_handler.gd @@ -0,0 +1,772 @@ +@tool +extends "res://addons/godot_ai/handlers/command_handler.gd" + +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 +var _connection + + +func _init(undo_redo: EditorUndoRedoManager, connection = null) -> void: + _undo_redo = undo_redo + _connection = connection + + +const _SHAPE_3D_CLASSES := { + "box": "BoxShape3D", + "sphere": "SphereShape3D", + "capsule": "CapsuleShape3D", + "cylinder": "CylinderShape3D", +} + +const _SHAPE_2D_CLASSES := { + "rectangle": "RectangleShape2D", + "circle": "CircleShape2D", + "capsule": "CapsuleShape2D", +} + +const _GENERATED_BODY_CLASSES := { + "static": "StaticBody3D", + "area": "Area3D", +} +const _GENERATE_DIRECT_MAX_PATHS := 16 +const _GENERATE_MAX_PATHS := 1024 +## One deferred request may spend this long across editor frames. The Python +## handler's timeout is this plus its transport margin (a source-shape test +## keeps the two together). +const _GENERATE_DEFERRED_TIMEOUT_MS := 30000 +## Wall-clock budget one editor frame gives the job, the same order as the +## dispatcher's own tick budget: a 1024-path request neither stalls the editor +## nor waits out hundreds of near-empty frames. +const _GENERATE_FRAME_BUDGET_USEC := 4000 +const _GENERATE_COLLIDER_SUFFIX := "Collider" +const _GENERATE_SCALE_EPSILON := 0.0001 + + +## 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. +## Returns "" when neither form matches. +static func _normalize_shape_type(type_map: Dictionary, requested: String) -> String: + if type_map.has(requested): + return requested + for short_form in type_map: + if type_map[short_form] == requested: + return short_form + return "" + + +## Generates sibling physics bodies and collision shapes for MeshInstance3D +## nodes. Every path and option is validated before the first mutation, and a +## deferred request re-validates each mesh again when it is applied, so a +## scene edited during the window can only fail the whole request, never +## leave a partially generated batch. +func generate(params: Dictionary) -> Dictionary: + var validated := _validate_generate_request(params) + if validated.has("error"): + return validated + + var request_id: String = params.get("_request_id", "") + if _connection != null and not request_id.is_empty(): + var job := _generate_job(validated, _undo_redo, _connection, request_id) + _drive_generate_job(job, _connection) + return { + "_deferred": true, + "_deferred_timeout_ms": _GENERATE_DEFERRED_TIMEOUT_MS, + } + + ## `dispatch_direct()` strips the request id, so batch_execute and unit-test + ## callers cannot yield across frames. Keep that compatibility path bounded; + ## larger requests must use the normal MCP command and its deferred reply. + var path_count: int = validated.paths.size() + if path_count > _GENERATE_DIRECT_MAX_PATHS: + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + ( + "physics_shape_generate received %d paths; inside batch_execute at most %d are " + + "supported — call resource_manage(op='physics_shape_generate') directly for larger batches" + ) % [path_count, _GENERATE_DIRECT_MAX_PATHS], + ) + var job := _generate_job(validated, _undo_redo, null, "") + while not _generate_step(job, -1): + pass + return job.result + + +## Validate the request shape and options first (no scene needed), then the +## edited scene. Nothing here touches a node. +static func _validate_generate_request(params: Dictionary) -> Dictionary: + var requested_shape := String(params.get("shape_type", "box")) + var shape_type := _normalize_shape_type(_SHAPE_3D_CLASSES, requested_shape) + if shape_type.is_empty(): + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "Invalid shape_type '%s'. Valid: %s" % [requested_shape, ", ".join(_SHAPE_3D_CLASSES.keys())] + ) + var body_type := String(params.get("body_type", "static")) + if not _GENERATED_BODY_CLASSES.has(body_type): + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "Invalid body_type '%s'. Valid: %s" % [body_type, ", ".join(_GENERATED_BODY_CLASSES.keys())] + ) + + var raw_paths: Variant = params.get("paths", []) + if not raw_paths is Array: + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "paths must be an array of MeshInstance3D scene paths, got %s" % type_string(typeof(raw_paths)), + ) + if raw_paths.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: paths") + if raw_paths.size() > _GENERATE_MAX_PATHS: + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "paths supports at most %d entries per request, got %d" % [_GENERATE_MAX_PATHS, raw_paths.size()], + ) + var paths: Array[String] = [] + var seen := {} + for index in raw_paths.size(): + var raw_path: Variant = raw_paths[index] + if not raw_path is String: + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "paths[%d] must be a MeshInstance3D scene path string, got %s" + % [index, type_string(typeof(raw_path))], + ) + if seen.has(raw_path): + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "paths lists %s twice (entries %d and %d); each mesh gets one collider" + % [raw_path, int(seen[raw_path]), index], + ) + seen[raw_path] = index + paths.append(raw_path) + + var scene_check := McpScenePath.require_edited_scene(params.get("scene_file", "")) + if scene_check.has("error"): + return scene_check + return { + "data": true, + "scene_root": scene_check.node, + "scene_file": params.get("scene_file", ""), + "shape_type": shape_type, + "body_type": body_type, + "paths": paths, + } + + +## Resolve one mesh and capture everything the apply step must find unchanged. +static func _plan_generate_mesh( + mesh_path: String, scene_file: String, scene_root: Node, shape_type: String +) -> Dictionary: + var resolved := McpNodeValidator.resolve_or_error(mesh_path, "paths", scene_file) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + if node == scene_root: + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "%s is the scene root — a sibling body needs a parent inside the scene" % mesh_path + ) + if not node is MeshInstance3D: + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Node at %s is %s — must be MeshInstance3D" % [mesh_path, node.get_class()] + ) + var mesh := node as MeshInstance3D + var parent := mesh.get_parent() + if parent == null: + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "MeshInstance3D at %s has no parent — cannot create a sibling body" % mesh_path + ) + if mesh.mesh == null: + return ErrorCodes.make( + ErrorCodes.RESOURCE_NOT_FOUND, + "MeshInstance3D at %s has no mesh resource — there are no bounds to fit" % mesh_path + ) + var collider_name := mesh.name + _GENERATE_COLLIDER_SUFFIX + var existing := parent.get_node_or_null(NodePath(collider_name)) + if existing != null: + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "MeshInstance3D at %s already has a collider sibling at %s — remove or rename it first" + % [mesh_path, McpScenePath.from_node(existing, scene_root)] + ) + ## A sibling in parent space inherits the parent chain's scale exactly like + ## the mesh does, so its box matches the visual; but Godot cannot scale a + ## sphere, capsule or cylinder non-uniformly (the warning it prints is a + ## deformed collider). A top-level mesh ignores its parent's transform. + if not mesh.top_level and shape_type != "box" and parent is Node3D: + var parent_scale: Vector3 = (parent as Node3D).global_transform.basis.get_scale() + var spread := absf(parent_scale.x - parent_scale.y) + spread = maxf(spread, absf(parent_scale.y - parent_scale.z)) + if spread > _GENERATE_SCALE_EPSILON * maxf(1.0, parent_scale.length()): + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + ( + "MeshInstance3D at %s sits under a parent chain scaled %s; a %s collider " + + "cannot be scaled non-uniformly — use shape_type 'box' or reparent the mesh" + ) % [mesh_path, parent_scale, shape_type] + ) + var source_transform := mesh.global_transform if mesh.top_level else mesh.transform + var body_transform := Transform3D( + Basis(source_transform.basis.get_rotation_quaternion()), + source_transform.origin, + ) + var mesh_to_body := body_transform.affine_inverse() * source_transform + var bounds: AABB = mesh_to_body * mesh.get_aabb() + if bounds.size.x <= 0.0 or bounds.size.y <= 0.0 or bounds.size.z <= 0.0: + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "MeshInstance3D at %s has empty bounds %s — nothing to fit" % [mesh_path, bounds] + ) + return {"plan": { + "mesh": mesh, + "mesh_path": mesh_path, + "parent": parent, + "collider_name": collider_name, + "top_level": mesh.top_level, + "source_transform": source_transform, + "body_transform": body_transform, + "bounds": bounds, + }} + + +## Why a plan captured earlier no longer describes the scene, or "" when it +## still does. Checked again right before each body is added, so a deferred +## request can never apply plan-time state to a mesh that moved, was +## reparented, lost its mesh, gained a collider, or went away meanwhile. +static func _plan_stale_reason(plan: Dictionary) -> String: + var mesh: MeshInstance3D = plan.mesh + ## A node taken out of the tree and queued to free is still a valid + ## instance for the rest of the frame; not being in the tree is what + ## "removed" means here. + if not is_instance_valid(mesh) or not mesh.is_inside_tree(): + return "was removed" + var parent: Node = plan.parent + if not is_instance_valid(parent) or mesh.get_parent() != parent: + return "was reparented" + if mesh.top_level != bool(plan.top_level): + return "changed its top_level setting" + var source_transform := mesh.global_transform if mesh.top_level else mesh.transform + if not source_transform.is_equal_approx(plan.source_transform): + return "moved" + if mesh.mesh == null: + return "lost its mesh resource" + if parent.get_node_or_null(NodePath(str(plan.collider_name))) != null: + return "gained a collider sibling" + return "" + + +## Build one detached body/collision pair from a prevalidated mesh plan. +static func _create_generated_entry( + plan: Dictionary, shape_type: String, body_type: String +) -> Dictionary: + var mesh: MeshInstance3D = plan.mesh + var body: CollisionObject3D = ClassDB.instantiate(_GENERATED_BODY_CLASSES[body_type]) + body.name = str(plan.collider_name) + body.top_level = bool(plan.top_level) + body.transform = plan.body_transform + var collision := CollisionShape3D.new() + collision.name = "CollisionShape3D" + var bounds: AABB = plan.bounds + collision.position = bounds.get_center() + var shape: Shape3D = ClassDB.instantiate(_SHAPE_3D_CLASSES[shape_type]) + _apply_shape_size(shape, shape_type, {"aabb": bounds}, true) + collision.shape = shape + body.add_child(collision) + return { + "mesh": mesh, + "parent": plan.parent, + "body": body, + "collision": collision, + } + + +## Record all generated nodes as one undo action, optionally executing it. +static func _commit_generated_action( + created_nodes: Array[Dictionary], + scene_root: Node, + undo_redo: EditorUndoRedoManager, + execute: bool, +) -> void: + undo_redo.create_action("MCP: Generate physics shapes for %d mesh(es)" % created_nodes.size()) + for entry in created_nodes: + var parent: Node = entry.parent + var body: CollisionObject3D = entry.body + var collision: CollisionShape3D = entry.collision + undo_redo.add_do_method(parent, "add_child", body, true) + undo_redo.add_do_method(body, "set_owner", scene_root) + undo_redo.add_do_method(collision, "set_owner", scene_root) + undo_redo.add_do_reference(body) + undo_redo.add_undo_method(parent, "remove_child", body) + undo_redo.commit_action(execute) + + +## Build the public response after every body is present in the edited scene. +static func _generated_response( + created_nodes: Array[Dictionary], scene_root: Node, shape_type: String, body_type: String +) -> Dictionary: + var created: Array[Dictionary] = [] + for entry in created_nodes: + created.append({ + "mesh_path": McpScenePath.from_node(entry.mesh, scene_root), + "body_path": McpScenePath.from_node(entry.body, scene_root), + "shape_path": McpScenePath.from_node(entry.collision, scene_root), + "shape_type": shape_type, + "body_type": body_type, + }) + return {"data": {"created": created, "undoable": true}} + + +## The whole request as a value the frame loop (or a test) advances with +## `_generate_step`. Phase "plan" resolves and measures every path; phase +## "apply" re-validates each plan and adds its body immediately; the last +## step records one undo action and fills `result`. +static func _generate_job( + validated: Dictionary, undo_redo: EditorUndoRedoManager, connection, request_id: String +) -> Dictionary: + return { + "validated": validated, + "undo_redo": undo_redo, + "connection": connection, + "request_id": request_id, + "phase": "plan", + "index": 0, + "plans": [], + "created": [], + "result": {}, + "started_ms": Time.get_ticks_msec(), + } + + +## Advance the job for at most `budget_usec` of wall-clock time (0 does one +## item, a negative budget runs to completion). Returns true once `job.result` +## holds the reply, or once the request was abandoned (empty result). +static func _generate_step(job: Dictionary, budget_usec: int) -> bool: + if str(job.phase) == "done": + return true + var validated: Dictionary = job.validated + var connection = job.connection + if connection != null: + if not _deferred_request_pending(connection, str(job.request_id)): + ## The dispatcher gave up on this request (timeout, client gone): + ## nothing may be left behind and nothing can be answered. + _generate_rollback(job) + job.phase = "done" + return true + if Time.get_ticks_msec() - int(job.started_ms) > _GENERATE_DEFERRED_TIMEOUT_MS: + return _generate_fail(job, ErrorCodes.make( + ErrorCodes.DEFERRED_TIMEOUT, + "physics_shape_generate exceeded its %d ms budget" % _GENERATE_DEFERRED_TIMEOUT_MS, + )) + var scene_root: Node = validated.scene_root + if not is_instance_valid(scene_root) or EditorInterface.get_edited_scene_root() != scene_root: + return _generate_fail(job, ErrorCodes.make( + ErrorCodes.EDITED_SCENE_MISMATCH, + "The edited scene changed while physics shapes were generated", + )) + ## The budget is checked before each item after the first, so every step + ## makes progress and a phase that just finished its last item moves on. + var frame_start := Time.get_ticks_usec() + var work := 0 + var paths: Array = validated.paths + while str(job.phase) == "plan": + if int(job.index) >= paths.size(): + job.phase = "apply" + job.index = 0 + break + if work > 0 and budget_usec >= 0 and Time.get_ticks_usec() - frame_start >= budget_usec: + return false + var planned := _plan_generate_mesh( + str(paths[int(job.index)]), str(validated.scene_file), scene_root, str(validated.shape_type) + ) + if planned.has("error"): + return _generate_fail(job, planned) + job.plans.append(planned.plan) + job.index = int(job.index) + 1 + work += 1 + var plans: Array = job.plans + while str(job.phase) == "apply" and int(job.index) < plans.size(): + if work > 0 and budget_usec >= 0 and Time.get_ticks_usec() - frame_start >= budget_usec: + return false + var plan: Dictionary = plans[int(job.index)] + var stale := _plan_stale_reason(plan) + if not stale.is_empty(): + var code: String = ErrorCodes.NODE_NOT_FOUND if stale == "was removed" else ErrorCodes.EDITED_SCENE_MISMATCH + return _generate_fail(job, ErrorCodes.make( + code, + "MeshInstance3D at %s %s while physics shapes were generated; nothing was changed" + % [str(plan.mesh_path), stale], + )) + var entry := _create_generated_entry(plan, str(validated.shape_type), str(validated.body_type)) + var parent: Node = plan.parent + parent.add_child(entry.body, true) + entry.body.set_owner(scene_root) + entry.collision.set_owner(scene_root) + job.created.append(entry) + job.index = int(job.index) + 1 + work += 1 + ## The nodes are already in the scene, so commit with execute=false: one + ## atomic undo/redo action without replaying every mutation now. + var created: Array[Dictionary] = [] + created.assign(job.created) + _commit_generated_action(created, scene_root, job.undo_redo, false) + job.result = _generated_response( + created, scene_root, str(validated.shape_type), str(validated.body_type) + ) + job.phase = "done" + return true + + +static func _generate_fail(job: Dictionary, error: Dictionary) -> bool: + _generate_rollback(job) + job.result = error + job.phase = "done" + return true + + +## Remove and free every body this job added; none is in an undo action yet. +static func _generate_rollback(job: Dictionary) -> void: + for entry in job.created: + var body: Node = entry.body + if not is_instance_valid(body): + continue + var parent := body.get_parent() + if parent != null: + parent.remove_child(body) + body.free() + job.created.clear() + + +## Check that the connection and deferred dispatcher entry are both live. +static func _deferred_request_pending(connection, request_id: String) -> bool: + if not is_instance_valid(connection): + return false + var dispatcher = connection.dispatcher + return dispatcher == null or dispatcher.has_pending_deferred_response(request_id) + + +## Drive a deferred job one editor frame at a time and reply when it ends. +## `send_deferred_response` itself drops a reply whose request is gone. +static func _drive_generate_job(job: Dictionary, connection) -> void: + if not is_instance_valid(connection): + return + var tree: SceneTree = connection.get_tree() + if tree == null: + return + ## The first yield lets the dispatcher register the deferred request before + ## any validation error or successful result can be sent. + await tree.process_frame + while is_instance_valid(connection) and not _generate_step(job, _GENERATE_FRAME_BUDGET_USEC): + await tree.process_frame + if is_instance_valid(connection) and not job.result.is_empty(): + connection.send_deferred_response(str(job.request_id), job.result) + + +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 requested_shape: String = params.get("shape_type", "box" if is_3d else "rectangle") + var type_map := _SHAPE_3D_CLASSES if is_3d else _SHAPE_2D_CLASSES + var shape_type := _normalize_shape_type(type_map, requested_shape) + if shape_type.is_empty(): + 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" % [requested_shape, 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; apply the source's scale so the + # collider tracks what you actually see in the viewport. Going + # through the transform keeps a mirrored (negative) scale a + # positive size, which BoxShape3D would otherwise reject. + var scale_3d: Vector3 = (source as Node3D).transform.basis.get_scale() + return {"aabb": Transform3D(Basis.from_scale(scale_3d), Vector3.ZERO) * 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..45ed197 --- /dev/null +++ b/addons/godot_ai/handlers/project_handler.gd @@ -0,0 +1,669 @@ +@tool +extends "res://addons/godot_ai/handlers/command_handler.gd" + +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. Blocked keys that DO have a legitimate agent +## workflow get a narrow validated op of their own instead of an exception +## carved into this list — `autoload_handler.add_autoload` for `autoload/`, +## `set_main_scene` below for the main scene — so the denylist stays a flat set +## of keys with no escape hatches to reason about. The rest are refused +## outright. +## +## 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 +] + +## ProjectSettings key that names the scene the game boots into. +const MAIN_SCENE_KEY := "application/run/main_scene" + +## Validated routes for blocked keys, appended to the refusal. `settings_get` +## on a blocked key works, so an agent can read the value it needs to change +## and then be refused the write — without a pointer it reads that asymmetry as +## "this workflow is closed" and hands a non-bootable project back to the user +## (#915). Keyed by the lowercased exact entry above. +const STARTUP_EXECUTION_KEY_ALTERNATIVES := { + "application/run/main_scene": + "Use project_manage(op='set_main_scene', params={'path': 'res://.tscn'}), " + + "which accepts only an existing PackedScene inside the project.", +} + + +## 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: + var message := ( + "Refusing to set '%s' — this key controls what the engine loads " % key + + "or executes at project startup." + ) + var alternative := str(STARTUP_EXECUTION_KEY_ALTERNATIVES.get(exact, "")) + if not alternative.is_empty(): + message += " " + alternative + return message + 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", + } + } + + +## The validated route to `application/run/main_scene`, which +## `set_project_setting` refuses (STARTUP_EXECUTION_KEYS_EXACT) because a +## generic setter has no way to tell a scene path from an arbitrary payload. +## Without it an agent can create every scene, script, node and resource for a +## new project and still not make it bootable: `project_run(mode="main")` has +## nothing to launch, and the last step — pure mechanical configuration — has +## to be finished by hand in the editor (#915). +## +## The blocklist's intent survives because the value can only ever name a scene +## the project already contains: the path is confined under `res://` by +## `McpPathValidator`, must resolve through `ResourceLoader`, and must load as +## a `PackedScene`. No new startup-execution surface is opened. +func set_main_scene(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 ResourceLoader.exists(path): + return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Scene not found: %s" % path) + ## Load rather than trusting `ResourceLoader.exists(path, "PackedScene")`: + ## the type hint there comes from the loader's guess for the extension, so a + ## `.tscn` that fails to parse would still pass it. A main scene that cannot + ## load is a project that cannot boot, so pay for the real load here instead + ## of surfacing the failure as a mystified `project_run`. + var resource := ResourceLoader.load(path) + if resource == null: + return ErrorCodes.make( + ErrorCodes.RESOURCE_NOT_FOUND, + "Failed to load scene (it may be corrupt or have a broken dependency): %s" % path + ) + if not (resource is PackedScene): + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "path must be a PackedScene, got %s: %s" % [resource.get_class(), path] + ) + + var had_setting := ProjectSettings.has_setting(MAIN_SCENE_KEY) + var old_value = ProjectSettings.get_setting(MAIN_SCENE_KEY) if had_setting else null + ProjectSettings.set_setting(MAIN_SCENE_KEY, path) + var err := ProjectSettings.save() + if err != OK: + if had_setting: + ProjectSettings.set_setting(MAIN_SCENE_KEY, old_value) + else: + ProjectSettings.clear(MAIN_SCENE_KEY) + return ErrorCodes.make( + ErrorCodes.INTERNAL_ERROR, + "Failed to save project settings while setting the main scene: %s (error %d)" + % [error_string(err), err] + ) + + return { + "data": { + "key": MAIN_SCENE_KEY, + "path": path, + "old_value": NodeHandler._serialize_value(old_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 work := ScriptWork.begin("run_project") + await _settle_run_project(request_id, base_data, connection, debugger_plugin) + ScriptWork.finish(work) + + +static func _settle_run_project( + 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 work := ScriptWork.begin("stop_project") + await _settle_stop_project(request_id, connection) + ScriptWork.finish(work) + + +static func _settle_stop_project(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..c43a6d3 --- /dev/null +++ b/addons/godot_ai/handlers/resource_handler.gd @@ -0,0 +1,591 @@ +@tool +extends "res://addons/godot_ai/handlers/command_handler.gd" + +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..f34f5fc --- /dev/null +++ b/addons/godot_ai/handlers/scene_handler.gd @@ -0,0 +1,429 @@ +@tool +extends "res://addons/godot_ai/handlers/command_handler.gd" + +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: + var work := ScriptWork.begin("open_scene") + await _settle_open_scene(connection, request_id, path, prev_root_id, payload) + ScriptWork.finish(work) + + +static func _settle_open_scene( + 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..050b243 --- /dev/null +++ b/addons/godot_ai/handlers/script_handler.gd @@ -0,0 +1,572 @@ +@tool +extends "res://addons/godot_ai/handlers/command_handler.gd" + +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." + ) + + # An overwrite can target a script that is already loaded (attached to a + # node, preloaded, open in the script editor). Registering the bytes with + # the editor leaves that live GDScript on the old source (#937), so the very + # next call would run stale code after a "successful" write. Refresh it + # BEFORE registering the file: a GUI editor loads the script inside + # update_file() (see _refresh_loaded_gdscript for the ordering contract). + if existed_before: + _refresh_loaded_gdscript(data, path, content) + + _register_written_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 + + +## 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. +## +## Call it only after `_refresh_loaded_gdscript` has taken its decision (see +## the ordering contract there). Instance (not static) so a test can stand in +## for what a GUI editor does inside update_file() — load or reload the +## script — and lock that ordering from a headless run. +func _register_written_file(path: String) -> void: + var efs := EditorInterface.get_resource_filesystem() + if efs != null: + efs.update_file(path) + + +## Bring an already-loaded GDScript back in step with the bytes just written. +## +## ResourceLoader caches GDScript by path, and registering the write with +## EditorFileSystem does not refresh that cache in a headless editor — so after +## a successful write, a script that a node, a preload(), or the script editor +## already holds keeps executing the previous source (#937). When the path is +## cached and the new source parsed, push the source into the live object and +## reload it in place (keep_state so existing instances survive). Reports +## `reloaded` plus a `reload_reason` when it did not, so a caller can tell +## "the file changed" from "the code changed". +## +## Ordering contract: this runs BEFORE `_register_written_file`. A GUI editor +## (not a headless one — EditorNode's cmdline mode skips the step) runs its +## script-documentation pass synchronously inside update_file(), which +## ResourceLoader.load()s the script, caching a never-loaded one, and +## reload_from_file()s a cached one that is not open in the script editor. +## Deciding after that call reports a false `already_current` for a script +## nobody held (instead of `not_loaded`) and for one this helper should have +## refreshed itself. The question is what was loaded before the write, so it +## is answered first; the editor's own pass then meets the same bytes. +## +## Skipped when validation failed: the diagnostics capture above has already +## reloaded the shared GDScriptCache entry with the broken source, so there is +## no good code to push; `reload_reason: parse_error` tells the caller the +## loaded code did NOT change to something runnable. +static func _refresh_loaded_gdscript(data: Dictionary, path: String, content: String) -> void: + data["reloaded"] = false + if _script_has_error_diagnostics(data): + data["reload_reason"] = "parse_error" + return + if not ResourceLoader.has_cached(path): + data["reload_reason"] = "not_loaded" + return + var loaded := ResourceLoader.load(path) + if not (loaded is GDScript): + data["reload_reason"] = "not_gdscript" + return + var script := loaded as GDScript + if script.source_code == content: + data["reloaded"] = true + data["reload_reason"] = "already_current" + return + script.source_code = content + var err := script.reload(true) + if err != OK: + data["reload_reason"] = "reload_failed" + data["reload_error"] = err + return + data["reloaded"] = true + + +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) + + # The file is fresh but any already-loaded GDScript for it is not (#937); + # make "patch succeeded" mean the loaded code changed, not just the bytes. + # Decide and refresh before registering the file with the editor — a GUI + # editor loads the script inside update_file() (see _refresh_loaded_gdscript). + _refresh_loaded_gdscript(data, path, new_content) + _register_written_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..76656f0 --- /dev/null +++ b/addons/godot_ai/handlers/signal_handler.gd @@ -0,0 +1,274 @@ +@tool +extends "res://addons/godot_ai/handlers/command_handler.gd" + +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..d04f47c --- /dev/null +++ b/addons/godot_ai/handlers/test_handler.gd @@ -0,0 +1,373 @@ +@tool +extends "res://addons/godot_ai/handlers/command_handler.gd" + +## 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") + +const CACHE_WARNING := ( + "Preloaded GDScript dependencies may be stale after source edits. " + + "Restart the editor before treating this run as validation of dependency changes. " + + "ResourceLoader cache modes do not invalidate GDScript's preload cache." +) + +## Clamp bounds for the server-provided ``timeout_budget_sec`` param. The +## floor is purely defensive (a malformed or buggy server value must not +## abort every run instantly); the param is not user-facing. +const BUDGET_MIN_SEC := 30.0 +const BUDGET_MAX_SEC := 3600.0 +## Conservative default when the server sent no (or an invalid) budget: an +## old server's own test_run timeout is 120s, and the plugin must abort +## and reply before that future expires. +const BUDGET_DEFAULT_SEC := 110.0 +## Abort this long before the server would time the call out, so the +## partial-results reply beats the server-side timeout. +const CEILING_MARGIN_SEC := 10.0 + +var _runner: McpTestRunner +var _undo_redo: EditorUndoRedoManager +var _log_buffer: McpLogBuffer +## Live plugin dispatcher, exposed to suites via ctx so tests can prove the +## lazy handler registrations (#736) materialize with their real ctor args. +## Optional third arg keeps old two-arg fixtures working; untyped because +## the dispatcher constructs this handler (avoids a load-time type cycle). +var _dispatcher +## Live connection for exclusive-run transport servicing. Null in unit-test +## fixtures and batch contexts, which keep the legacy synchronous path. +var _connection: McpConnection + + +func _init( + undo_redo: EditorUndoRedoManager, + log_buffer: McpLogBuffer, + dispatcher = null, + connection: McpConnection = null, +) -> void: + _runner = McpTestRunner.new() + _undo_redo = undo_redo + _log_buffer = log_buffer + _dispatcher = dispatcher + _connection = connection + + +func run_tests(params: Dictionary) -> Dictionary: + var suite_filter: String = params.get("suite", "") + var test_filter: String = params.get("test_name", "") + var exclude_test_filter: String = params.get("exclude_test_name", "") + var verbose: bool = params.get("verbose", false) + + var request_id: String = params.get("_request_id", "") + var live := _connection != null and not request_id.is_empty() + var service_cb := Callable() + var deadline_ticks_ms := 0 + var budget_sec := 0.0 + var started_ms := Time.get_ticks_msec() + var run_state := {} + if live: + budget_sec = _validated_budget_sec(params) + service_cb = Callable(_connection, "service_transport_during_exclusive_run") + deadline_ticks_ms = started_ms + int((budget_sec - CEILING_MARGIN_SEC) * 1000.0) + + ## Clear the previous run's results BEFORE discovery so an abort at any + ## later point can never expose a stale prior run via get_test_results. + _runner.clear() + + var discovery := _discover_suites(service_cb, deadline_ticks_ms, run_state) + var discovery_outcome: String = discovery.get("outcome", "") + if not discovery_outcome.is_empty(): + ## Aborted during discovery: no suite has begun, so there is no + ## suite teardown to run. Same outcome mapping as the run itself. + var empty_results: Dictionary = _runner.get_results(verbose) + if not discovery.errors.is_empty(): + empty_results["load_errors"] = discovery.errors + return _map_outcome( + discovery_outcome, "discovery", empty_results, 0, started_ms, budget_sec + ) + + var suites: Array = discovery.suites + ## #882: an explicit `suite` filter that matches nothing must say so, not + ## return {"total": 0} — a caller cannot otherwise distinguish "suite has + ## no tests" from "suite was never registered" (truncated discovery). + ## Checked BEFORE the no-suites branch so an empty discovery still names + ## the unmatched filter (with suites_available: []). + var unknown := unknown_suite_error(suite_filter, suites) + if not unknown.is_empty(): + if not discovery.errors.is_empty(): + unknown["error"]["data"]["load_errors"] = discovery.errors + return unknown + + if suites.is_empty(): + var msg := "No test suites found in res://tests/" + if not discovery.errors.is_empty(): + msg += " (%d script(s) failed to load: %s)" % [ + discovery.errors.size(), + ", ".join(discovery.errors), + ] + var no_suites := {"error": msg, "total": 0, "load_errors": discovery.errors, "cache_warning": CACHE_WARNING} + ## Keep the edited_scene annotation on the no-suites error payload too, + ## so the response contract is consistent across every return path. + _annotate_edited_scene(no_suites) + return {"data": no_suites} + + var ctx := { + "undo_redo": _undo_redo, + "log_buffer": _log_buffer, + "dispatcher": _dispatcher, + } + + var run: Dictionary = _runner.run_suites_serviced( + suites, suite_filter, test_filter, ctx, verbose, exclude_test_filter, + service_cb, deadline_ticks_ms, run_state + ) + var results: Dictionary = run["results"] + if not discovery.errors.is_empty(): + results["load_errors"] = discovery.errors + return _map_outcome( + run["outcome"], "run", results, run["tests_not_run"], started_ms, budget_sec + ) + + +## Empty when `suite_filter` is empty or names a discovered suite; otherwise +## the INVALID_PARAMS protocol error run_tests returns (same ErrorCodes.make +## shape as the paused/timeout aborts — never an error inside a success +## envelope). Static + pure so the suite can pin it without calling +## run_tests() end-to-end (which would recursively re-run the whole corpus +## from inside itself — see test_serviced_test_run.gd). +static func unknown_suite_error(suite_filter: String, suites: Array) -> Dictionary: + if suite_filter.is_empty(): + return {} + var names := PackedStringArray() + for suite: McpTestSuite in suites: + names.append(suite.suite_name()) + if names.has(suite_filter): + return {} + var err := ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + ( + "No suite named '%s' is registered (%d discovered)." + + " If it should exist, discovery may be truncated —" + + " re-import and restart the editor (#882)." + ) % [suite_filter, names.size()], + ) + err["error"]["data"] = { + "suite": suite_filter, + "suites_available": Array(names), + "cache_warning": CACHE_WARNING, + } + return err + + +## Map a runner/discovery outcome onto the response envelope. Ownership is +## deliberately here, not in the runner: the runner reports WHAT happened, +## the handler decides how it goes over the wire (plan D2). +func _map_outcome( + outcome: String, + phase: String, + results: Dictionary, + tests_not_run: int, + started_ms: int, + budget_sec: float, +) -> Dictionary: + results["cache_warning"] = CACHE_WARNING + 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, + "cache_warning": CACHE_WARNING, + "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) + var results := _runner.get_results(verbose) + results["cache_warning"] = CACHE_WARNING + return {"data": results} + + +## 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: + ## Name the cause: a fresh reload prints the parse or compile error + ## the cached load swallowed and returns its code, so a CI log says + ## more than "abstract or broken". + var reload_error: Error = script.reload(true) + var base: Variant = script.get_base_script() + errors.append( + "%s (cannot instantiate — abstract or broken; reload=%s, base=%s, valid=%s)" + % [ + file_name, + error_string(reload_error), + str(base.resource_path) if base != null else "none", + str(script.can_instantiate()), + ] + ) + 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..5668890 --- /dev/null +++ b/addons/godot_ai/handlers/texture_handler.gd @@ -0,0 +1,198 @@ +@tool +extends "res://addons/godot_ai/handlers/command_handler.gd" + +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..519989c --- /dev/null +++ b/addons/godot_ai/handlers/theme_handler.gd @@ -0,0 +1,476 @@ +@tool +extends "res://addons/godot_ai/handlers/command_handler.gd" + +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..cc80da4 --- /dev/null +++ b/addons/godot_ai/handlers/tilemap_handler.gd @@ -0,0 +1,163 @@ +@tool +extends "res://addons/godot_ai/handlers/command_handler.gd" + +## 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..f3c5756 --- /dev/null +++ b/addons/godot_ai/handlers/tileset_handler.gd @@ -0,0 +1,178 @@ +@tool +extends "res://addons/godot_ai/handlers/command_handler.gd" + +## 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..f183751 --- /dev/null +++ b/addons/godot_ai/handlers/ui_handler.gd @@ -0,0 +1,525 @@ +@tool +extends "res://addons/godot_ai/handlers/command_handler.gd" + +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..1d90189 --- /dev/null +++ b/addons/godot_ai/mcp_dock.gd @@ -0,0 +1,2849 @@ +@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. +## +## Client workers and update orchestration have plugin lifetime and are owned +## by plugin.gd. This replaceable view emits intents and paints copied +## snapshots/outcomes; detaching it cannot destroy domain work. + +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 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 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" +const RELEASES_PAGE := "https://github.com/hi-godot/godot-ai/releases/latest" +## 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/" +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) + +signal update_requested +signal client_action_requested(client_id: String, action: String) +signal client_status_refresh_requested(client_ids: Array[String], force: bool) +signal status_snapshot_requested +signal live_server_probe_requested(port: int) +signal lifecycle_action_requested(action: int) +signal dev_server_action_requested(action: int) +signal mcp_logging_changed(enabled: bool) +signal log_snapshot_requested(after_sequence: int) +signal plugin_reload_requested(reason: String) +signal settings_apply_requested(changes: Dictionary, reload: bool) +signal post_update_action_requested(action: String) + +enum LifecycleAction { RECOVER_INCOMPATIBLE, RESTART_SERVER } +enum DevServerAction { START_OR_RESTART, STOP } + +## Copied root-owned values only. The Dock never retains Connection, lifecycle, +## plugin, log-buffer, or process owners. +var _transport_snapshot: Dictionary = { + "connected": false, + "server_version": "", + "status": {}, +} +var _lifecycle_snapshot: Dictionary = { + "state": ServerStateScript.UNINITIALIZED, + "server_pid": -1, + "resolved_ws_port": 0, + "can_restart_managed": false, + "can_recover_incompatible": false, + "normal_start_released": false, +} +var _live_server_probe_result: Dictionary = {} + +# Always visible +var _redock_btn: Button +var _status_icon: ColorRect +var _status_label: Label +var _body_scroll: ScrollContainer +var _body: VBoxContainer +var _client_grid: VBoxContainer +var _client_configure_all_btn: Button +var _client_empty_cta_btn: Button +var _clients_summary_label: Label +var _clients_window: Window +var _dev_mode_toggle: CheckButton +var _install_label: Label + +# Tools tab (secondary window, Tab 2) — domain-exclusion UI for clients +# that cap total tool count (Antigravity: 100). Pending set is mutated by +# checkbox clicks; saved set reflects what the spawned server actually +# sees. `Apply and Restart Server` writes pending → setting and triggers a +# plugin reload so the new server comes up with the trimmed list. +var _tools_pending_excluded: PackedStringArray = PackedStringArray() +var _tools_saved_excluded: PackedStringArray = PackedStringArray() +## Custom (addon-registered) tools list — rebuilt live on registry +## tools_changed; per-tool checkboxes apply immediately (no restart). +var _custom_tools_list: VBoxContainer +var _custom_tools_count_label: Label +var _tools_domain_checkboxes: Dictionary = {} +var _tools_count_label: Label +var _tools_apply_btn: Button +var _tools_reset_btn: Button +var _tools_dirty_warning: Label +var _tools_close_confirm: ConfirmationDialog +## Update saves the project and relaunches the editor; the user says so first. +var _update_confirm: ConfirmationDialog +var _update_candidate_version := "" +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_label` 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_label` 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] = [] +## Copied plugin-lifetime client-job state. This contains no Thread, mutex, +## cancellation map, generation, or mutable owner record. +var _client_work_snapshot: Dictionary = { + "accepting_work": false, + "refresh_state": ClientRefreshStateScript.IDLE, + "refresh_completed": false, + "busy_actions": [], + "action_names": {}, + "action_phases": {}, +} +var _update_install_in_flight := false + +# Dev-mode only +var _dev_section: VBoxContainer +var _server_label: Label +var _reload_btn: Button +var _setup_section: VBoxContainer +var _setup_container: VBoxContainer +## Developer controls are a view of the ordinary lifecycle owner. They never +## start a second process topology or kill an externally launched server. +var _dev_primary_btn: Button +## Small "✕" affordance next to the primary — enabled only for the exact +## plugin-owned process grant. +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. Release polling, ZIP download, and +# installation remain root-owned and arrive here as copied presentation state. +var _update_banner: VBoxContainer +var _update_label: Label +var _update_status_label: Label +var _update_btn: Button +## The button is an action, never a status line: progress and failure text +## goes to `_update_status_label`, and the button only enables or disables. +const _UPDATE_ACTION_TEXT := "Update" +const _UPDATE_LABEL_COLOR := Color(1.0, 0.85, 0.3) +var _post_update_action := "" +## True from the moment an update starts its client migration until the +## server it then starts is connected. The transport reads "blocked" for +## that whole window (the migration barrier, then the launch), which is +## not a fault: name the phase instead of alarming the user (#999). +var _post_update_server_pending := false + +func _ready() -> void: + _startup_grace_until_msec = Time.get_ticks_msec() + STARTUP_GRACE_MSEC + _build_ui() + + +func _process(_delta: float) -> void: + _update_status() + if _log_viewer != null and _log_viewer.visible: + log_snapshot_requested.emit(_log_viewer.sequence()) + + +func present_transport_snapshot(snapshot: Dictionary) -> void: + _transport_snapshot = snapshot.duplicate(true) + + +func present_lifecycle_snapshot(snapshot: Dictionary) -> void: + _lifecycle_snapshot = snapshot.duplicate(true) + + +func present_live_server_probe_result(result: Dictionary) -> void: + _live_server_probe_result = result.duplicate(true) + + +func present_log_snapshot(snapshot: Dictionary) -> void: + if _log_viewer != null: + _log_viewer.present_snapshot(snapshot) + + +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() + + +## Godot can leave its shared progress dialog under one of our modal windows. +## Return it before removing the dock: freeing it leaves the editor's pointer dangling. +func release_editor_progress_dialog() -> void: + if not is_inside_tree(): + return + for dialog in find_children("*", "ProgressDialog", true, false): + dialog.reparent(get_tree().root) + + +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_label 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) + + # --- 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", _UPDATE_LABEL_COLOR) + ## 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) + + _update_status_label = Label.new() + _update_status_label.add_theme_font_size_override("font_size", 13) + _update_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _update_status_label.size_flags_horizontal = Control.SIZE_FILL + _update_status_label.custom_minimum_size = Vector2(0, 0) + _update_status_label.visible = false + _update_banner.add_child(_update_status_label) + + var update_btn_row := HBoxContainer.new() + update_btn_row.add_theme_constant_override("separation", 6) + + _update_btn = Button.new() + _update_btn.text = _UPDATE_ACTION_TEXT + _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(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) + + # --- 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_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 + ## Every advertised client uses the authenticated local attach bridge. + 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." + ## Long error messages from `_verify_post_state` (e.g. "reported remove ok + ## but verification still reads configured…") used to push the Retry / + ## Configure button off-screen — the row's Label wanted its full text + ## width as minimum size, so the buttons got squeezed out. Wrap onto + ## multiple lines instead so the row keeps its right edge stable and + ## the buttons remain visible; the user can also read the whole message + ## without resizing the window. + name_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + row.add_child(name_label) + row.add_child(transport_tag) + + var configure_btn := Button.new() + configure_btn.text = "Configure" + configure_btn.pressed.connect(_on_configure_client.bind(client_id)) + row.add_child(configure_btn) + + var remove_btn := Button.new() + remove_btn.text = "Remove" + remove_btn.visible = false + remove_btn.pressed.connect(_on_remove_client.bind(client_id)) + row.add_child(remove_btn) + + # F-3-4: use the authoritative facade so Open/Reveal land on the same + # file `_check_status_merged` drives (last-wins across project tiers, + # matching the F2 status fix). + var config_path := ClientConfigurator.effective_authoritative_path(client_id) + var open_config_btn := Button.new() + _apply_editor_icon(open_config_btn, "ExternalLink", "Open") + open_config_btn.custom_minimum_size = Vector2(28, 28) + open_config_btn.visible = not config_path.is_empty() + open_config_btn.pressed.connect(_on_open_config_file.bind(client_id)) + row.add_child(open_config_btn) + + var reveal_btn := Button.new() + _apply_editor_icon(reveal_btn, "Folder", "Reveal") + reveal_btn.custom_minimum_size = Vector2(28, 28) + reveal_btn.visible = not config_path.is_empty() + reveal_btn.pressed.connect(_on_reveal_config_folder.bind(client_id)) + row.add_child(reveal_btn) + + _client_grid.add_child(row) + + var manual_panel := VBoxContainer.new() + manual_panel.add_theme_constant_override("separation", 4) + manual_panel.visible = false + + var manual_hint := Label.new() + manual_hint.text = "Run this manually:" + manual_hint.add_theme_color_override("font_color", COLOR_MUTED) + manual_panel.add_child(manual_hint) + + var manual_text := TextEdit.new() + manual_text.editable = false + manual_text.custom_minimum_size = Vector2(0, 60) + manual_text.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY + manual_panel.add_child(manual_text) + + var copy_btn := Button.new() + copy_btn.text = "Copy" + copy_btn.pressed.connect(_on_copy_manual_command.bind(client_id)) + manual_panel.add_child(copy_btn) + + _client_grid.add_child(manual_panel) + + _client_rows[client_id] = { + "dot": dot, + "status": Client.Status.NOT_CONFIGURED, + "name_label": name_label, + "configure_btn": configure_btn, + "remove_btn": remove_btn, + "open_config_btn": open_config_btn, + "reveal_btn": reveal_btn, + "config_path": config_path, + "manual_panel": manual_panel, + "manual_text": manual_text, + } + _refresh_client_config_file_buttons(client_id) + + +func _apply_editor_icon(button: Button, icon_name: String, fallback_text: String) -> void: + if has_theme_icon(icon_name, "EditorIcons"): + button.icon = get_theme_icon(icon_name, "EditorIcons") + else: + button.text = fallback_text + + +# --- Status updates --- + +func _update_status() -> void: + ## Signal delivery is synchronous: the composition root copies current + ## transport/lifecycle values into the presentation methods below before + ## this frame renders. If no root is attached, the last snapshot remains. + status_snapshot_requested.emit() + var connected := bool(_transport_snapshot.get("connected", false)) + var transport_status: Dictionary = _transport_snapshot.get("status", {}) + var server_status := _lifecycle_snapshot + var state: int = int(server_status.get("state", ServerStateScript.UNINITIALIZED)) + if ServerStateScript.blocks_client_health(state): + connected = false + if connected: + _post_update_server_pending = 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 bool(server_status.get("handoff_retry_pending", false)): + status_text = "Recovering after update…" + status_color = COLOR_AMBER + 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 _post_update_server_pending: + ## Every terminal spawn failure matched above; what is left is the + ## post-update window where the server is being brought back. + status_text = "Finishing update — starting server…" + status_color = COLOR_AMBER + elif state == ServerStateScript.SPAWNING: + status_text = "Starting server…" + status_color = COLOR_AMBER + 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)) + ) + + ## The picker moves both ports (#647 hid it for a WebSocket-side + ## conflict when it could only move the HTTP port), seeded with the + ## diagnosed conflict so only the contested port changes. + var conflict_port := int(server_status.get("conflict_port", 0)) + var port_picker_visible := ( + state == ServerStateScript.PORT_EXCLUDED or state == ServerStateScript.FOREIGN_PORT + or (state == ServerStateScript.INCOMPATIBLE + and not bool(server_status.get("can_recover_incompatible", false))) + ) + _port_picker_panel.visible = port_picker_visible + if port_picker_visible: + _port_picker_panel.seed_suggested_ports(conflict_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). Both ports reach +## the client's attach command, so clients must be reconfigured afterwards. +## 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 "Suggested ports: %d (HTTP) and %d (WS). Choose both ports below, click Apply + Reload, then Configure your AI clients to use the new pair." % [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] + + +## 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: + mcp_logging_changed.emit(enabled) + + +## Signal handler for the extracted PortPickerPanel. The replaceable Dock emits +## a copied value intent; the composition root owns persistence and reload. +func _on_port_apply_requested(new_http_port: int, new_ws_port: int) -> void: + settings_apply_requested.emit({"http_port": new_http_port, "ws_port": new_ws_port}, true) + + +func _refresh_server_label(server_status: Dictionary = {}) -> void: + if _server_label == null: + return + var ws_port := int(_lifecycle_snapshot.get("resolved_ws_port", 0)) + if ws_port <= 0: + ws_port = ClientConfigurator.ws_port() + var text := "WS: %d HTTP: %d" % [ws_port, ClientConfigurator.http_port()] + if server_status.is_empty(): + server_status = _lifecycle_snapshot + var ownership := _server_ownership_tag( + int(server_status.get("state", ServerStateScript.UNINITIALIZED)), + int(server_status.get("server_pid", -1)), + ) + 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" + + +static func _client_transport_tag(client_id: String) -> String: + var client := ClientRegistry.get_by_id(client_id) + if client == null: + return "" + return "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 env_disabled = _is_telemetry_disabled_via_env() + + var enabled: bool + if env_disabled != null: + ## Environment policy controls this activation but does not rewrite the + ## user's persisted preference. + enabled = not bool(env_disabled) + else: + ## Defaults are registered by the composition root before Dock creation. + var es := EditorInterface.get_editor_settings() + if es != null and es.has_setting(McpSettings.SETTING_TELEMETRY_ENABLED): + enabled = bool(es.get_setting(McpSettings.SETTING_TELEMETRY_ENABLED)) + else: + 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 = _live_telemetry_tooltip(enabled) + + +func _on_telemetry_toggled(pressed: bool) -> void: + _telemetry_pending_enabled = pressed + if _telemetry_toggle != null: + _telemetry_toggle.tooltip_text = _live_telemetry_tooltip(pressed) + _refresh_tools_ui_state() + + +## Report the running server's telemetry state, not just this editor's +## checkbox, so the two can never silently disagree (#913). Applying an +## opt-out reaches the live server over the authenticated WebSocket and +## latches there, so both directions of a disagreement are real states worth +## explaining — not just the unreachable one. +func _live_telemetry_tooltip(local_enabled: bool) -> String: + _live_server_probe_result = {} + live_server_probe_requested.emit(ClientConfigurator.http_port()) + var live := _live_server_probe_result + if not (live.get("telemetry_enabled") is bool): + ## Absent means "too old to publish it", not false. Say nothing. + return "" + var server_enabled: bool = live.get("telemetry_enabled") + if server_enabled == local_enabled: + return "Running server telemetry is %s." % ("on" if server_enabled else "off") + if local_enabled: + return ( + "The running server has telemetry off and will stay off until it is " + + "replaced. An opt-out — this editor's earlier one, another editor " + + "sharing this server, or GODOT_AI_DISABLE_TELEMETRY in its " + + "environment — latched it. Restart the server to apply this." + ) + return ( + "The running server still has telemetry on. Applying sends it an " + + "opt-out over the authenticated connection, which it honors for the " + + "rest of its life." + ) + + +# --- 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_label` + # 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_label` 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 state := int(_lifecycle_snapshot.get("state", ServerStateScript.UNINITIALIZED)) + if ServerStateScript.is_terminal_diagnosis(state): + return false + return Time.get_ticks_msec() < _startup_grace_until_msec + + +# --- Button handlers --- + + +func _on_reload_plugin() -> void: + plugin_reload_requested.emit("dock_button") + + +## 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(): + server_status = _lifecycle_snapshot + var server_ver := str(_transport_snapshot.get("server_version", "")) + 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(server_status.get("can_restart_managed", false)) + 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 _server_restart_in_progress: + return + _server_restart_in_progress = true + _last_rendered_server_text = "" + _refresh_server_version_label() + if not is_inside_tree(): + _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 _dispatch_stale_server_restart(): + _server_restart_in_progress = false + _last_rendered_server_text = "" + _refresh_server_version_label() + + +func _dispatch_stale_server_restart() -> bool: + if int(_lifecycle_snapshot.get("state", ServerStateScript.UNINITIALIZED)) == ServerStateScript.INCOMPATIBLE: + lifecycle_action_requested.emit(LifecycleAction.RECOVER_INCOMPATIBLE) + return true + if not bool(_lifecycle_snapshot.get("can_restart_managed", false)): + return false + lifecycle_action_requested.emit(LifecycleAction.RESTART_SERVER) + return true + + +## Root acknowledgement for an asynchronous lifecycle intent. Successful +## restarts stay busy until the next lifecycle/transport snapshot settles; +## rejection restores the controls immediately. +func present_lifecycle_action_result(accepted: bool) -> void: + if accepted: + return + _server_restart_in_progress = false + _last_rendered_server_text = "" + _refresh_server_version_label() + + +# --- 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 Managed 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 exact plugin-owned server." + _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 view of the lifecycle's process authority. "External" means transport +## may be adopted, but this plugin has no right to stop its launcher. +static func _dev_primary_btn_state(managed: bool, external: bool) -> Dictionary: + var port := ClientConfigurator.http_port() + if managed: + return { + "text": "Restart Managed Server", + "enabled": true, + "tooltip": "Restart the exact plugin-owned server on port %d." % port, + } + if external: + return { + "text": "External Server Running", + "enabled": false, + "tooltip": "Stop the server on port %d from the process that launched it." % port, + } + return { + "text": "Start Managed Server", + "enabled": true, + "tooltip": "Start a plugin-owned server on port %d from the current checkout." % port, + } + + +static func _dev_stop_btn_state(managed: bool) -> Dictionary: + if managed: + return {"enabled": true, "tooltip": "Stop the exact plugin-owned server."} + return {"enabled": false, "tooltip": "No plugin-owned server to stop."} + + +func _on_dev_primary_pressed() -> void: + if _server_restart_in_progress: + return + _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`. + dev_server_action_requested.emit(DevServerAction.START_OR_RESTART) + _server_restart_in_progress = false + return + call_deferred("_perform_dev_restart_after_feedback") + + +func _on_dev_stop_pressed() -> void: + dev_server_action_requested.emit(DevServerAction.STOP) + _update_dev_section_buttons.call_deferred() + + +func _perform_dev_restart_after_feedback() -> void: + ## Brief paint cycle so the user sees "Restarting..." before dispatch. + await get_tree().create_timer(0.15).timeout + dev_server_action_requested.emit(DevServerAction.START_OR_RESTART) + ## Lifecycle snapshots normally repaint first; this is a bounded UI fallback. + await get_tree().create_timer(2.0).timeout + _server_restart_in_progress = false + _update_dev_section_buttons() + + +## Derive both buttons from the already-copied lifecycle value. No second port +## scan or dev-only ownership state exists. +func _update_dev_section_buttons() -> void: + var managed := int(_lifecycle_snapshot.get("server_pid", -1)) > 1 + var normal_start_released := bool( + _lifecycle_snapshot.get("normal_start_released", false) + ) + var state := int(_lifecycle_snapshot.get("state", ServerStateScript.UNINITIALIZED)) + var external := not managed and ( + str(_lifecycle_snapshot.get("ready_kind", "")) == "adopted" + or state in [ServerStateScript.INCOMPATIBLE, ServerStateScript.FOREIGN_PORT] + ) + 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 = "Restarting the plugin-owned server..." + elif not normal_start_released: + _dev_primary_btn.disabled = true + _dev_primary_btn.text = "Server Start Blocked" + _dev_primary_btn.tooltip_text = ( + "Complete post-update client migration before starting the server." + ) + else: + var primary_state := _dev_primary_btn_state(managed, external) + _dev_primary_btn.disabled = not bool(primary_state["enabled"]) + _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(managed) + _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 bool(_client_work_snapshot.get("refresh_completed", false)) + + +func _client_refresh_state() -> int: + return int(_client_work_snapshot.get("refresh_state", ClientRefreshStateScript.IDLE)) + + +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: + ## Configure writes an explicit url + live plugin version; it does not + ## need a healthy occupant. INCOMPATIBLE only suppresses status + ## interpretation (#916). + _dispatch_client_action(client_id, "configure") + + +func _on_remove_client(client_id: String) -> void: + _dispatch_client_action(client_id, "remove") + + +## Emit a value intent; plugin.gd routes it to the plugin-lifetime job owner. +## The Dock updates only view-local button state and never holds a Thread. +func _dispatch_client_action(client_id: String, action: String) -> void: + if _is_self_update_in_progress(): + return + if _busy_client_actions().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) + client_action_requested.emit(client_id, action) + + +func present_client_action_result( + client_id: String, + action: String, + result: Dictionary, + prewarm: Dictionary = {}, +) -> void: + _report_prewarm_outcome(client_id, prewarm) + _finalize_action_buttons(client_id) + var success_status := Client.Status.NOT_CONFIGURED if action == "remove" else Client.Status.CONFIGURED + if result.get("status") == "ok": + ## #877: Remove targets only the selected scope, so a configure is the + ## only action with an all-scope sweep to disclose. The manual panel + ## that lists those removes is shown on the failure path below, which + ## left the success path — where the sweep actually ran — silent. + var sweep_note := ( + ClientConfigurator.configure_sweep_note(client_id) if action == "configure" else "" + ) + _apply_row_status(client_id, success_status, sweep_note) + var row: Dictionary = _client_rows.get(client_id, {}) + if not row.is_empty(): + (row["manual_panel"] as VBoxContainer).visible = false + else: + _apply_row_status(client_id, Client.Status.ERROR, str(result.get("message", "failed"))) + if action == "configure": + _show_manual_command_for(client_id) + _refresh_clients_summary() + + +func present_client_action_timeout(client_id: String, _action: String, detail: String) -> void: + _apply_row_status(client_id, Client.Status.ERROR, detail) + _refresh_clients_summary() + + +func present_client_work_snapshot(snapshot: Dictionary) -> void: + _client_work_snapshot = snapshot.duplicate(true) + var busy := _busy_client_actions() + var names: Dictionary = _client_work_snapshot.get("action_names", {}) + var phases: Dictionary = _client_work_snapshot.get("action_phases", {}) + for client_id in _client_rows: + var id := String(client_id) + if busy.has(id): + _set_row_action_in_flight(id, String(names.get(id, "configure"))) + if String(phases.get(id, "")) == "prewarm": + (_client_rows[id]["configure_btn"] as Button).text = "Installing…" + else: + _finalize_action_buttons(id) + _refresh_clients_summary() + + +func _busy_client_actions() -> Array[String]: + var result: Array[String] = [] + result.assign(_client_work_snapshot.get("busy_actions", [])) + return result + + +## One line per Configure so a cold build is attributable after the fact — +## the dock label is transient, and #851's symptom (a terminal window on +## Windows) is easiest to correlate against a timestamped log entry. Silent +## on the skip paths: a dev-venv/system tier having no env to warm is the +## normal case, not news. +func _report_prewarm_outcome(client_id: String, prewarm: Variant) -> void: + if not (prewarm is Dictionary): + return + var data := prewarm as Dictionary + if data.is_empty() or bool(data.get("skipped", false)): + return + if bool(data.get("timed_out", false)): + print( + "MCP | %s: package pre-warm timed out; the first client launch will build the environment" + % client_id + ) + return + if int(data.get("exit_code", -1)) != 0: + print( + "MCP | %s: package pre-warm failed (exit %d); the first client launch will build the environment" + % [client_id, int(data.get("exit_code", -1))] + ) + return + print("MCP | %s: package environment pre-warmed" % client_id) + + +## 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. +## Restore labels from cached row status as well as enabling the controls. +## This matters when a timed-out orphan finishes: the owner publishes a new +## snapshot but has no action result to repaint the row for us. +func _finalize_action_buttons(client_id: String) -> void: + var row: Dictionary = _client_rows.get(client_id, {}) + if row.is_empty(): + return + var configure_btn := row["configure_btn"] as Button + configure_btn.disabled = false + match row.get("status", Client.Status.NOT_CONFIGURED): + Client.Status.CONFIGURED, Client.Status.CONFIGURED_MISMATCH: + configure_btn.text = "Reconfigure" + Client.Status.NOT_CONFIGURED: + configure_btn.text = "Configure" + _: + configure_btn.text = "Retry" + 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: + ## Per-row Configure already bypasses the RUNNING gate. INCOMPATIBLE + ## skips health interpretation (#916), so Configure all must do the same + ## even if a status sweep is still in flight — `_set_incompatible_server` + ## does not reset `_refresh_state`. + if ( + ClientRefreshStateScript.should_disable_client_actions(_client_refresh_state()) + and not _server_blocks_client_health() + ): + return + for client_id in _client_rows: + var status: Client.Status = _client_rows[client_id].get("status", Client.Status.NOT_CONFIGURED) + if status == Client.Status.CONFIGURED: + continue + _on_configure_client(String(client_id)) + _refresh_clients_summary() + + +func _on_open_clients_window() -> void: + if _clients_window == null: + return + ## Re-sweep before the user has time to act on stale dot colors. The request + ## is async/stale-while-refreshing so the popup paints immediately with + ## last-known state; the fresh colors land when the background worker returns. + ## This is an explicit user action, so it bypasses the focus-in cooldown. + _request_client_status_refresh(true) + ## Also re-sync the Tools tab from the persisted setting — another + ## editor instance (or a hand-edit of editor_settings-4.tres) may have + ## changed the excluded list while the window was closed. + _reset_tools_pending_from_setting() + _refresh_tools_ui_state() + if vision_routing != null: + vision_routing.refresh_ui() + # popup_centered() with a minsize forces the window to that size and + # centers on the parent viewport. Setting .size on a hidden Window + # doesn't always take effect, so we force it at popup time here. + _clients_window.popup_centered(Vector2i(640, 600)) + + +func _settings_are_dirty() -> bool: + return ( + _tools_pending_excluded != _tools_saved_excluded + or _telemetry_pending_enabled != _telemetry_saved_enabled + or _allow_hosts_is_dirty() + ) + + +func _on_clients_window_close_requested() -> void: + if _clients_window == null: + return + ## If the user has unapplied settings, a close would silently throw the + ## pending state away. Prompt before discarding current options and if + ## they confirm, reset pending → saved so the window shows the persisted + ## state the next time they open it. + if _settings_are_dirty(): + _show_tools_close_confirm() + return + _clients_window.hide() + + +# --- Tools tab (domain exclusion) --- + +func _build_tools_tab(tabs: TabContainer) -> void: + ## Tab 2 — domain-exclusion checkboxes. Rendered once, on dock construction. + ## `_reset_tools_pending_from_setting()` re-syncs checkbox state from the + ## saved setting each time the window opens. + var tools_tab := VBoxContainer.new() + tools_tab.add_theme_constant_override("separation", 8) + var tools_margin := _build_margin_container() + tools_margin.name = "Tools" + tools_margin.add_child(tools_tab) + tabs.add_child(tools_margin) + + var intro := Label.new() + intro.text = ( + "Some MCP clients cap tools per connection (Antigravity: 100). " + + "Uncheck a domain to drop its non-core tools from this server. " + + "Core tools stay on. Changes require a server restart." + ) + intro.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + intro.add_theme_color_override("font_color", COLOR_MUTED) + intro.size_flags_horizontal = Control.SIZE_EXPAND_FILL + tools_tab.add_child(intro) + + var count_row := HBoxContainer.new() + count_row.add_theme_constant_override("separation", 8) + var count_header := Label.new() + count_header.text = "Tools Enabled:" + count_header.add_theme_color_override("font_color", COLOR_MUTED) + count_row.add_child(count_header) + _tools_count_label = Label.new() + _tools_count_label.add_theme_font_size_override("font_size", 15) + count_row.add_child(_tools_count_label) + _tools_dirty_warning = Label.new() + _tools_dirty_warning.add_theme_color_override("font_color", COLOR_AMBER) + _tools_dirty_warning.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _tools_dirty_warning.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + _tools_dirty_warning.visible = false + _tools_dirty_warning.text = "Unapplied changes" + count_row.add_child(_tools_dirty_warning) + tools_tab.add_child(count_row) + + tools_tab.add_child(HSeparator.new()) + + var scroll := ScrollContainer.new() + scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL + scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + tools_tab.add_child(scroll) + + var grid := VBoxContainer.new() + grid.add_theme_constant_override("separation", 4) + grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL + scroll.add_child(grid) + + ## Core pseudo-row — disabled checkbox, always checked. Shows the 5 + ## always-loaded tools as a single line item so the user can see where + ## their baseline tool budget goes without listing individual core names + ## inline (tooltip has them). + var core_row := HBoxContainer.new() + core_row.add_theme_constant_override("separation", 8) + var core_chk := CheckBox.new() + core_chk.button_pressed = true + core_chk.disabled = true + core_chk.focus_mode = Control.FOCUS_NONE + core_row.add_child(core_chk) + var core_label := Label.new() + core_label.text = "Core (always on)" + core_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + core_row.add_child(core_label) + var core_count := Label.new() + core_count.text = "%d tools" % (ToolCatalog.CORE_TOOLS.size() + ToolCatalog.ALWAYS_ON_TOOLS.size()) + core_count.add_theme_color_override("font_color", COLOR_MUTED) + core_row.add_child(core_count) + core_row.tooltip_text = "%s · always on: %s" % [ + ", ".join(ToolCatalog.CORE_TOOLS), + ", ".join(ToolCatalog.ALWAYS_ON_TOOLS), + ] + grid.add_child(core_row) + + grid.add_child(HSeparator.new()) + + _tools_domain_checkboxes.clear() + for entry in ToolCatalog.DOMAINS: + _build_tools_domain_row(grid, entry) + + ## Custom (addon-registered) tools — unlike the domain rows above, + ## toggles apply LIVE: the registry re-pushes the filtered catalog to + ## the server on every change, no restart needed. + grid.add_child(HSeparator.new()) + var custom_header_row := HBoxContainer.new() + custom_header_row.add_theme_constant_override("separation", 8) + var custom_header := Label.new() + custom_header.text = "Custom tools (addon-registered)" + custom_header.size_flags_horizontal = Control.SIZE_EXPAND_FILL + custom_header_row.add_child(custom_header) + _custom_tools_count_label = Label.new() + _custom_tools_count_label.add_theme_color_override("font_color", COLOR_MUTED) + custom_header_row.add_child(_custom_tools_count_label) + grid.add_child(custom_header_row) + var custom_hint := Label.new() + custom_hint.text = "Applies immediately — disabled tools are hidden from agents and rejected if called." + custom_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + custom_hint.add_theme_color_override("font_color", COLOR_MUTED) + custom_hint.size_flags_horizontal = Control.SIZE_EXPAND_FILL + grid.add_child(custom_hint) + _custom_tools_list = VBoxContainer.new() + _custom_tools_list.add_theme_constant_override("separation", 4) + _custom_tools_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL + grid.add_child(_custom_tools_list) + _connect_custom_tool_registry() + _refresh_custom_tools_rows() + + tools_tab.add_child(HSeparator.new()) + + var telemetry_row := HBoxContainer.new() + telemetry_row.add_theme_constant_override("separation", 8) + var telemetry_label := Label.new() + telemetry_label.text = "Telemetry" + telemetry_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + telemetry_row.add_child(telemetry_label) + _telemetry_toggle = CheckButton.new() + _telemetry_toggle.toggled.connect(_on_telemetry_toggled) + telemetry_row.add_child(_telemetry_toggle) + tools_tab.add_child(telemetry_row) + + tools_tab.add_child(HSeparator.new()) + + var footer := HBoxContainer.new() + footer.add_theme_constant_override("separation", 8) + + _tools_apply_btn = Button.new() + _tools_apply_btn.text = "Apply and Restart Server" + _tools_apply_btn.tooltip_text = "Save the excluded list to Editor Settings and reload the plugin so the server respawns with --exclude-domains." + _tools_apply_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _tools_apply_btn.pressed.connect(_on_tools_apply) + footer.add_child(_tools_apply_btn) + + _tools_reset_btn = Button.new() + _tools_reset_btn.text = "Reset to defaults" + _tools_reset_btn.tooltip_text = "Re-enable every domain (no --exclude-domains flag). Still needs Apply." + _tools_reset_btn.pressed.connect(_on_tools_reset) + footer.add_child(_tools_reset_btn) + + tools_tab.add_child(footer) + + _tools_close_confirm = ConfirmationDialog.new() + _tools_close_confirm.title = "Discard unapplied changes?" + ## Generic wording: _settings_are_dirty() covers domain toggles, the + ## telemetry switch, AND the Settings tab's allow-host field (#507) — + ## the old "checked/unchecked domains" text misled non-domain edits. + _tools_close_confirm.dialog_text = ( + "You have unapplied changes in this window.\n" + + "Close it and discard those changes?" + ) + _tools_close_confirm.ok_button_text = "Discard" + _tools_close_confirm.confirmed.connect(_on_tools_discard_confirmed) + add_child(_tools_close_confirm) + + _update_confirm = ConfirmationDialog.new() + _update_confirm.title = "Update Godot AI?" + _update_confirm.ok_button_text = "Update plugin" + _update_confirm.cancel_button_text = "Later" + _update_confirm.confirmed.connect(_on_update_confirmed) + add_child(_update_confirm) + + _reset_tools_pending_from_setting() + _refresh_tools_ui_state() + + +## --- Custom (addon-registered) tools section --- + +func _connect_custom_tool_registry() -> void: + var registry := McpToolRegistry.get_instance() + if registry == null: + return + if not registry.tools_changed.is_connected(_on_custom_tool_registry_changed): + registry.tools_changed.connect(_on_custom_tool_registry_changed) + + +func _on_custom_tool_registry_changed() -> void: + ## Deferred: tools_changed can fire from inside a checkbox toggle in + ## this very list — rebuilding synchronously would free the emitting + ## control mid-signal. + _refresh_custom_tools_rows.call_deferred() + + +func _refresh_custom_tools_rows() -> void: + if _custom_tools_list == null or not is_instance_valid(_custom_tools_list): + return + for child in _custom_tools_list.get_children(): + child.queue_free() + var registry := McpToolRegistry.get_instance() + var specs: Array = [] if registry == null else registry.all() + specs.sort_custom(func(a, b): return String(a.name) < String(b.name)) + var enabled_count := 0 + for spec in specs: + if registry.is_tool_enabled(spec.name): + enabled_count += 1 + _custom_tools_list.add_child(_build_custom_tool_row(registry, spec)) + if _custom_tools_count_label != null and is_instance_valid(_custom_tools_count_label): + _custom_tools_count_label.text = "%d/%d enabled" % [enabled_count, specs.size()] + if specs.is_empty(): + var empty := Label.new() + empty.text = "None registered. Addons add tools via McpToolRegistry — see docs/plugin-architecture.md." + empty.add_theme_color_override("font_color", COLOR_MUTED) + empty.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + empty.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _custom_tools_list.add_child(empty) + + +func _build_custom_tool_row(registry: McpToolRegistry, spec: McpCustomToolSpec) -> HBoxContainer: + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 8) + var chk := CheckBox.new() + chk.button_pressed = registry.is_tool_enabled(spec.name) + chk.toggled.connect(func(pressed: bool): registry.set_tool_enabled(spec.name, pressed)) + row.add_child(chk) + var name_label := Label.new() + name_label.text = spec.name + (" · promoted" if spec.promoted else "") + name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(name_label) + var source_label := Label.new() + source_label.text = spec.source if not spec.source.is_empty() else spec.source_path.get_base_dir().get_file() + source_label.add_theme_color_override("font_color", COLOR_MUTED) + row.add_child(source_label) + ## Tooltip = the agent-facing description plus provenance, so the user + ## can judge what they're enabling without leaving the dock. + row.tooltip_text = "%s\n\nsource: %s%s" % [ + spec.description, + spec.source_path, + "\npromoted: registers as first-class MCP tool custom_%s" % spec.name if spec.promoted else "", + ] + name_label.tooltip_text = row.tooltip_text + return row + + +func _build_tools_domain_row(parent: VBoxContainer, entry: Dictionary) -> void: + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 8) + + var chk := CheckBox.new() + chk.button_pressed = true # default; `_reset_tools_pending_from_setting` corrects + chk.toggled.connect(_on_tools_domain_toggled.bind(String(entry["id"]))) + row.add_child(chk) + + var name_label := Label.new() + name_label.text = String(entry["label"]) + name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(name_label) + + var count_label := Label.new() + count_label.text = "%d tools" % int(entry["count"]) + count_label.add_theme_color_override("font_color", COLOR_MUTED) + row.add_child(count_label) + + ## Hover tooltip = flat list of tool names in this domain. Lets the + ## user decide without leaving the dock (e.g. "I just want to drop + ## `animation_preset_*` — do I lose anything else?"). + var tools_list: Array = entry.get("tools", []) + row.tooltip_text = ", ".join(tools_list) + name_label.tooltip_text = row.tooltip_text + count_label.tooltip_text = row.tooltip_text + + parent.add_child(row) + _tools_domain_checkboxes[String(entry["id"])] = chk + + +func _reset_tools_pending_from_setting() -> void: + ## Read the saved setting → pending/saved arrays, then sync checkbox state. + ## Unknown domain names in the setting (e.g. from an older plugin + ## version) are dropped from the display here (only ids with a checkbox + ## survive). The startup path is protected separately: + ## `ClientConfigurator.excluded_domains()` filters unknown names before + ## they reach `--exclude-domains`, whose `parse_exclude_list` hard-fails + ## on them. + var saved_raw := ClientConfigurator.excluded_domains() + var saved := PackedStringArray() + if not saved_raw.is_empty(): + for part in saved_raw.split(","): + var t := part.strip_edges() + if t.is_empty(): + continue + if _tools_domain_checkboxes.has(t) and saved.find(t) == -1: + saved.append(t) + saved.sort() + _tools_saved_excluded = saved + _tools_pending_excluded = saved.duplicate() + for id in _tools_domain_checkboxes: + var chk: CheckBox = _tools_domain_checkboxes[id] + ## `set_pressed_no_signal` — mutating programmatically should not + ## fire the toggled handler, which would mutate pending back. + chk.set_pressed_no_signal(_tools_pending_excluded.find(id) == -1) + ## Also reset telemetry pending state from the persisted setting. + if _telemetry_toggle != null: + _load_telemetry_setting() + ## And the Settings tab's allow-host field (#507) — same window-open / + ## discard-confirm re-sync contract as the tools checkboxes. + _reset_allow_hosts_from_setting() + + +func _on_tools_domain_toggled(pressed: bool, domain_id: String) -> void: + var idx := _tools_pending_excluded.find(domain_id) + if pressed and idx != -1: + _tools_pending_excluded.remove_at(idx) + elif not pressed and idx == -1: + _tools_pending_excluded.append(domain_id) + _tools_pending_excluded.sort() + _refresh_tools_ui_state() + + +func _refresh_tools_ui_state() -> void: + if _tools_count_label == null: + return + var enabled := ToolCatalog.enabled_tool_count(_tools_pending_excluded) + var total := ToolCatalog.total_tool_count() + _tools_count_label.text = "%d / %d" % [enabled, total] + var dirty := _settings_are_dirty() + _tools_dirty_warning.visible = dirty + _tools_apply_btn.disabled = not dirty + ## Color the count when the user is over Antigravity's cap — a soft + ## signal that their selection still won't fit. 100 is the Antigravity + ## limit; other clients may cap higher, so this is advisory only. + if enabled > 100: + _tools_count_label.add_theme_color_override("font_color", COLOR_AMBER) + else: + _tools_count_label.remove_theme_color_override("font_color") + + +func _on_tools_apply() -> void: + var canonical_excluded := ToolCatalog.canonical(_tools_pending_excluded) + _tools_saved_excluded = _tools_pending_excluded.duplicate() + _telemetry_saved_enabled = _telemetry_pending_enabled + _refresh_tools_ui_state() + settings_apply_requested.emit({ + "excluded_domains": canonical_excluded, + "telemetry_enabled": _telemetry_pending_enabled, + }.duplicate(true), true) + + +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 + _allow_hosts_saved = normalized + _allow_hosts_edit.text = normalized + _refresh_allow_hosts_ui_state() + settings_apply_requested.emit({"allow_hosts": normalized}, true) + + +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() + var refresh_state := _client_refresh_state() + 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) + and not _server_blocks_client_health() + ) + 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: + _request_client_status_refresh(true) + + +func _perform_initial_client_status_refresh() -> void: + if not is_inside_tree(): + return + _request_client_status_refresh(false) + + +func _request_client_status_refresh(force: bool = false) -> bool: + if _server_blocks_client_health(): + return false + if _is_self_update_in_progress() or _client_rows.is_empty(): + return false + var ids: Array[String] = [] + for client_id in _client_rows: + ids.append(String(client_id)) + client_status_refresh_requested.emit(ids, force) + return true + + +func present_client_status_refresh_results(results: Dictionary) -> void: + if _server_blocks_client_health(): + return + var busy := _busy_client_actions() + for client_id in results: + if busy.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) + ) + _refresh_clients_summary() + + +func _server_blocks_client_health() -> bool: + return ServerStateScript.blocks_client_health( + int(_lifecycle_snapshot.get("state", ServerStateScript.UNINITIALIZED)) + ) + + +func _server_blocked_client_message() -> String: + var message := str(_lifecycle_snapshot.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 + ## `error_msg` doubles as a detail slot on the green path: a + ## successful configure passes the sweep note (#877). Transient by + ## design — the next status refresh re-applies CONFIGURED with no + ## detail, so the row settles back to its plain name. + name_label.text = ( + "%s (%s)" % [base_name, error_msg] if not error_msg.is_empty() else base_name + ) + Client.Status.NOT_CONFIGURED: + dot.color = COLOR_MUTED + configure_btn.text = "Configure" + remove_btn.visible = false + var installed: bool = installed_override if installed_override != null else ClientConfigurator.is_installed(client_id) + name_label.text = base_name if installed else "%s (not detected)" % base_name + Client.Status.CONFIGURED_MISMATCH: + ## Amber matches the dock-level drift banner so a glance at the + ## row + the banner read as the same condition. + dot.color = COLOR_AMBER + configure_btn.text = "Reconfigure" + remove_btn.visible = true + ## Drift is usually a stale URL, but a `{scope}` client can also be + ## registered in a scope the user did not select (#872), and + ## "URL out of date" would be a wrong description of that. Prefer + ## the probe's own words whenever it supplied any. + name_label.text = ( + "%s (%s)" % [base_name, error_msg] + if not error_msg.is_empty() + else "%s (URL out of date)" % base_name + ) + _: + dot.color = Color.RED + configure_btn.text = "Retry" + remove_btn.visible = false + name_label.text = "%s — %s" % [base_name, error_msg] if not error_msg.is_empty() else base_name + + +func _refresh_client_config_file_buttons(client_id: String) -> void: + var row: Dictionary = _client_rows.get(client_id, {}) + if row.is_empty(): + return + # Re-resolve on every refresh so the Open/Reveal buttons follow the entry + # when it lands in (or moves to) a higher-precedence merge tier (codex F3). + # F-3-4: switched from `effective_config_path` to the authoritative facade + # so multi-project-tier cases resolve to the LATEST tier (matching F2 + # status semantics) instead of failing closed to `path_template`. + row["config_path"] = ClientConfigurator.effective_authoritative_path(client_id) + var config_path := String(row["config_path"]) + var has_path := not config_path.is_empty() + var open_config_btn: Button = row["open_config_btn"] + var reveal_btn: Button = row["reveal_btn"] + open_config_btn.visible = has_path + reveal_btn.visible = has_path + open_config_btn.disabled = not has_path + reveal_btn.disabled = not has_path + if has_path: + open_config_btn.tooltip_text = "Open config file:\n%s" % config_path + reveal_btn.tooltip_text = "Reveal in folder:\n%s" % config_path.get_base_dir() + else: + open_config_btn.tooltip_text = "" + reveal_btn.tooltip_text = "" + + +# --- Update check & self-update --- + +## Tolerates a null manager so test fixtures that build the dock without +## `_build_ui()` don't false-positive on the worker-spawn gate. +func _is_self_update_in_progress() -> bool: + return _update_install_in_flight + + +func _on_update_pressed() -> void: + if not _post_update_action.is_empty(): + post_update_action_requested.emit(_post_update_action) + return + ## Updating briefly disconnects AI tools while the add-on is replaced. + ## A dock outside the scene tree has no dialog and proceeds directly. + if _update_confirm != null and is_inside_tree(): + _update_confirm.dialog_text = update_confirm_text( + _update_candidate_version, ClientConfigurator.get_plugin_version() + ) + _update_confirm.popup_centered() + return + update_requested.emit() + + +func _on_update_confirmed() -> void: + update_requested.emit() + + +static func update_confirm_text(version: String, current_version: String) -> String: + var target := "Godot AI v%s" % version if not version.is_empty() else "the new Godot AI" + var text := "Update to %s? Unsaved changes are kept." % target + if McpServerVersionCheck.attached_bridges_follow(current_version, version): + text += "\n\nRestart AI clients older than v4.0.4." + else: + text += "\n\nRestart your AI client after updating." + return text + + +func present_update_check(result: Dictionary) -> void: + _update_candidate_version = String(result.get("version", "")) + _update_label.text = String(result.get("label_text", "")) + _update_label.add_theme_color_override("font_color", _UPDATE_LABEL_COLOR) + _update_banner.visible = true + ## A fresh candidate re-arms the action. The restarted editor after an + ## update shows "Godot AI installed" with the button disabled; a newer release + ## found later in that same session must still be installable. A running + ## install or a pending post-update action keeps ownership of the button. + if _update_install_in_flight or not _post_update_action.is_empty(): + return + _set_update_status("") + if _update_btn != null: + _update_btn.text = _UPDATE_ACTION_TEXT + _update_btn.disabled = false + + +## Apply only the keys present so the manager can ship partial updates +## (e.g. status-only during the download phase) without clobbering banner +## state. `button_text` names an action ("Retry client migration"); +## progress and failure messages arrive as `status_text` and never replace +## the button label. +func present_update_state(state: Dictionary) -> void: + if state.has("post_update_action"): + _post_update_action = String(state["post_update_action"]) + if _post_update_action.is_empty() and _update_btn != null: + _update_btn.text = _UPDATE_ACTION_TEXT + if state.has("install_in_flight"): + _update_install_in_flight = bool(state["install_in_flight"]) + if String(state.get("post_update_action", "")) == "retry": + ## The migration barrier refused: the connection really is blocked. + _post_update_server_pending = false + elif bool(state.get("install_in_flight", false)) or String(state.get("outcome", "")) == "success": + _post_update_server_pending = true + if _status_label != null: + _update_status() + elif state.has("install_in_flight"): + ## The install ended without a swap (`_fail_update`): the previous + ## version is live and the transport status is the truth again. + _post_update_server_pending = false + if _status_label != null: + _update_status() + 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("status_text"): + _set_update_status(String(state["status_text"])) + 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 state.has("label_text") and _update_label != null: + ## Installation is distinct from server/client readiness. Instructions + ## can still name failed migrations or clients that must reconnect. + _update_label.add_theme_color_override("font_color", _UPDATE_LABEL_COLOR) + + +func _set_update_status(text: String) -> void: + if _update_status_label == null: + return + _update_status_label.text = text + _update_status_label.visible = not text.is_empty() 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/migration_bridge.gd b/addons/godot_ai/migration_bridge.gd index 50874db..7783bc2 100644 --- a/addons/godot_ai/migration_bridge.gd +++ b/addons/godot_ai/migration_bridge.gd @@ -1,212 +1,8 @@ @tool extends Node -## Verifies and stages the embedded canonical v4 release while the migration -## capsule is still the enabled plugin. Every step runs in GDScript on the -## editor's main thread through the same installer scripts the v4 tree ships -## (`utils/release_verifier.gd`, `utils/update_installer.gd`); nothing here -## spawns a process, and nothing here writes a live add-on file. - -const PAYLOAD_ROOT := "res://addons/godot_ai/migration_payload" -const ARCHIVE_NAME := "godot-ai-v4-plugin.zip" -const MANIFEST_NAME := "godot-ai-v4-plugin.manifest.json" -const SIGNATURE_NAME := "godot-ai-v4-plugin.manifest.sig" -const VERIFIER_SCRIPT := "res://addons/godot_ai/utils/release_verifier.gd" -const INSTALLER_SCRIPT := "res://addons/godot_ai/utils/update_installer.gd" -const REPOSITORY := "hi-godot/godot-ai" -const CHANNEL := "stable" -const MAX_MANIFEST_BYTES := 1024 * 1024 -const SIGNATURE_BYTES := 512 -const PENDING_V3_UPDATE_SETTING := "godot_ai/pending_self_update_event" -const INCOMPLETE_CAPSULE := ( - "The migration capsule is incomplete (%s). Download the release again, then click Retry migration." -) - -## Kept byte-identical to `utils/update_manager.gd::RELEASE_SIGNING_PUBLIC_KEY_PEM` -## in the v4 tree; `tests/unit/test_release_support.py` pins the two copies -## together. The capsule cannot preload the v4 updater to read it there. -const RELEASE_SIGNING_PUBLIC_KEY_PEM := """-----BEGIN PUBLIC KEY----- -MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAr4OmbONFTONGFcXSUQ2p -e54YaUhWDA75wxeDWhOc476vsdo53YnXEFT7EPr2hUKqeNxv++LqKOkFuAsxSNZy -wBe6P1tmQA4Og6Ezv4CGnZdEj1uhlDJFK9ShQ29oWfC6bf/84625SvvBxZos2Br9 -yPKl7h5wzqDoeUSpv+f0ynTiC0i/HAUo/NQBlkgGwkomK2Fr3pP1VDxxq2xvgHSk -lU6Qcomr9WjJxI+HkDN5tRPPn0pDrg6YFx2J18OfD8KIa/kMGxuXOcHlPyRYpjyu -qTtg2oL0NyUIG+1TmJ3DcN4GlKC55eOrkfJ04vudS5pxdnUIFRmkGBXZLdaetoPc -ixtlD4w6gi8KIH1CTG+/TtHP1KVdOogCWDcjRCAmMJPFZe6eEKXmGQUZDb9wfnbx -h++XiVe5tq83BTLWmaFTy+fZbNo12uhNCNS1LJ42/yj+S1xvo0yMbkkNr1hIYk0P -584XnBQeBSVJDf3667NZXaxnWv94K9zbb+1OvOvPwhbOdgi2Ymcw5QEOQIavtg86 -XLLcWzG+SJsycz1imikjv6sStWh8WHneKSTMq6A7V6PBj7oJyEJp10696BDw287k -YlH+9VGqowPEMXpWX57wOBKiWb4K1kw1LfxjT8W1e/pcX9pJqiv0DkjTXUxo9CDG -1X1+ZXBBR3MkGuFAOCjy0x8CAwEAAQ== ------END PUBLIC KEY----- -""" - -signal state_changed(message: String, failed: bool) -signal prepared(package: Dictionary) - -enum Step { IDLE, VERIFY, STAGE, DONE } - -var _step := Step.IDLE -var _manifest: Dictionary = {} -var _manifest_sha256 := "" -var _from_version := "" - - +## Retained capsule path: Godot can finish an old parser's dependency list +## while loading the new plugin. Migration is complete in the canonical tree; +## the verified capsule carries the active implementation at this same path. func start() -> void: - if _step != Step.IDLE: - return - _from_version = _previous_version() - _step = Step.VERIFY - state_changed.emit("Verifying the signed Godot AI v4 release…", false) - set_process(true) - - -func _process(_delta: float) -> void: - ## One step per frame so the dock renders each status line before the - ## next synchronous hash/extract pass runs. - match _step: - Step.VERIFY: - _verify() - Step.STAGE: - _stage() - _: - set_process(false) - - -func _verify() -> void: - var verifier := _load_script_with(VERIFIER_SCRIPT, "verify_manifest") - if verifier == null or not _script_declares(verifier, "verify_archive"): - _fail(INCOMPLETE_CAPSULE % "utils/release_verifier.gd is missing or invalid") - return - var manifest_bytes := _read_bounded(PAYLOAD_ROOT.path_join(MANIFEST_NAME), MAX_MANIFEST_BYTES) - var signature := _read_bounded(PAYLOAD_ROOT.path_join(SIGNATURE_NAME), SIGNATURE_BYTES) - if manifest_bytes.is_empty() or signature.size() != SIGNATURE_BYTES: - _fail(INCOMPLETE_CAPSULE % "the embedded v4 manifest or signature is unreadable") - return - var expected := {"repository": REPOSITORY, "channel": CHANNEL} - if not _from_version.is_empty(): - ## Only the final v3 runners publish the pre-capsule plugin.cfg - ## version; older ones cannot, and the verifier must not be told a - ## guess. - expected["current_version"] = _from_version - var verified: Variant = verifier.call( - "verify_manifest", manifest_bytes, signature, expected, RELEASE_SIGNING_PUBLIC_KEY_PEM - ) - if not verified is Dictionary or not bool(verified.get("ok", false)): - _fail(_error_of(verified, "The embedded v4 release manifest could not be verified.")) - return - var manifest: Variant = (verified as Dictionary).get("manifest", {}) - if not manifest is Dictionary or str(manifest.get("version", "")).is_empty(): - _fail("The verified v4 manifest is missing its release version.") - return - _manifest = (manifest as Dictionary).duplicate(true) - _manifest_sha256 = _sha256_hex(manifest_bytes) - var archive_checked: Variant = verifier.call( - "verify_archive", PAYLOAD_ROOT.path_join(ARCHIVE_NAME), _manifest - ) - if not archive_checked is Dictionary or not bool(archive_checked.get("ok", false)): - _fail(_error_of(archive_checked, "The embedded v4 archive does not match its signed manifest.")) - return - _step = Step.STAGE - state_changed.emit("Staging the verified Godot AI v4 tree…", false) - - -func _stage() -> void: - var installer := _load_script_with(INSTALLER_SCRIPT, "stage") - if installer == null: - _fail(INCOMPLETE_CAPSULE % "utils/update_installer.gd is missing or invalid") - return - var staged: Variant = installer.call("stage", PAYLOAD_ROOT.path_join(ARCHIVE_NAME), _manifest) - if not staged is Dictionary or not bool(staged.get("ok", false)): - _fail(_error_of(staged, "The verified v4 tree could not be staged.")) - return - var stage_root := str((staged as Dictionary).get("stage_root", "")) - var tree_sha256 := str((staged as Dictionary).get("tree_sha256", "")) - if stage_root.is_empty() or tree_sha256.is_empty(): - _fail("The installer staged the v4 tree without reporting where or what it wrote.") - return - _step = Step.DONE - set_process(false) - state_changed.emit("Activating the verified Godot AI v4 tree…", false) - prepared.emit({ - "stage_root": stage_root, - "expected_tree_sha256": tree_sha256, - "manifest_sha256": _manifest_sha256, - "from_version": _from_version, - "to_version": str(_manifest.get("version", "")), - }) - - -func _fail(message: String) -> void: - _step = Step.DONE - set_process(false) - state_changed.emit(message, true) - - -## The pre-capsule plugin.cfg version, as recorded by the final v3 self-update -## runner before it extracted the capsule over the tree. Empty when unknown. -static func _previous_version() -> String: - var settings := EditorInterface.get_editor_settings() - if settings == null or not settings.has_setting(PENDING_V3_UPDATE_SETTING): - return "" - var parsed: Variant = JSON.parse_string(str(settings.get_setting(PENDING_V3_UPDATE_SETTING))) - if not parsed is Dictionary: - return "" - var value := str(parsed.get("from_version", "")) - return value if _is_pre_v4_version(value) else "" - - -static func _is_pre_v4_version(value: String) -> bool: - var expression := RegEx.new() - return expression.compile("^[0-3]\\.\\d+\\.\\d+$") == OK and expression.search(value) != null - - -## Load one of the v4 installer scripts by path. The capsule cannot name them -## by `class_name`: those globals only exist once the v4 tree is live, and the -## bridge must still parse when a truncated capsule lacks them entirely. -static func _load_script_with(path: String, method: String) -> Script: - if not ResourceLoader.exists(path): - return null - ## Final v3 ships older files at some of these paths (utils/port_resolver.gd), - ## already compiled into the resource cache before the capsule was extracted - ## over the tree. Ignore the cache, deeply, so the capsule's copies and - ## their preloads are the ones that run. - var loaded: Variant = ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE_DEEP) - if not loaded is Script or not _script_declares(loaded, method): - return null - return loaded - - -static func _script_declares(script: Script, method: String) -> bool: - for entry in script.get_script_method_list(): - if str(entry.get("name", "")) == method: - return true - return false - - -static func _read_bounded(path: String, maximum: int) -> PackedByteArray: - var file := FileAccess.open(path, FileAccess.READ) - if file == null: - return PackedByteArray() - var length := file.get_length() - if length <= 0 or length > maximum: - file.close() - return PackedByteArray() - var data := file.get_buffer(length) - file.close() - return data if data.size() == length else PackedByteArray() - - -static func _sha256_hex(data: PackedByteArray) -> String: - var context := HashingContext.new() - context.start(HashingContext.HASH_SHA256) - context.update(data) - return context.finish().hex_encode() - - -static func _error_of(result: Variant, fallback: String) -> String: - if result is Dictionary: - var error := str((result as Dictionary).get("error", "")).strip_edges() - if not error.is_empty(): - return error - return fallback + queue_free() diff --git a/addons/godot_ai/migration_coordinator.gd b/addons/godot_ai/migration_coordinator.gd index ab2ab35..afb8775 100644 --- a/addons/godot_ai/migration_coordinator.gd +++ b/addons/godot_ai/migration_coordinator.gd @@ -1,200 +1,15 @@ @tool extends Node -## Prepare the capsule's value-only handoff to the shared activation runner. -## This coordinator finishes before the runner disables the capsule or swaps -## its source. The runner alone owns the scan and in-editor activation. - -const INSTALLER_SCRIPT := "res://addons/godot_ai/utils/update_installer.gd" -const ACTIVATION_SCRIPT := "res://addons/godot_ai/utils/update_activation_runner.gd" -const PORT_RESOLVER_SCRIPT := "res://addons/godot_ai/utils/port_resolver.gd" +## Compatibility path for cached capsule dependencies after a signed update. +## The canonical plugin has no remaining capsule installation to coordinate. const STATUS_SETTING := "godot_ai/v4_migration_bridge_status" -## v3's `plugin.gd::_ensure_game_helper_autoload` wrote -## `ProjectSettings.set_setting("autoload/_mcp_game_helper", "*res://addons/godot_ai/runtime/game_helper.gd")` -## and saved project.godot. The capsule never re-registers it, so the entry -## dangles for the whole migration window and points at a file the v4 swap -## moves away (#946). Remove it before the swap, matching the exact target so -## a user-owned autoload of the same name is left alone. -const GAME_HELPER_AUTOLOAD_SETTING := "autoload/_mcp_game_helper" -const GAME_HELPER_AUTOLOAD_TARGET := "res://addons/godot_ai/runtime/game_helper.gd" -const REQUIRED_INSTALLER_METHODS := [ - "acquire_lock", "release_lock", "discard_stage", -] -const PRE_DISABLE_DRAIN_FRAMES := 2 -## Unknown pre-v4 versions still describe a major crossing. Manual-major -## client repinning replaces any owned pre-v4 entry, so this fallback is -## identity metadata rather than a client-selection authority. -const UNKNOWN_V3_VERSION := "3.0.0" - -signal state_changed(message: String, failed: bool) - -enum Phase { IDLE, DRAIN, DONE } - -var _package: Dictionary = {} -var _phase := Phase.IDLE -var _frames := 0 -var _installer: Script -var _lock_held := false -func start(package: Dictionary) -> void: - if _phase != Phase.IDLE: - return - if not _valid_package(package): - _fail_before_swap("The prepared v4 migration handoff is invalid.") - return - _package = package.duplicate(true) - _frames = PRE_DISABLE_DRAIN_FRAMES - _phase = Phase.DRAIN - set_process(true) - - -func _process(_delta: float) -> void: - match _phase: - Phase.DRAIN: - _frames -= 1 - if _frames <= 0: - _prepare_swap() - _: - set_process(false) - - -## Everything that can still refuse without touching the live tree. -func _prepare_swap() -> void: - _installer = _load_script_with(INSTALLER_SCRIPT, "swap") - for method in REQUIRED_INSTALLER_METHODS: - if _installer == null or not _script_declares(_installer, method): - _installer = null - _fail_before_swap( - "The migration capsule is incomplete (utils/update_installer.gd is missing or invalid)." - ) - return - var activation := GDScript.new() - activation.source_code = FileAccess.get_file_as_string(ACTIVATION_SCRIPT) - if activation.source_code.is_empty() or activation.reload() != OK or not _script_declares(activation, "start"): - _fail_before_swap("The migration capsule's activation runner is missing or invalid.") - return - var cleared := _remove_v3_game_helper_autoload() - if cleared != OK: - _fail_before_swap( - "Could not remove the v3 game-helper autoload from project.godot (%s)." - % error_string(cleared) - ) - return - var fingerprint := _editor_fingerprint() - if fingerprint.is_empty(): - _fail_before_swap("Could not fingerprint this editor process for the update lock.") - return - var locked: Variant = _installer.call("acquire_lock", OS.get_process_id(), fingerprint) - if not locked is Dictionary or not bool(locked.get("ok", false)): - _fail_before_swap(_error_of(locked, "Another editor holds the Godot AI update lock.")) - return - _lock_held = true - var from_version := str(_package.get("from_version", "")) - var record := { - "from_version": from_version if not from_version.is_empty() else UNKNOWN_V3_VERSION, - "to_version": str(_package.get("to_version", "")), - "manifest_sha256": str(_package.get("manifest_sha256", "")), - "expected_tree_sha256": str(_package.get("expected_tree_sha256", "")), - "editor_nonce": Crypto.new().generate_random_bytes(16).hex_encode(), - ## The v4 plugin reads this after activation: a v3-to-v4 crossing - ## may replace owned client-config entries broadly, which an - ## ordinary v4-to-v4 update must not do. - "replace_owned_mismatches": true, - } - var runner: Node = activation.new() - get_tree().root.add_child(runner) - var accepted: Variant = runner.call("start", {"stage_root": str(_package.stage_root), "record": record}) - if not accepted is bool or not accepted: - var reason := str(runner.get("refusal_reason")) - runner.queue_free() - _fail_before_swap(reason if not reason.is_empty() else "The migration activation runner refused the prepared handoff.") - return - ## No capsule Script or suspended callback crosses into the scan. The - ## runner holds only its independent source and copied package values. - _lock_held = false - _installer = null - _phase = Phase.DONE - set_process(false) - print("MCP | v3 bridge handing verified tree to in-editor activation") +func start(_package: Dictionary) -> void: queue_free() -func _fail_before_swap(message: String) -> void: - _release_lock() - _discard_stage() - record_status(message) - push_error("Godot AI v4 migration: %s" % message) - _phase = Phase.DONE - set_process(false) - state_changed.emit(message, true) - queue_free() - - -func _release_lock() -> void: - if not _lock_held: - return - _lock_held = false - if _installer != null: - _installer.call("release_lock") - - -## A refused attempt leaves no staged tree behind; `stage()` would clear it -## on Retry anyway, so this is tidiness, not a contract the bridge relies on. -func _discard_stage() -> void: - if _installer != null and _script_declares(_installer, "discard_stage"): - _installer.call("discard_stage") - - -static func _remove_v3_game_helper_autoload() -> Error: - if not ProjectSettings.has_setting(GAME_HELPER_AUTOLOAD_SETTING): - return OK - var target := str(ProjectSettings.get_setting(GAME_HELPER_AUTOLOAD_SETTING, "")).trim_prefix("*") - if target != GAME_HELPER_AUTOLOAD_TARGET: - return OK - ProjectSettings.clear(GAME_HELPER_AUTOLOAD_SETTING) - return ProjectSettings.save() - - -## `McpPortResolver.process_fingerprint` from the v4 `utils/port_resolver.gd` -## the capsule carries; final v3's copy of that file predates the method, so -## the lookup is by path and method, never by class_name. -static func _editor_fingerprint() -> String: - var resolver := _load_script_with(PORT_RESOLVER_SCRIPT, "process_fingerprint") - if resolver == null: - return "" - var value: Variant = resolver.call("process_fingerprint", OS.get_process_id()) - return str(value) if value is String else "" - - -static func _load_script_with(path: String, method: String) -> Script: - if not ResourceLoader.exists(path): - return null - ## Final v3 ships older files at some of these paths (utils/port_resolver.gd), - ## already compiled into the resource cache before the capsule was extracted - ## over the tree. Ignore the cache, deeply, so the capsule's copies and - ## their preloads are the ones that run. - var loaded: Variant = ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE_DEEP) - if not loaded is Script or not _script_declares(loaded, method): - return null - return loaded - - -static func _script_declares(script: Script, method: String) -> bool: - for entry in script.get_script_method_list(): - if str(entry.get("name", "")) == method: - return true - return false - - -static func _error_of(result: Variant, fallback: String) -> String: - if result is Dictionary: - var error := str((result as Dictionary).get("error", "")).strip_edges() - if not error.is_empty(): - return error - return fallback - - static func record_status(error: String) -> void: var settings := EditorInterface.get_editor_settings() if settings != null: @@ -202,12 +17,4 @@ static func record_status(error: String) -> void: static func status_setting() -> String: - ## A failed migration in one project must not block a different project. return STATUS_SETTING + "_" + ProjectSettings.globalize_path("res://").sha256_text() - - -static func _valid_package(package: Dictionary) -> bool: - for name in ["stage_root", "expected_tree_sha256", "manifest_sha256", "to_version"]: - if str(package.get(name, "")).is_empty(): - return false - return true diff --git a/addons/godot_ai/migration_fallback.gd b/addons/godot_ai/migration_fallback.gd index 5030a4d..eb59fcd 100644 --- a/addons/godot_ai/migration_fallback.gd +++ b/addons/godot_ai/migration_fallback.gd @@ -1,217 +1,11 @@ @tool extends Node -## Puts the final v3 add-on back when this Godot is below v4's floor. The -## user's own updater already overlaid the capsule over their v3 tree and -## discarded its per-file backups, so without this the plugin stays dead -## until they upgrade Godot. Parented outside the EditorPlugin so it survives -## the capsule being disabled. Leaves with the capsule once the fleet is on -## v4 (docs/self-update.md). - -const PLUGIN_CFG := "res://addons/godot_ai/plugin.cfg" -const LIVE_ROOT := "res://addons/godot_ai" -const FALLBACK_ZIP := "res://addons/godot_ai/migration_payload/godot-ai-v3-plugin.zip" -const LIVE_PREFIX := "addons/godot_ai/" -## Every file is written and read back here before the live tree is touched. -## The update directory is hidden from Godot's scanner and from git exactly -## as utils/update_installer.gd hides it. -const UPDATE_ROOT := "res://addons/.godot_ai_update" -const STAGE_ROOT := UPDATE_ROOT + "/v3-restore" -const UPDATE_IGNORE_FILES := [[".gdignore", ""], [".gitignore", "*\n"]] -const MAX_FILES := 4096 -const MAX_TOTAL_BYTES := 64 * 1024 * 1024 -## Capsule-only files; the restored v3 tree must not carry them. -const CAPSULE_ONLY := [ - "migration_bridge.gd", "migration_bridge.gd.uid", - "migration_coordinator.gd", "migration_coordinator.gd.uid", - "migration_fallback.gd", "migration_fallback.gd.uid", - "utils/release_verifier.gd", "utils/release_verifier.gd.uid", - "utils/update_installer.gd", "utils/update_installer.gd.uid", - "migration_payload", -] - - +## Retained capsule dependency path. The canonical v4 tree has no embedded +## v3 fallback; keeping this path must never initiate a downgrade. static func available() -> bool: - return FileAccess.file_exists(FALLBACK_ZIP) + return false func start() -> void: - _restore.call_deferred() - - -func _restore() -> void: - var entries: Array = [] - var error := _read_payload(entries) - if error.is_empty(): - error = _prepare_update_root() - if error.is_empty(): - error = _write_all(STAGE_ROOT, entries) - if error.is_empty(): - error = _write_all(LIVE_ROOT, entries) - if not error.is_empty(): - ## The capsule payload and any stage stay for a retry; nothing is - ## removed until the restored plugin has been enabled. - push_error("Godot AI v4 migration: could not restore the previous version: %s" % error) - queue_free() - return - print( - "MCP | v3 bridge restored Godot AI v%s; update again on Godot 4.7 or newer" - % _restored_version() - ) - ## Disabling the capsule removed its dock; re-enabling after a scan loads - ## the restored plugin.gd, exactly as final v3's own updater re-enables. - EditorInterface.set_plugin_enabled(PLUGIN_CFG, false) - var filesystem := EditorInterface.get_resource_filesystem() - if filesystem == null: - _enable_after_scan.call_deferred() - return - filesystem.filesystem_changed.connect(_enable_after_scan, CONNECT_ONE_SHOT) - filesystem.scan() - - -func _enable_after_scan() -> void: - EditorInterface.set_plugin_enabled(PLUGIN_CFG, true) - if not EditorInterface.is_plugin_enabled(PLUGIN_CFG): - push_error( - "Godot AI v4 migration: the restored plugin could not be enabled;" - + " the capsule payload is kept for another attempt" - ) - queue_free() - return - ## Only a running restored plugin makes the capsule material disposable. - _remove_capsule_only() - _discard_stage() queue_free() - - -## Every entry must be a regular file under the live add-on path: no absolute -## paths, no `..`, nothing outside `addons/godot_ai/`. Read everything before -## writing anything, so a bad archive changes nothing. -func _read_payload(entries: Array) -> String: - var reader := ZIPReader.new() - if reader.open(FALLBACK_ZIP) != OK: - return "cannot open %s" % FALLBACK_ZIP - var files := reader.get_files() - if files.size() > MAX_FILES: - reader.close() - return "too many entries" - var total := 0 - for name in files: - if name.ends_with("/"): - continue - var segments := name.split("/") - if ( - not name.begins_with(LIVE_PREFIX) - or name.contains("\\") - or segments.has("..") - or segments.has(".") - or segments.has("") - ): - reader.close() - return "unsafe entry %s" % name - var data := reader.read_file(name) - total += data.size() - if total > MAX_TOTAL_BYTES: - reader.close() - return "payload too large" - entries.append([name, data]) - reader.close() - if entries.is_empty(): - return "empty archive" - return "" - - -static func _prepare_update_root() -> String: - var root := ProjectSettings.globalize_path(UPDATE_ROOT) - if DirAccess.make_dir_recursive_absolute(root) != OK: - return "cannot create %s" % root - for marker in UPDATE_IGNORE_FILES: - var path := root.path_join(str(marker[0])) - if FileAccess.file_exists(path): - continue - var file := FileAccess.open(path, FileAccess.WRITE) - if file == null: - return "cannot write %s" % path - file.store_string(str(marker[1])) - file.close() - _remove_dir(ProjectSettings.globalize_path(STAGE_ROOT)) - return "" - - -## Drop the stage; when nothing but the ignore markers is left, drop the -## update directory too, so a refused or restored crossing leaves no trace. -static func _discard_stage() -> void: - _remove_dir(ProjectSettings.globalize_path(STAGE_ROOT)) - var root := ProjectSettings.globalize_path(UPDATE_ROOT) - var dir := DirAccess.open(root) - if dir == null: - return - dir.include_hidden = true - dir.include_navigational = false - if not dir.get_directories().is_empty(): - return - var markers := PackedStringArray() - for marker in UPDATE_IGNORE_FILES: - markers.append(str(marker[0])) - for name in dir.get_files(): - if not markers.has(name): - return - _remove_dir(root) - - -## Write every entry under `root`, checking each write and reading it back, -## so a full disk or a refused file stops before anything else is touched. -static func _write_all(root: String, entries: Array) -> String: - for entry in entries: - var relative: String = str(entry[0]).trim_prefix(LIVE_PREFIX) - var error := _write_checked(root + "/" + relative, entry[1]) - if not error.is_empty(): - return error - return "" - - -static func _write_checked(target: String, data: PackedByteArray) -> String: - var parent := ProjectSettings.globalize_path(target.get_base_dir()) - if DirAccess.make_dir_recursive_absolute(parent) != OK: - return "cannot create %s" % parent - var file := FileAccess.open(target, FileAccess.WRITE) - if file == null: - return "cannot write %s (%s)" % [target, error_string(FileAccess.get_open_error())] - file.store_buffer(data) - var error := file.get_error() - file.close() - if error != OK: - return "write failed for %s (%s)" % [target, error_string(error)] - var written := FileAccess.get_file_as_bytes(target) - if written.size() != data.size(): - return "short write for %s (%d of %d bytes)" % [target, written.size(), data.size()] - return "" - - -func _remove_capsule_only() -> void: - for relative in CAPSULE_ONLY: - var path := ProjectSettings.globalize_path(LIVE_ROOT + "/" + str(relative)) - if DirAccess.dir_exists_absolute(path): - _remove_dir(path) - elif FileAccess.file_exists(path): - DirAccess.remove_absolute(path) - - -static func _remove_dir(path: String) -> void: - var dir := DirAccess.open(path) - if dir == null: - return - dir.include_hidden = true - dir.include_navigational = false - for name in dir.get_directories(): - _remove_dir(path.path_join(name)) - for name in dir.get_files(): - DirAccess.remove_absolute(path.path_join(name)) - DirAccess.remove_absolute(path) - - -static func _restored_version() -> String: - var config := ConfigFile.new() - if config.load(PLUGIN_CFG) != OK: - return "?" - return str(config.get_value("plugin", "version", "?")) diff --git a/addons/godot_ai/migration_payload/godot-ai-v3-plugin.zip b/addons/godot_ai/migration_payload/godot-ai-v3-plugin.zip deleted file mode 100644 index be3f0888483a75b31261f56ce907d95515a7208c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1890142 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�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

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

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

cS+a8KNK_eWXJk!T#2gsQk7xMZ7 zM#xASSll4Rmn?T`ZfVvX^Poo_y(ft0mj}e6CU)ZdQP$${d<7^{kU8mjezbw~}9oVjS`*+yQ(QQ;S=z^BYn94s;pO^$#K(R(B|GR3F~6ihEq zTcT2mKat^P-r#bvNwG;%rV>&i)10a^lto-Mm&8_Cs9gw5d&p6~Y!1Add7vv_?;$jW zIGTr;yJ_MOf)Era(l`uiapUZeFoc(Em`R&&#MZN*?#|-1hSGH9clA>5mR?HxN&OgW zxtoUFvPSr#BnFuEMm2D3aAd^)2<6p}tdXC!wXZfUzESe<7<1GWRo5U(%O_2FcGsOj zTqRKHz1u&zreGEHqQlTYY^4!_kk>;(5T|VRDku<-^PnV&gaR5;FM^v53keax=q989 z*^1a?vp-P@>|R30P_><{V1x{lY-z(K)vMZiu)6^GUXUaVWCGj=bfp<&4G9QNHEDUi z0x_*wIV|#wG8hrsz=1+&w0x^k77kENcaV3KK3M>n)e4m2AyHF0mqz-#O^lmahKux68E+6<#0+$-=SBtWifR&`xIcQIWm z30RwP%>luCj6``#QF9+H`;}FqgIu2M5<(sOW5!m#U7^p91uO`E$0Xb^wf^mj2?;8$ zDcm5$%>Beo=)(j+WUweO9Y%G1r3_S%n`oqYj|Qx%GC1TVrWJ8VY|neQZroI}rBd>s zC-uJoP?CtEPML!*RxMQ1HT22=N{vlzb{XY9%HSF0KJVP|)W)vPjcZN{MTrsg`&^Qt z7w}`tRoaizhoriplWI7>);9a8SX1#?fLyRx4n;KxJrOU&PR%hV5ZI`|ug$HVq>eaZ z?YA+JB8ol_0oBP^xoCSzR3~pY=tG(#%UpA!nYcY3kI9MlHOmkca82sQx(3r4 z|3CKLuD7l$OV?{4K>#_oxeceE^TjsgYere-0;3YYWv~bH&5oZg~8LK@-Ll zpm2-cFy<0QdRL)AQ&3v3NuY6FQx5ifW-IkV0O-iY(qqEN zm*ylv&8I`%m04IjX)|cwfyB+ty7wrYo(gc#oO*`s%xdj4n4J!<89i+?L;fSn)E+SAjg z7sq01@pb*v#*ojpZ9eVBXUH+LWl4>1aqU@WF<;JYR&{Y$Vb70{TnyD@4FllSk}cX- zz$%Weyc6%^+WyBdJZOV==hhcHG|c<-)<4~q)l(V9SXO)IN*24%Ufx=~yZyHAam{21 zUy=w-&2}N;9VMQ;6(~I!^)aXkfsWc9l}Gdi)Z4IB<CM?ejmpaZPso_IOJyVEmxYW&Kr)m`y|X-XVppjF(A0WTqvo zyX^3fa`s5iO^I)C47zBet5m~$x1(&*jp4q-Fl!Ys-_AwjICLgXq*I{pax;kI++5MD zNYf|@Zk(Z(p(xtkkHdw^>rvS#s&Z(1x}hfIP*W!3+5!ehfY}RF3VfU%x?KAE?NCXr zM#<)I3y?a zE1~dd{r~QN{9o=8;o*P0n}^S_ep?45?yXQ;8n7S7ulh=pYB0^)AT6sk8L{K=+auto z;E^MN+ERpAB6`xNnlUdS!Au#D(~w&8fcwKE{Iq(qC38@7P&`BT@Ri}R6a~d_|B!r+ z32A{+IZkj-%~K_rPJ}BZc5_f>-9C53H}dFeEHeC5=4AJdQu(H%LIkF6F&>L2f5%$c zCrU?it|+-((&#xRG9=L3fu+z%YVM#nhG19v>Fz!mbtl6w(sAT_+w9p?XqB64W`SD1 z?!Q@krF8c=S;ipWLbe=`y4U2qt-55I1Ki+Q!ym=3#>?F7_+>1vdKJG-ft#y+o3`6} z@M!I$;~39LpVQp>%H%6^_8?GVTyUaw^8pthlI6y9x!kyY$$GiGGfa(uDA+yP4nHH? zAAa+Epdj5BFdZYlsVmD=Dw4;+n`y1sQd#*?&eeveTs4jIs%}MF@4WTK2!&@MA zGsKd^=EOtb3e}O(#|131e8uTx|D0Z6@Y}nE(@?HM*g0K26yH|Ww|s?M<)q=B%KROF z6QBws8Gd_+##f(&**-|Z#}Vksv8drVtk}9SaFdAfWDltH?#?z1<@P#VMdUFEsB$x< zY)-wd31!mfRj0Mtp{)z1HlMpV`G)R8IGgT1-FtSj6%0?doSgM4yO#4q+(X#l^@uOB zPzo57Q5$e#n@1VT!yjkh%LgTnlOpqAK`@z8;6UPmxIw51p6RA~Kp$HUqrvIT4b3b{ zDZ9DsBEkBQ5U|E41*Uy0to!b06K1hOnUN>EPWj5Kd(AoL)v#^$eK_#b{63u%SILx5 zB}n>}mflgeywdV7%-bh!^6fSfPn^5r<46NF1+WPt?f}tVYM^+#;5A`HoX+G)ln-dJ z;i^#@EF!4!rquC`GE=t2UNsa2Y}S(^h^kcl*0F^|x@5xl6tp1}fOgUegriQY3r~*} zC%Msb4&oYI(qS>ahkef^a)JqUMH?*Lv{wEcjmtJhr#S)HhdA@qHSvyoOkxC~wb=p> zhH!dD@Id}wWjIMmsNAx>oP?w{$mme%fqYU_^gzmqd0E!H{&qu~+PMC}Dkap3P8bSx zI4%MtAAEV|4j0UM@%Bkb|BOXc&<=`M^Ac-SQtu)?2u+s76;LQ9Q00dp!_6q@<953| z2;R!oa8t*ZZWfG_1_MohEI&_pJAM(SHB`G~bkH;l+ePJ+{5MUnLxmDifm_2z4MTC2 z5NCW^{s{f<8+hexxNq$KDwt3zz|Hi~;fD|UP4^!fh}2J+4+PcxvAKz%bFF;{8Mi^| z60^OzyuLvrqZrToonc5K`Qy!c1u%iyav96hmKbbzB>&FXc#n=k>`{hAU3^|*A-$Lq zPih~RYX5$8Za^Ff8R`C&v3JEagu{Uu5qtKE_|9=y3_4%N6F?0ouGD@pY{f+Po|o+x z{#p*NG@cdi83h&S3eat%`|h2-wp{%m?KvCNn|Fb;Wtv`l9ocJ4w|qzGyvzo zV3ctOc-7IQGFHS>_=a+2f*IQT4z~moc1O$fhmEz;`_IqR%kHX+<_j{v6sy#W)2!mVikSl@FoWcThtU9c= z7Fv3M`i;||l<)@;$VmTP00)kq`b9*YEFB!IdJrGH%q)BWT4-sdCCfaLvqC5z#c}6Z zWY-)Y`lA)EuVK-8s0)Net>~kEiz!yif<@{)jIgJs2D1O78ijrHNq9;$jb_) zKCBh8Bo>GRIK8^3tc)5g($H@z3_?IbYb2t`bIqotQk^4;I}1?~DMaTgdsr>$oZ4jZ zb^5@aZjrzyCQKQDu)rDbtbXZzhWLNb1%Wy8)P@Qys@Z=qHZvcj8v>?5K5*EX=Gpmy z5sMUt8;*Q6DNZOIoZ2Gcu8~vGedXvoi(I=f5A+dATgp|TYdS^RaWn|Wm6-yKq+RVh z4qk6Xt4M_+71`F|qqBV@13(`dSzw4B$@VfIiUt%=m=bi1Nwhy7^1wkLIKauyoslrHIAH;>lDDBkGrv7x@0WZ=LR8XZ4k_+w} zW#F$Rc8tN|!mK;BSsQOXR)Jj4+44qUQe-{_ntT^*W)tLmcd>jz{z)WsE2~F9ijN5l z>>YP?Nn`PJejVa?%Bb5ixKg@B1(lX%L!exTC{B+rPIL$9iW3&BkQc-r)a%m7@@yib zXgw28HKHprhdpSrIYu6wBJzH1L|EnTay!hzzCWtUH=Y^kN zr1&vljhR2#c1`Gv_4!bj)C0!1(FTIC6>Mo4|6+ncn1Rd2KY5#m%yA>2i-L4KWdw>N z0QBYFBO&A+4mRM8ogHm zGEE_clfqFgtItQ;r*R~PKiL7~2}U3*I9R?DMiX;{F~&g8Fl(D}=eq;IKK$fhXcSQi za`+u0G2t3bEXCk8nV{nohiG#M{}w4LPA)|(Q>8KpQfWek4&nRO?FUcK11D`(c7cl3 zK*$RW2UhexxtNBSFfTE2U^eU{lCH{A!{NH;Ej%gLXC)-X*@LOl5MV0%INU)AUY%7C zP)a-&6Ov5lr`2Slr|(~PKjFFO5Pby!+>j8pOa zSY{Jiq=rbGJGJHsJ`dHo8b>`vofbsjE>jitfgu>h@WqFmwDbXGSTeQTc%BoPz3h^NI0KNllIe`MfRlgBop-{YKQdSx0X za%Ko`-rqGAzL!Y9qR%p@WW@L@GF;X%z0QX|rFAoAPXOspnOK?z@7q>G zO;bYV&?~x;Ud{=MLoA|-VC)G}d;r#=8&eZCTfs-^?u)#Vh&;nHYNx^=w?LE9yLNZ5 zCOKSYP) z)TI0|rcmejjmzSW8`rn@5zi}MlUAB5*-lkjPV{De5CpR}>%LdhfjO#;DR=K>3+Yy{ z0;I+m^Fs<*S$>AHGl$UluxFdHn`) zn@X#(DVAVZQ(I|qiJ=rt!tL3!o)Y#RB*()3m?ja+u<@1(ilS7RQkRCeOeFGL<}4&n z1kViZy18S{4xeF-qRGMr!hX`rWkfCBpOoY$kUd%YG`OvHN`S({t#_#6Q!FZ6m|@-O zCTGFY7MWNQn@hu1{D|b(l^D9j*dgkA3D;>wQny~zMyP*+1vzYB#V{8GT6$VR%C>Y1WUPF$OhR0na{=foy;i@bH$ z$l*et`x;m4=~*QY0NQ_`Vp)+Z*G3!YRVwZU+<)*;{s ze*XOr(mcf0&d)aUUG-kt+|W*=*V1*{#(eTVL$7UU)e!)4b&tmQk&sy#9QAD6@|9VIMJLs3EID?p zu?*h~!Yi$%1}6fAi`Q!tX(wu#kG#}+nJ!+s(u&*Y@tWIXAT?ff`=vEbY)>>6ZI!>} zDpqr?BiuQKE8$=#Q#HNS_hjIQ3bl2NF?>u^Nh@j|0SNAuK57GHisYn9RB?#9lCno|m^5Qm(DbV}&aIwPYXQnvuyzYSzuEkX}^=D`&Y z!U~R`L$H^m7UHHs(+Rk=>>QWu|@WL43EVzlDhdxO|f@_L2X8GiJb5t zj6(mS*|nTy4Lw-YcV8M=JMCKpJ(3W$nmmeODgGIWC|#W*_3)>zfs4R&lSr~~>l;C; zBqI4JL(6|dxR;S$BE8add;#F=IZ?sxrgLp&YPJIwSGYqZGm`t{4H8hs3 zy#~|KUh_M3IxEK&oOeJ-Znc5#R4k`jxp-{_DRDBji9y7$Wv3NiPoUh1xUs_* z;*cXV3J7J~7SvuTJIy$5)B=flyV6I#Q3G*E4oU1Rd8fuJu?E@=e0LsI7m+oZ&S^^m zxsZoB=J;Ei&{?awttJC>gjs}2QwqYA_D;(f^z!J)aIw$*-S%m_odU-c4ODXd;xc6M z5G7VDsE<4L$n}E5?T=C7!kOdBTVrh!8~lr_URe7#X%Q%MqWcr|I-?Ttw0({r+P|$! zYo>s~Bp);9i{E%H{?Xp)S0r&z#(ce2CJk>+43)N2#W3yk=!bL$j+O?)x05mBc4@&# zlZuT+0>h|qwPm=8hmF1V-JfG$x{roRZ7L( zw!Sw}3y5XbFhL3qknxAvEhr9=w!jRrc4|aUod9dQ&c|&f96+V@)%RRG?9$-^W36v> zaCeS!Gw1A=fZgeMcdt;JokhW;!tB8@q5UNP5au-dt>{ z^2nx_fZ}!uTJbjB4dHmZez>2eYj$B_EeHp6`9w2NqnUfGg zNeJ;Olrg0n;0v!$FUiVL7Ani`y0&8Aoh;oK0FTx2#S`LqN5sL)xVr?rBKccqXM4}@ z4>jHLbLxO>v~fJIW#joKG0j#${tKIV`m_OC%?ND!ASZOg*;?}wI}H8)9tVOo>DNVZ zG9?k|KvyRvtTUVfsl*KuYx$qkC!yI!b{jb|OS*L&%5b_?mgfpmd%|*=#;?tT%4#PD zU<_FO6koX+-@5E<&v4n5K98tT_W z<+AaoS#Y)BRq?2aBTv}QI`orYEbQNWTkx~3O3=xpAjM(v<_dc3Ypym#iyNV5cMrvN zHm-+xpYCa+LLP@JpAI&^*FkO2$NlRKE@DPA@(hk8cv@S3*C~=^lxUn$DB9g9KYVNP z_VpZ8EMaxJAK|4O(T1 z(nL*PZmRfptY0!w1@Jv8nMv*Jek}j1m?2om z@SB$%zqO^^Y*4vB>?6j|uGqkO=WD$w;LR4#oaBdZlj=GUE83NK>7kqRmLat1$rd~i+C*%fW+fj^*niD3r6GH8;W-lVcqIXnb>?`G+>Ij{7_ zZ|KYgw|%lVW|^#;-tlM8H)BPqf)Z78UW0g?f^o0&pHlnRxE!PnTseMip|%uWZtE1&@b*KbPRPW{mdVK?yrS+L*zRLY+XB&NXeIp8~NOZZr;o^lY z3?gN9hGQ2@RXS(pEs$Ci6O#F(mBwKRRBhJa7Y)l}xCA?&7OARAJ6*(;d!t$bb^b?L z!g515(j_gGQ588#noq7>8gj=}>)p$8avIgRz(Foky&C=0eOYPb7b{&gCnB&Icn$vO z0z9-QrK!V*A|KlF9a)AEFcoc7oMe^nv&g(G~aJ27Cp10NX&X$khd>5T3V#{`=`;ZO{lq6R&d^KvGJPL4bs%Bf4F*I7@V? zADb(mZ9{n3p8gUc2Ya{y_?-3sPHuAix%nA2abvV*hK~Lgo{UGtuBPMS>C9*nQB}Lf z(hk+9Wd1$5r1^?^za!9M0bv!47K3jnfx|TI-+UBI*50=rz~f%VW0Zje+y3)d8sayw z!^a@Cq5Ct25JO;6xtw$msqYJ(>dJ$9N`8ouRKz+cGG?UK4is1GChbi%DTbpT7c(Jxac_XqVAJ(#&;Z4-kpa! zG}!NCB88z}>r}OFe6li&jB>SnS{jOsBVS!xN-Ajk{1-TtYGO5yYA*+kPFV_s%S(Im zD#Vnk+lx=m4Uy=W%dyHr)LDYbf(i-Js?Ii&SB>X;TyOX)xN7$tI6_}`VMYp5AHOI% zB&eiCZ&$(Cd&%HIFxE(oHYbSpLh=?jALa^Xo^m>zA;QX#1`^&FvX69IABXW zKW7>j)ZsFV?@{Kh4u8Ye5EC+4Stv}uUviz5n{2~f+eohGC~84Y59{z!12J3-e@8N z@=1{j+TFb($-8xU;uKRsF&oPU-_2eCdamUM6%VG#9AFF(_7MC9N_Ok0#pava?>wSI zxbJ8=xE@)uvtlxUDd|z-(!CXFmLWf;GX}`&krdptYhkQM#{9OyAw7(F-1=$PivyKM z<+7Dq0>$xC+^!9nA`n0!UUsQ9o+|-vif5J*#k9A<@oSfX3a$a7Q z@`kkYh@=48t%|(+{K+e^8SW2Kf9Xr z54=%lXZOP`>xUlH3>|))INz?IET#njRY_y7bi8>hrb<1a*VI4<`$18&5DTg3vKs7{ z@c>zeHXE!Gr)s!5D^hEu?x;cK_|=IvEhH)RDOa{S2Lmq7PQrdz{U*X|^~qa9hv zA3d>-?$Fx#rvk#tu zKjRWbD|jK2KKgtMwpGN`j11{lnfC@EtciM5RhXzj(^njB7c()@Zo+!jOthUI1O+F! z=`0ULiblD7Ly;k+LPGl%eOcr-ohr)-eh44&9%h9$jtW!6Dm7 zxMXt}GbA{9Nb#@W|BCK02%UTYO7AzoeQg&E zF+2E!E=X8~)4JnttaO6^few+)9~gFJ$Tjc7UpM@WREpj-*i9sj;CPjTSeeLvFy0pV zCV34JNV!VJd6J9oFFWS7v-e~_rS+Asil&mbr*y5l>o?XD<|Wi9kcR&4vYk(tr>E+-!kmFw`G*=LO!jifRUtWn&Dkt`G zcF#4UH0s|wfy=TWx7f=Ad65E=sMHZcpP43hUN{9y(9wnLLdp-XU&cAoiiS3$1L z4Z`U+Z`|OD9Qq{Pczk+5Kx^;l23e0V<#nTdjVfc>a#mQAHU^8DQB`dwu1gq()6Tq- z+O5Qd)BFj|(*h+wQECy%`ENP@0Wl<0?znl5jO6tXL7bz zPvdEeNClJxP&fFiGo%z1D?WjO#XVE7m?w<27XF;ha`f%z8~8$XXO)m~1HX{Nk#LF! z`6>GQW;Cb14fxdF@aEvuf`=(2up~{DA6;KC^W&!0YLzwNf`n6>i|J%G+-7I;FPBbZ z4M?%4;r;gL(id^cAD_lcfC|4--mrqd$KG`e&W}F1bN3zriZ1N|SM{L*mOT`GE3sj~ zBoAKL=Jr8YI>WJ5`^4w~C|EL&uQImD>k@ISLQjhXgN%|`cbap6LGOKkbznq~i z6IC1E=q+fstZpgy&8Ja~E=xQ*bnVskz49TrLzf%k$ZhSqb-NpJI4JKVn|Yq9QWvon zf6AAM;iNlPte#Kz`BI&bVunGtHGILc;mNpIjk|8hOTj^)L!367D@(F%{mq&7UBiK_ zY2wwSZK7})S%c*|3!ziD5X{%1@Jqm7oHob$+?90DR;4%o}I%-a^!HKySlI7NorR_7?Z`# zVoeh;Rex8`ZvN^>SZASB*i|WaBN{#jjO?+%6|j zAr?VNhj>VWQZ!BqKfRl4g6z5|H~@9Xl+&{lE#u0MjxV^cLCux`Le5(6J|Ncb)=Cd> zBKxYBNTWA8@T0%WCC+_)&)*_bFJ+jlzA-!*bCSCHS<)_W(R_1-1mPh=+h-phC#62H zq$U#y8L-aMuhfEC6sdC47JZX9X5{6li`0z#o&Wt(5$J6x|BF=GnHgt)zH~ybVO2F4 zJ$~^76Lr9EzgQ9QFtViwJ)oYv_={4f6=9#eTaRBn0Vp8Z9D#$$ETi8poywTcM-d9j z@|kK_vp-*b!U#6{cFO+6)SZyJ*3^?Z6nM|&+Hw>6swI`YBfN!}UShikR;bj80 ztO`CUBYyM-@_Df3gO81V54*pAq>oS8aCx*Q#&Hy!41NGC3+KVlTF;{`o56KnGnVdy zXdsJY&;j{T*=N65yz5PLuNsmr2k|!-`!r1Q==mI;k_Pq|2=WpyZZ!=0aqC&ukJ zK2Q`Q-#rU1_02A7T-{HzznC49>t9b4 zJ(IH$>D@Fc)kVAHbAY-hTWFT7>FDHJ%BcnV7Hf2m%fOz&*xi)TAHQ_%17$&fCl*fe zwC0F7B)34d1D&-0!v&Rf{NpN(4|hJg_2sAccJF=i+0N%*-qR)MD%$rujws?1 zPLZd5rp%bl4YemN93NhilN1Gb&XZg{TKyE|(zY{aysLI>Zh zYY!Wu^Wi8!5R80EuX8F+y!fCv%*}-oBo$Cz<4XE7Qj6V(o8?!3O_w{W~td9;{3FfI97!Eqn zi$m5;40T^g{QNqHzOLIzQ*mcMxC*|G5j5yc7{ZIWf6pbdL~j*#4Q=4R_c&@PkK-b| zzejAHZ_ZTq4b^m}*ep3aMoKOh&yRE~0Uj4F9U<379>BnNF(jkwpjuQCpmTscRcRFk zhRFIE@^|nsTFwT-qM_}4M3KwRhx7x!`^D$C@9v;!agTuKQoG5_QL;h8oYIcs=2={w z)umsYkn1$Rdy0~vx&e#dbY_7z-*~b}krv{kR1tQO0u?uWXipv0ICL8{(|b&kC3!Br zs<-Sv&jjwxyz;j1>{jA6yJm%JG*|>#gN4egEVp`BNQ|u!M}z;w>&T{JJ((RP z@?VrIr}ydRnzpm*LvwVtBoc_M`SuW2>>xsRZppv;1Qbi;aE7cyl=UYaTHbA#g1^>u z*7B-_wGml}LMn}myP43Q+6B1fPfx>zLkJi7zZ&a*l1Vy1G6kDN6g{0`Gn!6E&Ajrc z*`9Q{dRN+=)FtO^!|3&j#(%cbzpFk{Q%cv#*5LOCN~;pcjX~E` z{^_82LM|Jc>Ws(!U%}N+`t|0I{UgTxfVQ8IoBP&od z)7VlM;}VJdm-4Wh8leK0W@`=nQ*NhupP5}-%cDPWhar{pztpNOGvv4zg#8eT7@Kry z;%`>D^x~J;)ChX6lY5Mm$$M+MZ*fkIzY4R5BQ3QCl4#B{jYy>Aap9-kS;LEQ^ljLX zpoYLW&1XjkZvG?{&2W4dzY_bSfyB|{ zuhrY7{qnQ?h5I?V6PF7mR^I*jbj0c;zsMoNjy+Ak@$}?zEam74m&UlQ_Ah_V4_?hX zYW#x3lC$~AuYUEbo3_KU2>|*Q*Y;rfMK_h*1H6+bxNV3hJLIaK99HAX$o4ei6I`L? zu3XZd1Pa1zBf}l`O!tBX?YAF(^B5P8v%f>bqUpT-L?o;SOXgcg`k8_cl;r=TRgzt?ee&5f3Kbta^W4Q-;$~s1U{XP6Yc4=4GaOXH@3a_ z=#*+F|7=NH-vqL4I%dITE|$vWGv=A<&Sx%E*l+1)sFH^?9b{Mf{A4zoPp9pG8SRM_ zOURFf|D#7DJ_JVl3nmSkRN>}|kL`xz@$vTK17f!KG?bn2Eq#DUg0~Kk5GO8dct|7p zz?|{>m0It`jen9v$*+*iDK&z74^kRUM*7>KODA@$Rj|H~${*YrfvrDy%EnFy))yka z47ou>4UC+?#L8qZCD!u}z!^bEC0aBQ9=(R`qC$3vbbqZoa(%0= z^=WzZ53IZE#{m;c(cpjW*<5T&JF|yT9(>n7=TDT!@~Sye`UmBWq|!6K^H9t~f@FK~ z*^%r>4_6de6G;b_y5WL0lBIs}&abbl?GC)vrB5+0mTK@jl5Aj0y0Kd?ub8w&RPQl^ zU9Db~-?m}<`<+j}*tt_~`%YqcDeS^J8w~8}9^Q+^*w)?lJ!`{eby2RWy@GMO=v32K zrlh~5a&F07>DJ@)G2g+CSR|3ete4@M{;*HDvE~=c2 z((st0;{>iz?f#y@d?~os2BoC}rQ9Cjm`xlNIGg&0#A~l{ZkuA9gt)*4d?V@a;HTzn}FwMdpO)J)y{)pQ_j#yks+7K47BW zc({MFU0c=}!EGS&uz9ZF8Xu$+Yg5iv!0Hc<^j1G<9xK$=gggps-Z(TKLHH_1=yxmF zKl%ATyiHRv<$$KjhY@y63_y2nk;}+oIvH#0aa{gwZ3^p;o9B?y`iM3HIBUg_#dOw+m%;N;jct{5t4Tx2F zXD`}~2aR|0^s?Q=_)B)<0i$#Fllk57DH{^wA>D2?U~pM(uL@u5i8Lk+CM-+;4gi#vsDSAKr}?EHGEh)~wsmQZ#Y5k4n8(vtv^9v%z_tcvt2-I4h)N5|A zmu=mY#Q~YN}qF&e-|-;)GuJ)~~sQ_KZt&wM?-Z+~$1bMgWanimK5@NkQJyH4KJZEnbcy zHJen9k*<(hZ0v|-9DetZOaz4euj!YY_L{*bgI(|D32K+0-}!Lo&L_7&*4ovLVC9Gv zveCewa56XIf?ZAVcQOg&M|l2sE_ouems@R(A9r?cefUqif4udHGjSH1KjSp5K}6=0 z$9($9A9kpzygLjF`r3SZ``uqKo`ulsN6GR|ndtA~)vU#7@JJ)@ z9Mav8a*k61+(?A8s^eTLZHI)b>_6qxl(>#l&i8ag;f=LHg0pR=XWkVQLC2#5;xdbWt2K!&g96qVSRkn5?hk@|_OswlV_d-DVR?9Vx?FCl zFTO;(8V@Kgiqi<9Ry&G)CwE2J*~Q7x`3?OuydphX?~=;zYMt$;b?;Pp85(&Kdh7c# zqOaDFQ99S4VNBJP2;(Gr5AAi|p>^#zgpWWSrhbV`E$yh@{Qr86mi8(MU0$k?iZI~3 z1qO>Lvuf&*6%`Z_A801Xpk+e5;X+fv#Xgtuz~IdCGG!z>>kQ|yHfIZcx;`~p7pHku zMgQ{Trld2U@|^PDCpu}+Yi*!Z)*&g#Q_h8-e|)MCZ51ptC8pQorIo!PJxGgkB$Kw3J2zL$4n_$=QhkO(2Y!bYMpW46qg-DnX@~SykGb^6W zxe{oJK-rHRiC2#=_9$RJ=l&m`O{eF{OIHb32t8i)>6D|s$%F(yx18`u4p&l>x>Txo zhWH%Pr#w&QPi617$byQbsUcsA(2`YwNH=di%0nZR7U-F3k@gB!2ryzGp=O#NNA%0b z!i@&7Y>4?N106}q-RapORD)%^DBGkwQR}mrBHD;hZ`k6idj>`?CN7OlCG#1sY7%9$ zAwZ21#jFu7IgdtU?`hG+x2kW}{6gy01ic|C>y2v_;khD+iru{kMh!@pTD;R`7aoVOn*YygxC0+j9jtcqa;=dhUe(97YjH+E%t z1XOqzD|QJ^x3dO0v<-9RcYaM$)gS2WZGL)!zz+gN4S* zi-Wz$VB7$iF3a$!TzuOcb6&(=${Isfecf2$f%2R;?ssP)&hYJV7UIm|jZ+cVdH-M` zKdSB-(Y0Uy;vdw3T&*}sSx_t-`~W$(+-?dA07Zma5e3qPDT^jxxT;s+2l}!u!;yiR z-QqiiOu~(0JQg4T+<=ayax3sw_}#05d8ixLY|&nH?||xIM~F%$ojE;22ma)OeEQ>4 z=#)w@9lo6UBEyOfO%?T3wd&7ds(~ww>(8iGsQFjmT3ynRdC#03GC09I=luw4`;^ET zw0n8JM^=ReI-K#xWq#6(GC%NNDra;pfIePfj>Q!vARv!POw*~Xq9S)QH+qI3AH(eU z=o=OK|Fc{%o$wI#;d_X#y7Yz3?y-$7lFkHlPS;h>! zc^Lf4fxj-|`5KJ5OU!Et5E0_$BHd?DxI|!)MFg-)Nqckq?MF|-f2u)NPiNxZG>~(y zq$-n7N&1VVi=O$+C+jJlrdr0s`@&zH+z7h4M&IsvMl;y2C_Lpjs=z^QWl+VGDs|<& ztg9t73$F&CvDIif)$vfX!of5U6BN!mZOb=p2mwbJygt&(v|!X^@Lv-x1Th`78*@fW zE#iSG24#-LghDTp5M(Kz&yloAxaXd4(WZU4e64X#O`noA; z=8)n_P5objxc|dY-@<8O&2fzK&+q{4A4+Ne^XPF2o_0pIzJF{*HTj`6rFMltDPp4( zWg9Ndgczx;nRz_J7c=#2#5aReu1jCOj3Z8YOSyzBnYdn)B1OzRqWDAWglR?f-3ffC91kz}#zm(ZF z)p&kvd!)H<$}A3B7$d^oOq&vt*Ufy+1&mDz$w{OvletBhh5N{cxM4$~nGn@?p99T! zt+HTTBdd?etsB#=IB#a%Onq5{-cJvXyf^8UvESuKBKf}((+i&lvgm4-*G#U>k6HHD zi`CBPnh=p&e_X`^v$s7GsHz5aCR=KI$nS_eC8+n|@dcF@xP^S}c2qliHYbma(?*=w zVTIMwXw!b6hlgOYgAgt4LVlujon{z7HBcmvg%`?g{lF~ldZ3=xkjTY`J0deS$nj9B z&a884v9al^MnvUdc2!$8(}fx7fssB3UoCp9iI@ze;qtXY|$W z>sWQY@4swBk9UJ5J$~RZ#vp!j&oDo!lkXm0USui|kFD*HORsKouQ9b7pBI0WYx~e$ z(!;-AK1#8)wVouqoG-VSVd6!< ziE~x{)kv1JFH$0f=;?E(N6|i;!Y7r)K|M_rIhfZOig_g>5htcv45aPwhyw=~CpR7) zozVEv#JIQkJ4vc7Im!?JPL!t`*Rokv^`DHnB#~Al3A4iJ?`e=bO+bH8g%rceln#c@ zczl{&6x4N^?5B{r4sJ^`+{<4dp|SiaW2ag(%wM-Q$8=I`qrU{PQ3nmBOPi|+Zo1O+JM z;{bvh5eYOZ@+I+4Aqm&jEEd@vR{ez}o^+?U&Zuo66e5L);sATN#PI?er@1Y_QhfMIvV&_chu}$S%$PYlF<+jmd93c){Pk3t5;!~GJxt}~GUby5%QE(BUduMt0%DpSf*{2d0Ubug; zd@i?8h(4u5tq9k>PQ2DkQ!zW1um*9up+d|`{K$G&y}EjnsXn~s`e4lky_$s!KqWTQ z2Y-F>W((%wr5%!-<^fjX+EZ}GO1p37?x2?hiSU!OHI&0zmx2vLtihs<9b|BGc}`-s zRY}qcsJkb#I`s(2*h*vFYm-G=!5g*_fC8E8ad1(K0N6)$dp!Ux6iaohwfPH|-L4tH zBZxW$Sx2Id;fyGAC9E#_-iqLmDXWy%F}-nr@F|m*;2cqcERRy`Q_EXkx-C71r2yPx0QJ~@1*1V%l#2S&}Y z^rHrC`w3cF`ywL;09>UW(KjS%McCK+H+ij|jX(Vb{iH@yAOxfF`=W1Lt2t<^ib(hxXH4WS<*cwO_T#tr#~cvaEz#3HEb8wZUr8Yqzji^x}r8Wyz|x@U#*0QZQHyY(<}BMG8v+} zQ~Fc0c8!=QB?EaTokhEjVnTmDj^)J+^!gWPSo!@340i`OMi0xmG1H3v=`CPRKPn0T zHP`}IvAWPj*m(hq>nk8y^`al)NMC^?bqYJ%1^jQl2dNwiwG{@eM`(-ULw$N7d$IgC2{37bW4U#l`} zDLQ72usw5hEiZwSUY$_HN?AnVWL#$Q;$?^L(E!Xq2xb%Q*~?9(=_nW^NxAcJ$+UE| zHa%O_bzxtA`-y%n<-60cHQ%6I*X7M^Q!M#KY=Sgvc|@|N>*d(Q$UJ#%KgMXb`x{~C zhjZv>Qy z8a6~?{EMSE;5uE?O%ol0;8H4Q#qtP$1bN+Ktn%-LdO|N@lwvzcPXiL`2goiuwN=#* ztclj=2x&|wWhd)vT>C})!?E8Xs5#fy$3Gp6*pfS5B(@i~ti`*Uv%9&97yvF|H5CmS zvv07{luNBQDbBdoetb8YsA+_DmFtt2)|8puyW7z9WS`Hx78u*V!`RN?(Jbq|JjNMs z{U#p}GR0jEQIsUWqL-8lXbz)W`s-hjD{m^oE;k=5bZAw|!L5&*J44td7O#dfKAm9W zkvqIM^fVyMaK8854KwcyW4g6m_rSN`$i0wt91)hnSNyqeBfIfowlzfp;HrNQgpbSv z?#l4p=k7*pC!8n+2bu%oV)rl%dndSVCsXkypP4yU||7aY#FGpI+ z%g1syb$<7=Jucv0LB}$yN=r*EBLD1qR;0J<<0km#=Tn(uvo=7@M5xTigyqvuCTFqO~ms zw`PjIaz^7tU|N8@T1tHK1sD8p@EOB`abvKuJ zIy0xpRmeipOaO2SK_o`c#1WXhqAwBTDL%87FP9VP$5+pLwOekfmrWJ+_%21b=W|A( zX!XSgrajJ9GZpRQ`{K^$@9*r|`=iR^A-OGP(^+43Re>?9K8$JJYjD>_2E~1@5_fYI z9j#S}?(a9|$JW=o;q{0&hUr@Kv2?6$#&0!tBxawUe$zQKMoL6FY4F$fu97*K@`>p~ zXk#Hdew4Io(j!`(V z9X{DVJW%T}ORYZ~9X^v)Brb?e09!jfrYrD2{Olj#5b7?c_&IIIR8wi?iaS2{FCOoL zNoq&t+G}#%wS^%q$RplbJNcq zE&xtSF^m{CD!cJ_=3e&?FM8Xb-Q9C31_0yx{d`;F)q!rK)zT#YZR2+TjvqQOvh|Mc z7UQPM>?0#|+*n@F=j|l#-wg&x%W-Y^Vo3~ZBcQI!mG!mL)8hn2x0{@TG3DDJi%OB0 zjpSKM?ig>3na2-^az*5fsF(d9FIq~=frztNvm6;#WJSNyYQz%q-T<#?3hmzjY*|f@ zU>bSeJo3;iG#&vm925dYBy|e7nRH#&MZ>16Kr7mWf}{hs5Da~}&l6`oB7f<}`aYuc z9pm4X@nr3C8`{mG&O5@EIT*hbzRh6R+!ij(V8mLs0kQN_;zb0GV_^RCbvAPyI2l2z5_rLs@81e0U zcRv5LTG;=1H_vK6k)Ye;?&UYn%6oJVJE&`oRV^LD^S9Cw%SA0_iRF;!?;yl~!;E+B zd6IpFt6y$ECnet~RlrxSwu$i?*&wl7MD$5xRHqI|0I2fBQ`%KEYR)1P)sgkOpM!W$oO5COlec zdl`#%K;lYN1(^hsN(I1F_L3d}ET1oTc6DX)=O69fxpiAP3d)Q5a$uy92jVZ4E|Ov8 zSTNp$q{>i*U)QjE$sLTt_p$n&u$Nb>W(VWY-hpZD=PJ#ODna4GLgY)DS#@h~UWaw< z5(MYfRM@%n+_@%%qh#4J49uv5Pdd_?c!hN_#uqVV$tL~Ib%iDBBn|2c!;Y!e&&tn| zIwMF--t0VIX86|7m1N3{JHw5YZi27fkJ4y9{Nx@fHv~GP^_LIw+QuzDqn1iOnY2T; ziuu%%6w#^57bquvNAeB>1vN{T)~Cpi-0I2zU&?oO?V5ISO9RFR+gvKJ-S0<%iz^M0JnU}ZbEc*@3}(;KUq_E)?vY6P@t}SL4ljQC z=JKZ&oN*sv5S0_96~Me7qeh}tu7_*aSinouSJ;VRFGD3`up2U?5k8?@c>LhiUG`=S z=wEb*$lk5fM~~)T9S`YWR+MS}9sgqb&jp&Nev4ma_u^<*g>=~Bvs;`SSVsF5c*a=! z;S@Lx6V-~(u;^*<8a5QZtn}9F3k@VWaunh|C8I^WZ6nW5Yn|hxf0jF0$E|87)kdrY zKD*wBHd3cX_q94(F4ROaTl#T>oWF9IPM!#fPshXWzw?#k*I-(jbg!1z-mgO*${(Te z&!x8MlqJ5lS=x)Pf!DnPYfOb?wq329f-@WhZ6aG5MIQ|h*+qiaNfrhZyg4bAF9kBLORsB^2e3At$}7}1cH$vdRoKDgkXM;yL~^cBaC zw-VRr`LgsY|6M-LHQtBdBrwn9B-edL^_O+b3P*IzCx>V* z$KphQ{!zpizCGnOi?Cu->AA9JOyn{!hgaZWnqUNK>+ow1p||UBOfg?D0wk zH+3JXt8G@b0AI7qrM+?k!aBf?5eS3=x~Z7d17724u`fkOLZuB}uL$ngC#ia==I~@8l7Zp=WE8*U3x95I@LK0EcM#beyr_<& zXm0(^Ooe3zBTtSz99+-e>nY&jKUX-@Yd`o<+o>KN4<{secfMrDETla(s0-A8Hhk|) z@^y}!-na3|Z9Hn9e7O6?tvk0qyK8*C)~y~T?Z3SMpHv;u)*sAR?g}a3^;}7o%k!!K zDY13}6G31^D2gh?gT`%}HBkk2iKx57I-s6z{(PQgdf=O5zl@>A;3!*!hXR^oPEuj_L! zX}bQ#5Pf9?wqAP@yU8C7?b?b^<$G~<3Ca2RWmEAv7&nv%c&)TYes#yDRzBeID`;ka z5t_S-aGB|ekrKvnzp8X7P?0GDw+k9u$>E7BKlTq7P0Djjji6uCMUth0tC;+1iu$gd zbv?rUb*SsTCe*JEau|_HDx%VGGv^6TWV#S;asUS;-AQ+y0<03KLNZlyRGeu2W!P|b zkt`%C%L#|Ck{l$>+C?{znZ#?$_R$~LUWsqnaXjR&#EbnG@ta&(v1yoXvA6eJ zRaPQUH;_m@F>+HPB)BnHPZiOdGoNN{r(_8_zx;j8>8>bXnoUe9x@1h(0t(8`Er%N~R0JI829?1KqKzBMto1>0M zFSWr~Q~g+XEp3T#3B_?D_Q4Tt6hUH_fSa~@Rf4`9Q2kow9D7o$WnmIW2isoU)w(b;7*cD6cO9Pz) z-?9Qk^EK!8tLDGA+0sN7qO9>9y<+bBz$tzNXONaQxVtJxe)z=8sh(?typzw2vOnkC zLRC&IWfXi=eU~{68IVuNw#H3;LDLuTuAJZcm3uEJV^!k)GK#yL@KU?$-zO#d^l2dk zmJ1Cz_MRYDhJ*kf){Dec>yGcfVc!J!yij>a^!LnW%cw!RUuh*(dYF2`Jub_e@fH&R z4#aSsNL{5+fA1b4b70~@a>;>T*Sq@CF{FDtcWzUX+FGYv+X%t?^0(Radme8LvU*`6 z^XSM01sbccoo^`FFW45aBzgt^9?n8 zGxjbS#91?tr%#H?EA6EWJFRYJ*10=<{vu>DrVmMHi7LtFP}}~f?))yD*bIdu;eevG zckopGndmt_HMH@RMKu{1@nhl+yhF=4$)(@ubLDld`jh3$-}^=-PNcy+>-e zyxUQNx%&ipL0-~uX}mbg`Y*$UF8Qpv($K5%g&spG{D1x`j*uWf20Jbm<}HpUT&1_a zkdR-cxm0HVr7lgLNu8U^Dk$4PnHwnXB5VOv20ofSo&C_Q)J<$u+YrW+^RVGjFK$)Q z=|r$;vG#gW+1g_BQq&?fFH_h&a^xRIm1?@VGUx{tN-`Rvn2a9$b)^h}=sD)K3L6GZ zA-=1>uhxV*oB^qtv6_KQFK=f)cb*^Xq)HilL1}|@o`O2O=Bor)r@Yup(fvJEW@v}r zQ(Db6ud*05UN=R5fH5A3;Sa|c`~f*8RAAGd^wl%@WB-V8@<$}fhhIGH!e~FyZxFvm zS2qwkquYC9#Xt{7kDlv3CJct_0_96+g?P=?UZnpPrHBoV_jOPMUNd=dJ975>mjZ-_&U4HIB3_N&yZrf)Jr+Limu@=${SW`bE$3g{ zW1eOnbenwW>KWmYJcO}ZXtLWj9xvidFGD)nMz=@KMg@(42HIIqI)w61$M!UyPL+i$!f z#f}h$opZZ>se+(`OD#j89$Fvn?O!y!?|-0KRjn}gh|K&TTbXGcpYHuhJwm-~IErqX z4woNYoqqMRUtT9;q)s4z*7j;Ug4$BkxDglbv%NMK{@%FZEjC%7$d+IBp9@IP1z)^) zcz$~3dCnel;gx=n`aF3Fr8?T%B2_gcyWSqzahcueKlGf-crG`*jMz~rEw^Jek1^X9t$Vw`Uv*I1M`Vj2NFkn8us&Nh{Mb zY&>R?ntPYNQx8Y}-G*#=X1h9naP51Ybo0Kj-p3Vg{9$I=hkKCbEDlemEzGD==Weco z&FnP!VlVUs2}@k7#J#AeXSzUv)_q40pKmYzh#!_=^cVEi!G&B>Y#14w zQ!{xF#NeLlM$<*!%32%2dG?5_&QA9Cj=7VPb-=gYc{lFA6q6N+#`P6m6d>T1&q5#1 zB|f4nbJS2DV8NPC4o;uZKX|wz6+oY$xSzR)FM7R5bb-;L;l!-Z5p`}k>jV)EEM&vA6-c9-bk11q67*TY zz&G;dnh$qAx}}R|_+MfMWE`l$tD3rec2@}Y^IyFE_AqifV)y-jqKnaIpWV9sp$3!U zw($_ZFuAJ1yU{!~0Tv)f^AY>|dg}qg^uglU85f@|W66LV%zFH_*@`c_<#GeyM*vkG z<%02Eghxm7`v89)2k1Ay#wmSv%C(5TMd*{yzWDskJ^Cu$yT?V;pWXV`Y-;BNqM{rA zJ|S^8(#R!}L3jF8=>N}XOT)@le$hSs<=fuR#`)ei-sR!p3AqOv|1*BkR!QhPgh(GA zAJO(`S80~+u-AUJ9<7#(yz>sw@jJb8-8~0vqSm0axG*`RZmpAvZE-b%T`qpHe9uzK zkSw7F%?tYAwlSAhCeffflS$mC6by8Q4kx5EGO(H}kRnhr#S1~ti?t3`=~^uLmvPW! z?ChNwvdib(2lZqC%nyl2JFT6Ia3SOa8cfxVW=p zm!FjUPUkBDD~3DwGZ-5m2jL}2%hH8_|Pd@{7h;&sutwK)m% zULBkztFhWiwpVskt>$6))s^bNxNE%fr1hT)k?QW>e#dSpg4`VO))Nc7WE1 zP;tIq8Y8SG)YM^rP3QmlFYBVA(Vj{+Z+2TB&b~W|;bDWpj9^@c`8V5H3b%jv6GB ztqunr23rp;4!h49c7bk_18SPKtsG8R`kzkZ;K=7Zp)Nz_auO5T4Pai?S4V_rtv!iu zxk7#4Z{&6XX$ODc9MkeR{sEYs{Bd#8_SRGKu$*p|dN^87{=UqRK|cu&tiX#}?)(_e z=tGd*#O$t|XHj-aCG#2G!Z~T}e>T?vu;|yEr;N z-y##r^XRTHUCuc07+)*vS@gPAE4p8P4Id;6jI9y7zkpz6m#100Jlfa2A9RX8@rw#Z z84|Ub!rcvVxrU`;IY@df4<&{^XiCo-;m0-#%*A20DaAqSVNo}fQ_BI#=E>g92S!E( zJu3_|CT$-}o@4h$JL-C7dvb1^Nb+u`znxwz6-DFzHWJ}#0b8&+Ams#yRBr@bbdD`( zEtm%;?FqV1u)C+nvY>SNNq!uF1E%c#hpNZ{QIvhCEaiuX*1f`qQ-FjU=)P#prV~;7 zx^(i$vZreUsdTpbm8@r~$?3@2K1(Hfclm7Z=}hwsa65ZemYE1oK_V(b&C(E0UaU~W zeChbPFvgpg9TJCdc<>&0VLZZ=Ih5hbNn$ABTtYKA26Z1wmzJAKmuHGT;u1a}=3*1n zhtzr^#;6ZWtc@$_c*(o^kp?3f@UOx*Ll;yq&GL#AX=tP z7f?j5*Lujrg9e=s#+qYvdRs>y0!vbvE_{Qwe0{7c?rNt%OL0Z@+A?gyBIzMhyK`Ce zCEH+vgiu;FsC|P&v~rw{Pca~v*A$})rLqf&^Rgr7`AU#5Kh_x}xQ$7xFlgR!PkOC~ zhENTo%uu=ZRRO2YCyquwfYfd*?@I6vsxUgDUBiPvaB!VsN~9*qx5$`16}xI)8#6>G zoF)Z_=uYL|t`lnn@Ufip$qLNlj;wrkF7wzn0`h3Nyg202MfAtTEjQRyOAC4|ESHZi z#1E~fPPJOCiZeVqIXu&q!{(iAumHt%kT- zl`5pC++4#Yj&c9x;LK0rf5<5jt;f&DN8|2}p60JBB>~Hr^$IU_UA*`jr*YOpNa6zi>yS4mDB-jO>ZWdJ7*AXaoC9KscsgNjW^mx%+Cwaz@7!7&_+v1a0O zHY0{7>W~9)+Uji>!h`N-fDkx?jysmLpMs;7t17A>F8GsX%5SG_tdFU5jn5nwV!*eU5@#W*&~ovZ zU*0k|YIk{QnnxGs?g;-(452zUM_&SLYAmHQyGKK+aRGB)zJRggNPFn2nh0-JEL0zp z+&SaoWH^9b!Z6kS(*vA3XuA(j&*0cl_x7M@qb_`-IAHQE>CuW5%6)>E#(Tf%BWn00Id#DrCPV8A zlrp*q%92#Wl6{Yv(Z?i82CR`YIb>1kZfvOB8|5IV3WDCknD`*E15)u*nP5V?=5Zci zirc{vz3sl@-QSZPV#$7a^392kIp-^dE&oJ@gIagVQ442-kO7?J&f_%Ewj}(X%do#0 zo(J|9=>-^tqCut8gG(?ZCc2ekYD-s=Ad?RkAd+c>A`|k#wjXcHYKTbG2#S7vd9jZ> z)ZpjDGhYsIF5f_e@`iPBXP`p>#mV9F;qs`JFP^CIx%`>JJuhUzLUhcv4Tv|csvZtn z^zHS;I9JEXsm#z=3lS~PQ85MxS+$yn$2N%s5cya|St!7ssRSF&nXS`DTl@MuAR9Aw zQU}sJj9VapDF_i@@Ks$K9W=wsd zb%|&&n}^9^hno~`{k#AEe|=~B?J?u=4fUFHPoxyBkGDB^O* zIB&jGjU>w-(TR4D!4b{3!3+y-YsQ7Sce24REi#nq?FsU0!~n$CT(wz_n|z0wVnb=O zz*Fb+7M>tLaueK^f&Vj^H@U{4V(sniU-&#-T6#Y~qkfWku+xCoHhTOw?MuYQ2V^S7 z=Oe;Rm!I#x`qo0_ar&=B&o57;^+R%rhVZN=W^}}(e@v_c$MckFKkQ+=@6Jd=5PcO72%xNkL+rfGXl>lzJy-YvXDKK_#ijhsnaMq!WbcF>)H8b38kc6EhPb_m~V)M6Q_^_3{%VSkdd%+4| zIKy<^*MVY@XdqH5ATL}|_oAIjX#G>@q}Un0JNOIyEwk<4L!VA2eD?Jzx@-XyX@B|* z^|X_gp*&Oq<(4{XNZ{tx!PiB@iOr6AJlT^Ez!3~Gk2ve)H@Xde@8bNF{ESqs+xNob z&{T;GaQSeFLy$DaDJc^khHs3gtdBbOwp#IkQDV||)M+UB*?d^56Q%6w?t>xmNQ9M_ zv)JX)=_F2j3LSWWpaqtCG0&u`z|aSqN#!)m11tF<4i>n*J;CEAO}?CG`4X-Q-XV z4He_?OiUiCsP&rRaV>Kiy~QODP7jwSKMgz2?wh!AdQ6ASo?UE)rxrC_lx}CG-4D+W zf0`~zu83Ab=jlQPWLu7MK$*|NMHFw#y(7ZIzlcxx_z3UH$V@Qjz>dIjW@&J>-ryxt zpPM75w&$)e(&gZmKA>694KVF)y#PaF@cofskX|}KEl{yb{uI`CIbm5hh+6sWS5&rb zzPY@vz*;G}Zy5^PIN1@tDx(`#qEfoTDOuY6X~$P*%+GYBIA2h_3A?^D?6Noq>-U;2 z=GA&ZdLH>Q+Tf^xRF;i${eF7-M9vq!KCGUGFq9J@h zetsY8Vj&Oy~!D`t2Kg`*O61 zACbEvA+6m&MKO)Glj~ia%Qpl*tIccJf(=%ks{B@2Hu!C^EoM)mgcYELU%Y+2+TBbL zdIqC?07B_SeUg7qaC;yVaJlK?u@F!={&Ali)8avH2tNF23<@`;5H!3*;R6i^QO%Ig zM4Ae{nm^n<-$Mzpd7CNmVkr2rmP)$;v=Jyun1U+ldObY(7Jh{7OT@L#FI?F|fVUf|;f zp-Cm6Y%FdpHX^~>e;pI^%R~~rnE-_0uur9b9}w||+~j`<4}D}@IdR5`nmd!*A3Im2 zUa)gBW(AL#=`K8c2vv}3a`UE?)rQy28nv~eH5CVFC+B%=M<1TSxG72~;sg;odGzS$ z5C>F`eu);wzOkhvZLJ-L6gp*&KY5zf8&nJs>BSaH;u6D#=S_%5YC#}qC*cEggD)ob z;?4$lMJUV>&y=R+Tml}>(4Zh>62$kytH_naW=&Ge-BORt5J>#-ng#Id=~?Itv7)R1 zl-iLFHm~aiOYk)1f*&MYe%s$r926HZUH3FM!BW)BU66-ASKXxli>I*NsFnQp# z75@Hd?`$g-B5_pH_d^ZoiT|ba>XmV(Ru%ftb-64;&Jvy!JC;kvQ<>k1Af}lv=rSsR zS?Ug~Y7G!L2Azl#3U@3qS{?^fOjfCvaIoOScmA+*$6>vyHF`^~a?IQcelp+Txl~p5 zbSH|aPz78U)hGe|`Q(7?ljuBDMOEeANq)b72sjP`Hp;0$#G>wNZ}EZ(PGl*9&*Im7 zQRon$_K0j^U5@eiCF-5FyJ484AX@)x>yIY}ghlI#15Q1{BB~o|7O(g!vd2Q}2FcdV zEMWJaoGj>fbNfhy2Rz>#Vw=G*SivQeY|Tu<91uAkvrFDv_i=0qp>aY`@OUCO?zM@) zFd}vKLqSsnyTX3;?ApeC)1Bo&>~8U9foISJAPLl(&1F<&@p;wf5wt9{G12|2>%K+# zT5q4kl=2Jcc4g4=_vO^InfwX}@^IIob zm}`95R2GF=us%J1{#NVdhcAwmD+&~He6(t+y3GQP4z|QK&Ei)`+o&I+K-iORC!igl zvNT{lU|gW;w8p>GXNB%!ua4Zs;809GJE_%dxqsYLn7pXL>HxKxX~c`+2Eva`&ugmZ z%rR?XoTe-ZNjJ{WM3bH@-4uywZS?l>;mPCkuOZ*x&50wUTN@0ew100C#zshQ5~G?C zW7ijdzu4fIg-6<`%osU)-CZ1W^r_NC#e%s#sp3i?N#phHV#;TBWbTGfNOQnBfWKB*MT3hi&+!Qt11WuArb1QA*St1c>(S;Z@#87 z7c^^{uvEMoXQ$_0k!c=jR++!bQGK)L%H{nF z%s%*IRyHdPRf_yuaH=zCFiO4W_C- zb3_)SwpLF-B=DZC4etys-1^TeJ8~9cHB+QdPUh~qG56ypuq(Qo0?&-2dpIH5;49Or zCU<44(k2`72`jUB`$cOhrlS0{@eYu0=R2j?;d>)SN#NI+J3WbiJa z$(*n*CYcbA;9F@;Dy>;QC5=U7kBG!yWGW6iBb%aed~qLjb`s~H84W4T%kyPBJb|2h z^Qdgy_^R)6*IzB^^tLMj^0Of1Lb%$Z;i>5DS}^v@X#T(kPa#n<2Be3XRl&c1k%-f> z8A}0xe5< zeRg;t^^*wKZuR+aU_H#u2&!k25Re@_-4m`Gi?LaAi)JfJ`V67J^bw7kJ-R~ZFDn!) zQ37cqd!DGg%vGIeTge&K4wyW_9;B`#8+FJhpfKIz<5S!ZXx7ijYLzPFsiadU%fTmJ zxd{rxwP>j<>^@Y2Bm58riylqnmy!Oi)q1YT5$NMABokyIh;q6$kQ#;aqsL#LD~?ie zyaKbDs04InG}O}q55RZg$m*FWbj%AjI-7~sg+pBGnVT$a12hq8O>=>|=4q~bXWET? zlVp$lmrBHR8(20G;)i>4=>Q&9yC5hNT|I?QG#+xx&cx1gf4^YKg_qX3fD?uth_~`` z!AnhPY|ZAXd+O%x%}ZZwZk&Of^v*PshjuSrhnlfA$Jlj!ZwO5lWnPkUu`X*xq7JM4 zDgM{tI;z26s&i8o2(Vc>-dWllCJUxY7P>63*)x5^8H@w5gRe7Rfw^bhw$|!M^PPncAk~qNU;wqWEp4J^xisF{jXHEt=BxYRZ7RmHhO=DT7yu5>82*T@46B(k z6qod(61yEng+90JECj_XUdkyep^NxuWAG1bV-|;Ie@xP1po)<=fpPi#2@>|~=plWL z&Q6KEh7BCW0gj1;Akf>D?S&|F7GNc-MDR7ktOzVJJ`T=~Xc_j;a%}G(K9plM79hdY zVwK9e4W&}scc;I-#`%gZOQG*i<|RUbm&5|iX8J>4&Za+&LWP&Qd^(SC>qJd0?MnPz z>@N>{jE@8Nk#3vHPHlWhcJe{bMK`^po*!ADq$)$q^ltV#GF9D+u6CrgC{{X(l6oFo zhiRQn`rwJh^Af3eZh)=?mUSM(Cwt%RM#4ogx?g)qk)4m2GdGm$u@d)S89O%UQ2%4r zuwn)`WXFn>dIt-6~A{7aq+jz=zQ(7;CDc6_31s^ws$D1C+FaZIwxH>a7*rnS)_rj4KuTuU`g+D{n1kU%NMf%0qfO z0xRP+HVTpjbTU_xzprh)xx7h|4IoUHE5R8(zvj0>u4jvBq#rjEnB(`qYD(n&+pE9G z&QXgtxrpn{6j#Y11`vB;(~iLZ$N%%c{a4iy_-|j-tas@D`|$ku;G16^eY-q8{n^o^ z|L_0(@Bh#L>HqrA|K@M>|JUcHq!xp)2-)$=8!FG$&9+@HV6c^>h~?rB@1K760ck$; z52Dqc+?F5mB4Me0ziZ(|D*`7NN@{%e!53wY-6*z*Goo5BY%e}nD?cZ1J#!NHm!D{| zxOPYr2tFie5q{83TuX(IFu} z-&cm2dP5TtR+$j=_yfJl+~4Ot%qc818-3(P2nHx$kXq!<6WKsHiXC&1z#zQYYC4eJ zcDk(r9KmPjJFz+igkAcgV8j6+BNa!2pDEoackklh=zQzjcNahT*)QKV6lL~lm(s!X z)hPGZ6IGm%jo!~BexRTB&+Ij$7SJD6g_9zZ&uN?-7c>g?@<`K*TQ2*9N`*rA^xt`_ zY3rA%!-ZSDpr=i@6nK|iFzSa&bt6Ha7r!2D50wF>7qXwAsM_K=kNM`}4tXH=*x9Fh z`-jIv8`wDWn~M+RxP0Gt2MX?=k}0@&fC&Axx({xjpVDGjb3Q&Ke2|CE5ukf>Jp8xw z0t>Gw?~uG@Nt({kbptv~^Cz@(<%&YYX(1HZdiW%bIGF_LbOIq=d^HP^9=PUYXs#S& z7Hi;3cvOk$>01(Ncwv>ZsGJH#2L5zN38>2Zf)JvtRBMZdtV(21*UwKsxxI5&fsN1! z6<2g#B`D;ADYdFSTzqi{RQrc_TiO>9iq_6}d2Yb-nhsCNGkoNIN*IIgM53d{Q|w!5 zeNMwuX7Q#d^Z)`;9B0dy!cnj2&`)4-_=#xpOnQDgx2B zI^sDxG5=BdCIudcU++=;FG>T{+EJrqiXc=9a(bemm!S2@0YN5uPvaRtxd-K};KQvd z08W0RoCCPgRcxtTY`j4Srxy_AR$HDSs_DvabjF>qEJe7H8R1b0e0ug2%fPUtzMm!5 zwsGs&>y{gq9y|ES=GLVMHJz3D$nM1v@#FFh43D}w=p_aNVeIDb?;a}d3$KYY<3+15 z+PS=6hiAKIVaor$mmo2CI#gIEB-rMHZTil}`j*b!ttq!Hl7pxuCh zUd+tGi?QK_-G*U!V=wG6yzv9GumLZ<^|$~7hJgXRY}gC?_dn;{``z!02$CsfO6n># zx~h`#-S<8BydUC@s$q>Q=p7vOG1otuOh;dcI2}R#MWwj-?#`V*S?{g?tu3j z3*S}dqSReNv_jkLxugf?i>Y%6O6vbJ=mJkF8rUsny<|ZX{7h+C&&jtr7A#GeKO7b{3sjpV9adyqaW^tWLCk9p$-{b8sr@ z;kr*e=5F3Dc#a1eOe7YH3R9={x@NFWZ#G89gfDz2{i9@#OCeLLIV~?Ae`S*KdHi(- zF<=3C#9)%cX&+{{wjgP&B--Jh-RNP!Omi0s&9O(L6P8tO$PiJnupC<}f30HU9B(+o z885e3sIX66$_|rhys;Yl#A+UXrWK2k(k~JWd_3X9av6eSy>=70;-6)ul2cwLV!Z@f5}=h(jw9hF1&VmMyJ_kTm&8o{m?-(L*%XQ{cN;W3JJ7hp;Sp zc!;`!-@&GUi?JVY=@_Lg>^JMx#-_(me6q#PDCuP|2l)3w{AaBXHt&CIgA%B@MlK+7 z%B}QAFqO*i)7rz+)McZO@891tCMXBZdoL{$3~hNf%sGZ(or{E-(59@Tt(q0fm@ih5 zj7!IRjIU%tF2Gnc1!Eockz5~%y!5O1eBZUvv6TEM7U=n^ly?H-Q`a|@eFLp`fA4*&i*TYT#}UsCtz#RPo%Iy4ylcP!?|Sqc+o6TDz`ny7_8y&{ zlER)lK}F7=PYJf?5AlnZX3umZD@b9z|x(!*U8%sUyMsd zraKbW8cT73F^wu-rh3WZl`onz(0pUYI$>EpZ8ewnDkYO+roGd#FsJK}lFX4mW%+JR zWC*Tr+p-=Y>J?4kbWcYo#vHN_xlOcpa**J#em0_(^(8}VCMg5NB2py3V7N_}@ht~K z!;ohTOpcO-ASTXYTX`)E(+`#PY1&%5lLm820QnWce2P2@ZZstrt|5C=(@~W+5TH$3 zjYw~M1u(*&yphUqRRa!F+6L_qbI9o@Q;b_)oLulc zbXs=GQt!o~X8xgF5P)jPm5o-jC> z8PX7GKKtM5=h#jHrueh^E{XTWv>qWe#=K6KPz0jZQidwXD_89rLo@3dzbA7*VwOFB z6?#b2D+J-%F_}iYFSd${_Pm%iHd=KB0CY5TVuklfq=N23{6QqH=^u_s;A0g)b_JuP zlYs39=eWvG>FV_t;0GHCmq@lTJwIm>TkyTk(as(jU#oRgxy*2{uXi@q*EO%QH`or0 zX-<#8vk4(QE3JzL^92*qW#)J#2!@B~wl>h~`I0PY&?doWzudlaAEm3Rg((jrPev}u zIBJ=yhj>bheq`Oaz=9!B0fl|tUY4mpy&MKkVPZPjD@-_gKnVh}PEu9L(#xUNM`ZE( zKb>9V>sk{LNf$(GKdU0D^40BD+CVh_aa8VWUo{ah#08f zf~Ql^34e^_Xc3yUVX%g`>PN+DFAiUUjypJpdQYgW--pPH7^x7ZWsZ9Gh4 zCrLnQk#rc=;P)4uVnWHf8*4=k&jpA(@UR_af>_2)^BF*!09k^xL6;v!7CT*klKQ1} zHWewq`wutX{~a_O`pOm6E~S+g(`qp->;#ew6ToEH^QvMnSdaW=WgNsoef!3`J>Lv@Qez8(~OR z7sJO9(_raJ!h@gW-Yf^Yi)bo7EZo$kH(IR z^^9(D(Be6ucQ>&)#LChgM|6LtkLj)rWN`hK%8i;&_~;TF;)ZFmTOSYcg?`@J^6Skk zuIQ~ux^_TzWP5dcq+g-n7w3RN16{0n45g2#|D1nF$2B^^`i9C@es!M2o{d^$T5~Sb z1u&X`V*bWG(EteraB#D6T#c0GwME(GLX$^iyP#Is81v?2h5sStsy5)BPpkVQjELF^ zl|6YyE3;!VS*N@i{so>}3sy!OY@ZSb&`whdAa5XDt!c+Rg-vKHmE-@HN-WMgxC26S z8~f~v3&E1Zf;_I;R$-S7cb@>=-xq8Kn*Ybf;`j?n6RUeU9Z*;@p#AfyamM zFPD_SK+sA!s%c5Ft;>4>YN7gZsVljv6!6btWCLRsU^X2yhpCx;cSHpaVLUbKpnt3q zsc=TgvJS4W<(T%47hJ(udgK}?^6@Kz)nX3qZ5XIDPQs9x&cbz>8C$+(SAwYrt?{r| z;HzFbDTpLyHLZ`< z?ydJeUcbAwzTI1UaDVge=GyJv20!Wos-$E@p^NJVn+R6}t+v5D&{fHseNlEGQ6(i% zennfInTE=3(}!S99m@R?;yq!m3f#*PCR?EhdkJP9cStc8y%R`)o8!Stu_l+o+6rd&fPOX7Vij`H3p%WJg&VGyCS6!AbB_( zpMf$dBpAYxaB&b<*K+J-v|=NbYRI3;$32l2aAK@Y;cUy zpn$xelFPm_nlBA$Ll+C}JmIXKnpi2yc#IzMW3->too-1Pzj)=tL=T2YMQU zAfgj-xN+Qer8zchTev>BLe($@k-i>zL?} z?|r!WVB>b%QF`K@8qIkH{O2_Q|JTXAOT^~=G~$VS@doHTF{fpet++bR9y1; za+zRu`kVyGXs*C+;A&X8vlFUf z4M@01J{3G~tL#a8+>QD1Sp8*hjTSeSY70EKst6kust8;?*9oaa(+?80a_#0M%$&p3H@Cx-Z zA}sYDO4QEA+wYQOJY02xf@?`qP--oSFWRBx=5Py`6BiTLc773Y73ox-8*)$;HN>lP zzw|)$LrxaWZGU=@PMe6Na7SqA6-=u|1_xj=m>x9GLt%neGea6=B?5t6GDhFVM+Q;Z zI#(I&cyT<>?S}j0mu+DVBCLEUYOZT*a=Na5)3p5MeAidI#tGYI(;zClrUFN>Z%V=0N8GS+jLZ}#<~gr@)(3D7LvIUeh{EiZcSGakD1%ife~qO|4s4hj93 z?8r3qRk$q-obhx4R*eX%M3@>Wf}~$IFb-6`5L{UV=FF5YL;6r8b1|pfWJ*n0?Ln>K zSIp{WkJVm4%1ze>K-+3G`ZR9hplRAtno3F%KFg5`Osz02UeuOyQXX^n>-bQks@hKzN6=E2zBo!9i^QIoe4* zHC5))qsmXt7%!4OJ_Ze<3np4dh##aQr+o+StqfvcCQerXeba&Ttw_ zP_`Xa5RUqbbHMU5$nVbo6auWhq4^i|?L%$8(dydr9Xjmm*uQEp-v9aE`S<^Ja@SnM z*r!`rz<5`T{b2l)AAdeN=nvjGeDtg_!TSFD}?8d zq&|MV&f9MZTV&>bwvfAbPPM{E-+)g6GVhaxE;1VRJrOQxk~qu2T)6~ws*i3C!XiPY za+ZRur9^hdsN~#-=(+l&o4(IWk<+wjB;XzPTm3OhFCyn$g=qzl;T>>aG&0mo2G`E) z{PlN@K%)i^(9AlQ3B7?5J43gWSW;#Q?%V7&yp!ryp z)i%CsC^#)NQ}V1c-X88li5ArWO*pcEarWVq5I(Eo{!wS*O4?`55;T77Qia#3u~_;a zci-7fN2oQaN+aTcy`-qcHyz5v6hj|jZNTRNu1<@h>CmddW4#zjj8Hb{@NZ2Zz)B4) z%DYJ(tN<{oHrL~`XiNqB3C!T>5tfdgcUvD+2f;z9ypC3G_5hucphap~J7X=%vMGkw zvSc^#=%K6XDhOmz8LCo}W}$xMKn<}SDez3Mh3ccV%X?MS2^?5I7-u0|XQ4M>!3fU8 z!h{co=aSJHG>~H}1L!*&pRC=z6CCYMuuPUS&wVKpU*>|RNqD>Zx3QqMFWwLi5z3+a z3DtBg@12~@uUA-JAVo!C!WP~=Yp3~h-hVp zw3t8ufjh4L32l#D2+Z^>gfSzf6ZI*A9-1FA^?Flr(|X?`fxpWlt<62*CJomS8+47A z>w)Okz@vYOy1-Ja%eBhfkQ^0wcwTG~C9mj*L@Fu_4{a>bRmGr03GHH%weL;Me&dV8y z-p-wSckZs?mi1}x{>PLI`*`#2?XvRZd%59*5BzcWKG@uNuv51AacZNm+MNd?atlw;a$bdmON?wdiSh;JRV5Sup|G`hFi)mY43S)qg;=~~sWzCWjk;QdF`|g=sEk2R z(8a?|cf-3=$udQ(rTO8A($^%AS94br`Id6i7>KNGRg?HlV3r2IrX1tA{ieT_&^$;S zKRJW3MVJq4n>2zLv@F`y?o#k#$>N~(0mxF~q5vfyxpLWy*Nl{)cKdb0z5r6w%c;K; z(Q&5MN%}O3PeiYv#VD-R)CtpCq8UDxnHSsY;HmlSR=gOF#y#90r^jjP1gsbJrkcQY zFL>YVj~g(O&WbPj+=?~Yt{K7jv~_c5@T3+X=y-N`q;ma7M0r!b0V@N3JBDq=t*YYK zs!Dwv%D)B-Z)FLi>`mh^^TjWI&$W{^^EFVQ1F%OQv6Y;Fa=Mx89(1%k?TjR3Ebv-@ z>U}2dTtbd4xq~+V8F}0GJ5Wl2BtO26{nGlkNKdeddjrV(!Oq%8#BW>Ko2z2S-Vt1u zxz5t^iVKS2wdjz6{BroaxEXPCYSJ|${;a11?X-J->*Wl#`IJ3Jf3RBrjeH~qTk#g` z#|G+Oh-lJUizgk2YcQQo9*_kq6-zEqzpg-A7uq7CnU@FPtM%!`{}xE90YxxS)y?0X zvL;j*MI-0d6%o{c1&traJ&lW5y0|wF413x#uIh!YysN{_OY`cW7*l z7CTJ%=ReTrMKoQ|;9E>L^w9pqP^FYy@ktle^QiHT^s=2kWcO-vjBfQGzVjJ4aF73q zQ$ufS#o_EK3W#rHT{7fcum(~X>bNt4_8YS@Tjp3cK*y;Dq$PUzZarrh^EKG(KmL3F z_Fp9qeo@G5;x|{T*X#@(3Ydz4-~Y*bgCD>D&f$AUKYf3%F~jcv_w>*I#@~7EHT~yY z9S*V~7`xEP&=a$(3xjvlsQ2*pFcSn0u479|?dWsbD#45h~sLh}X(c<6t-c!d`%Se;{i$lF0F7aF!2ILDT)m_$q+;aOyNG zDB6{H0j@EOKisJ>w*Mn$SiE3Qql~TvO&4CsiXWLgQ&=?um1RZ`DXY=~ad9eL=uS1R zN0KWZ7xTU1Z}a!P{uowc>aAiu{)Z2eFi!98=H^!K9u65hTGN-W zU%^sHJ~zl@0@kiI+1T8WcBU;4$Bvmgt|Xt!$x<`r3esr@LvQx!#4~bA-rITTqDA6$)lrng zgqS7W#Hy-&K5!>GdSS)~xT(*FnBrPlsYrJQh2{wu(02t197Ar!*-|>#iv^3j z7z_{kD)@L5TuZW*4UEu>$SFp}Ogwm-d#Wo7Ule}EIa1J=`j%@AE+{S5cVofo)w_&- zoi?Bv|JLp+bkmJzTo_{cwf#b`tXqCl=k&IRx1hHntc1*B z5`$gOze+*X!9)x#L+g4p7c$C^`dB&ItAcc9W zl{c{25{p}bfERm@F~KsoreZQ}nd$Qhz52-w-hEa-#n$hm{OX$e>*Bp)mwm9_+k9}p zxA|djdu`*Rb@mzKB;z8LI$9mKD_ZP;e~yC|;V777BjWj1WP>fjFy_3UibG$p5JLuc z#xUe4%=4IL`0Tde`$j0M!Y+`%YDRXz|GP51T$)#SxQa5x!7^yLMf@Nn7(vf`R|P*A zjdQQ5%Q9XiefJ3*E(%d$%SPpqrFLC!QX_sv`yhQ~hrs9F!_f+X`8KYH4V8^edFmg7 zXBAfWe0t>);Twf=GlP_uR}SE9=r7w+yzI1qSe1ATk1fz6NIw?lsQgn!bniO5kjVR$ z=8zE+=Acm=aAkzx3w=81kTA|)m1fMk*|E2cY{&Q7~B8dYoLt)yMD zwK!&-if!D~5uKv7pQe7Ov4!ap^dTygYBWRNBwn#!6UOC3@wgt=UZ5;;sxoSf0=--z z2!~W>yfmPFif639jjrgR9BeZ?hsDphHIFjBIm=JrpV$K#&WJ+)55#4B(hKA&r; zo<2N6?sxjguCE>ZMK!shDL*6H`#-#CJ`??(&do5C_wkM7mx(WSeT%yG^i%A^I_7h$ z%cBFPS=2ni5=MPmq(e#uE???*!WB74zZx;x$1b4X5yd4W>FWBIFdYR*h|nZ1Cqu-N zZq=Xj?p{MtjX)`;n2o#DxIWr57v3PuZFHXZ6R#-}!Z z(CUR$x7M(mC-*La+Kbz|8_EQ{9?Bhbqot09L@p2K+RG$QK1q*}R96U9~Lc+?8F4bhjVS$%I z3y?%z>JuZHf<&-2~uBN`W3&}=)1^R(twgOb!|n=kLR6hQLtG$v2DqXKiiIT72p zgfZ|269qMOvUYDg^fFyeHC?OY%J!SB zw>Hp1ZGhWvrWf=UE@=ccwcgrN>cg96-9?rB*3~#P$%@rTN_dIe9KM6?o9;FAwIs)O zHKG@Y58ve0Th+R-H6Mtebrvq~TI;RuO|NH5vX64Hz0%HTM7{#slDR50Hch+5MH-wM zm@m}gR8#Uq`TtPi)Rx8*qXwFkI0hWFFr9#qW_5`EKn@@7E}*!mrXgzU z$&c-2^L-k)SYTpl(jp3;PyCkY@nnS|cM-{v%$H(VT^dn8yuu-eu2@-R)x4FwEzI}@ z?N>5SOVh8=n7YD+vUjlK3NMCrpAphGKDD%9b`pbk3`-9~g`tqu9wZr~t?>WFT(W-JG~u z0Xx^4oI4*M$V0Gd0DcwTj;DE0y~*2mZpk}gZTr(ps?TaKa9#N~(k#U*R2&h66Gtf_ zUrjCS+g5hkwk_=7lCv$<`!AT83Qvl>PIL|;<(h|E1~gF%7t5jCr7A@ zT5AI9V_9oF{3I~KqqI{-8i6%sdY%LYQGY6=c1v98YNs0jb*(CbmP@+ ztR?r=JUymDp@FQVglk|}1vFXO6C5>&v4$;2rD@NUP?}Sds;qWx4QcN4=;S-bP@qtp z_J+?8k*$0y(lOCEnDtdhok9n=fG7zA&ylGPaWJ$5&^*T?G*H+~V>vpl#DF_W~M>#Z9=9H%?hh!G6Nw;dOkWI7)pwXdH>P zmH8|YX9%6HQPX*1MZu*Xn3cOwZWJ>eIz-9~(%vUdhKFK;t!P<_@JJLg(Rp31D6^$f z#qG-?N%M`=@t2LNQoT+kuNqahj_%f=KxQQaOQn0X5(BNlge0SQ`YO5`?VF`~ir_!3 zM~PeH&N-SX>=<_0l6*yhyv47RY#lm?qPHj|Rpj zXcDi}peyL)gQiY`($1{k;%T}P24kID}4x34(|&4fU_bWNjTt}hCkCBvSK?#0qt zZyTY#=fp(Xe;6OQA5ffS3rx^O%7wT5H3zTm_9F3P+H+S>s!f0Iv>8iKcCpsqPVvq! z^VN%&HJ~v8Q^?GJ?U~dGK1&2D7A1;ST~OJ!lEkYt6`ityG$=ck7~^W=<$nz+ubn|Y zIg<5}%#+4U<~U*?VzF$&thqeVS=%G~(_;v^)W1|aF#X;o1^b4MLvQv<`z?+5R;5QE z#?kZ|934#H6C#XAj9qxWX+2Wnv+B;`wZY^3p#O94(1H;LK*e7sJ?NmO|GS)+?I0Rr zM_tHvoGv6<_yfI&lpWx23^6=_mI{Yg4x5lTEZ^4@sO9sHD5J6tl}gZNL0BS?h*ts!vy1r2?i6u z#`DA~w$ZtE(^Q}cv8-CIS~eAt2tU!i7D{P4l`7X=(o$<{>{bTj;R-V5OWPI-mJ+u(mV6660@Ct6Yeuz zLmAg4s#J=o)e%^38u3=8%vp{FV6snjPRip;M6WQEx)jgR!j!N_Pye9h(J3cokYvcu+E-I3GWq!60A^^21GiwwCkvGlvv)e zwux-vcx~q@(~%ywY3qxV<;Q{9mHaHktyca`g=e)m(u;2(XDv~ZcfxgV-Cf(+acyiS z-tHW(^;o7RB|-AJrFIV+LtbgJ1RBORHHO~2`A|hpf&x{<7SiExp@}jOhquV9YzL>r z)LKRkmU7Vo${~eHv1ue#Z?-pE(H1Qx_%1v1I-jDh#u1&{x!h;H|F_5IK#{G~O2$v< z+sk$N?M9&#zU8YcfXv%yzu$U*ny#VM>X5SnMR?9xA;wh@=*3^mRn?*s$rbu4LZ7vW zACRs&l-1l3zi&tve(g5~PX;nH(93U9L@u!Sf~Z#WxN-Yo?V9}gY7ny@E%6#Hp0|Zc zc`rP}XE^`eY_+$s)nW87WN+y~lpoOJrWZX$lDmH!0j$VHC*L`K&>~(belQlmA;Rvr zz|EHz>sd9V717sf+1$GNz? zMOygew<;Bks7n#iX$tv#S<-yJBtPIh=0YFf{MF(lOE=&u6H^yOPB;NeC5z$TB|61J z*=DT{1qDFecl2~}W=>3|Of&v9d@s?-jt$RS9;+`&10j2tYFNmE=f@_zFFl-_=Jc() zrI+B(SS`1*L|Cng!lO}(a$66pdKjf>^m>v|q<h7ROV8fR9Yxb(S18KyE_}^Wrl}Gk-Fd#D_i1l zex>c}c`J=x8F|QLkd0`Avt|gFRA{y~S5RNYB^iHx{yTq22Oit0;Kv?IjA+l~63bs` zmAZXK#yolJ1t9xUTo)i|j5-1Cuk6ma(p0Mg1bXO0lEAv|Tc;jtaRc?0wqgS-##B;u z+Da&@<(EZpqOzBtKE-!0B?t6JUeOdK8*l2qfXS;69=(CHPT zu_j{@>$GR+%!PSu^(kgKBv?)wh83rS1C6(q80jrzxZPYo7)B~Ynd}(}T?l&m$C%s? z6rveSyBB1(v3N)E+NHAbB{h==nCa0!h4HZ@bNK`LN#;PuRB1gf0bEPp##_GL+MAFEoTUt$w|WZn7afKcsie%pB8!ox z@fnoF=t-KX+_mOV00rTs05T6R-rC&vgvlx=Fda@Bm}IPtb8@`a_>uEYJuW=l`K~0T z0m60b4#XIK&X4SneZw+8vC`DV4cz8hUyA`s-+cmq7lkP%fd7Y-Aw~AGyB=4WvQ^|< z4-tM^Ath6Srh>-JS1$*~cFK()Znw;flEP1mn_$>)o-#!5I+8rZRRfPH*+rMCKWD=p z=ulPJ`T1f^@59QWfZ|2he6y?bwb2o zX^4+|Qn}T%jOTIs2H3xCKapf{aRAZ&Q60>6BGfjH&%fv8P5nt}oA0*nzgs zkFGI5ue{Tpv$VrCR^9!G!^WP>Hz|t4ik1c&P|P+*LX1s#h3Euv!s0T-%jaBM)rEpG zmCetO!sB@4dSh2XcKL5`L`X50KA=3)_wRVBr5W9~!&&fX8*xtZF-hv>;;=;!sxVYk z#Sit%l`lQg?WR(bDD`;T0FTe*-4F`JT~-e87EI!B|CK4A8^iE2(<3ZKthRIEc&any zkyPZsrY@J2%&tTb5Ghr7gqDu+^T6X~k*F@kAqbVz2$X3AqKs!t9DqJl`KlTW+>0iz z5<3faH#Z{Myu!-dBJ z8jTe0aR|yA9;7RYG^A9wxGEk=Fg+S)!B?5BMWeJbk7_~qqYhP)7;l|Uk5>M~%9=xz z71o0ih2zE*>qWA6wK{J`SkTN;1U}S!W&m-QI*I#njuwp6u|i~jU2}Xj0L(2oS#ibF z%l|edmOWethZKor06TXkF;HX;2|khG633YMTL3iuGnjW-~nb?DM&~?ss!}2_L!R<-O}E@WDOL z8H_R@#E%~$_4W6sM1%R{s%$_lhgV_;hjQ{>RPEqWrhpI=(CK*9ggCyUKX}Ido8psI zQ1~WemjlcVuCff@Xz*fy=HOQA;hPLz4v+^|e17H6Gkc-&U7{f))uJKt40`POfqtFw z7Y&d{XAPzT|7KGx9YyoJWJrIb_3`is>N+zrCsO~iXOIVNRDg6&g2JXJI8!C$S)G-k z8ONwSH~4j1AA@>CcuhCCqOrUI39OPrA=BPcbt!sBT&b%p3zepLousGLXhH8ijds^% z)ZIE(x00)tquMY|8uLg9@j2B(3=wIpL>F?Mn9fkeEso#@#RRr?ds6RGG57nEI!?JLdmyuZn=$$b_lZ>RyUNPV z1wMX3OjE<bUU69!R+*Wbf5;z;X(R0uF`5XHQzw)^Z_d~M$Wv(j0(frWOf#ZZS zW>48^O$U+@#HH5AP_ekhqb(SO)l{ceod-KfFrBiz3SJtgRBa`CE?5BD$Zv@GFEN7P zC9l8i?jteCrE17 z&6y5G3pP(M<@8%YN+M;+Zp$;6$TM2fpyLEb9>k_b*h{KR_DfQdDhL*J=J1)!k>L7B zK6I_MTSph3V#Ar|l-{AQb!-n0ZYk{qbOh!ZQi^v7|7)?aAKl&jVC`=2<2yU|H@81k zZc}WRO0pp0;+Sv7uejT2q=OOZuAxjTp)|BQ_N0iyP_GB4s+@>4Dt7a%K`KXmWHW0H%hq z3fepp%n9Tu`7#mAjOqM`lo~vd-XP0g$pe3OwojrRY7sJcpPjxD8BCN8tDAT_8O@E0 z0qF`beONCEhrWkLE;f?5vc{4%T(W<7MqIVlGuFNh2V^9lNZu~Gy5R|L&XLS0#iJQ| z%D6?A=7gL^2fgDF*;U4enbl?ZL2Ea5FQdV3GN4-E9DJD45%H!BGjP+7=5B#47wj%U zypdu#VniA{g%Nj;0y{xxo3Gl*Fb?4v5$G3))1P}TWR#A^U90?jIA#r%`KI$u#d0?9 zF4VQ{>cm=i{MrnnpMXjgMp@pTTdn3n5grZK2+)Sxqe}RYeg#j70SO>M>qnElJy5fu zdIVVE6`KP;kUoA$2bPYP@JI|-3C+Y}79=!Gm<2P286|qv1!v={8(CW`e?S>*gADSv z!JLSfEo*p*oBf`Z60b|XnT%(&BdpTLu!Nu@!m4X{x(ZxOCm)nh}l=Eu`rW^dG@^UER+}n4OZqC>zp!KR0)LbCt^Qc3pj37> z9$12IKL%No(>>=4meXRf&-8Td?EMN2=M)FiSi67WjH|0}o+lq`wdjJ9NgIpJ&K5V1 z<Hj*$Q=pZnAEiLGl zA;@G*ts140j|ISFR9PD!kmZ6AYO;2L0!=l-B?pnPC@Y^vh%7G?aX}?@tqgV`MEw#A za@wh~H>M>mzWAx|!T-T9ff%=gdlySCJ!-*`tA2Ino2SD+JsTmd0~a}cLY?tJ^Fq<% zSV`&LvKZAwXY(9fz}T2f(Ib`N0&<L6;QtQE6WZz^gBx8Z9KlG!j5?Ha{ZTBJHDw$->;9~V~f907C%v$ACm zxX{%ldNdD_)_Iw4H;f%8geU#ck;r@<<>|iMb-*wnDzZofZ#g?+^QPL)Hw$7> zer`W;oh7Vx;tF+~v6k$GSJi+CLLnQ(@lxM3#$N^dA#oiKR6 z@k+Tb{j3cbcg4L1pf6;_18hXoy7iOx-}2% zdi6tbVp@b9*@qxCbLEk!KSPy!G!V!AsDC2eFgIG0U@G(^BFYH{s$CZge+wEh>ozMr zSc2CLG`Q>A+nd`gW-X~-xfNsvAzD9oQ>2JFl=XA`Jg!fP+P8l3PyN$><2C=kfAh8f z>H44j-}+1cd6)mdHcZB|)x*hPg4uku8Y<-3Y6h`FJ=#4R4fy|?tE>C(d_EnW&E9+e z*=+D$V~YQwd-JdUtBu!Q(|=YjH%0QNI2`=ZDfYeBhtDXXhYGvWZJV%lOU|gE_v48P zP0`B$RANm&9LLhhl{sm|EP^WBGJX_(E;mIvS`@?0v2A#OS$HOnc|i}{4JD4bQ!(G7 zY7HeM9E^>3T{4<+8s0e+>PAW51`UBEl`;$c8QlELbpEKPsYiBwz<=Es#4tCyE?;8% z4L7nC^KPnk>gM1X5I0-_>TrLa%Jf`iVU3aAQ#l9unfZFSbp*lb#`^jibB~SXxrtWE~CxbItK2E2Y&~b-Ul-6$Y;@Qo~NX6+J zucCn}*lX=SC!I)Ksj0iUBwpVntQ6rag&42hrS%9WgdJbQ>C8rWAr6P_`0A z7Fyw1)mJ`hwA<6s0iI8RlH3_fgy zenb{87?Ir{%~p+d2110gijlIO?$QIf1Zm0u8|U@cMII8y={*G=ZYHQmj#ao=j7~do z$~DtU{AS%Cz3M$39^|HZfJYtChrqtI$&4_l&nq#VWVjE=GrzN!ZaFbtHqcz*nDz?m zaXNN0sXnp8;(UrI_o9WkOvzTdXw@8F(nFurbTm$llbdui>Y$t_wV~%b-5-lf)igTy zn)0Zzy6Rp;*~I!l5o+o?vm4JqwbE>Hf}XiTZFx0u3Wq1C{blR%<~ zc)s`+7R=vjFAZ~cC`)*sN>m^iJBs|%MJg>)sh^sfS1Od;-FfgqZ~gWi%=f(q z8*87e-MMR4V7024!H?(#t*472~2;WQUFP8gd z7K2R!fuxd!GC`g=m#0wgp*VJ+1#&&r`XVgj@;?$Dj>N>aj<0t*uozvm*>Mb}exYpGT-rDus zt2?lsr^6e!S1A_*A8hw3`6rE1T#-aa*9F)a8P!cw6rv)?jR# z6XyM4Frc?kHo6?*X&TDzd-$9w(vhoGF#gTiRSk@Zi&XP*LT^-(}(5}IT z+o^epkn6Y2s~{bnY!+AEY(0$Ia$U6lVP{mC+iZ)Ot~Jnn{v8l*Yb`xbBFJhJI48HN z;>FQeb}bt@Udy@SHMgTP&O}fLoS3Qtfz6AwAlEy$ds}PUYxg`AV}Z%O%Q#v+ADnk7 zO46@*45H;F$m{3`KyacaM>Ir6vJa?>!rhAw{Jyw@(iM0Q-H%?wS`y|H3etxvx++nw z1z}ZHPs`P^vAMAxV^poq!&rYPttAdmhP9h3@um7_uuIz4B3GQEd4m-geC#wIEg$5IiCBfjQ zd~Wu$-_c76dh&VFvqlfYWC>j)Cs%JH5%}BBpXtQb!eU;j>{JHcluXmSre9dVLFhag zsAy4&7zM5`=yfS(6fMe*gOReD<2@jqxmS~bV zi^Yu>7Wzt%aOP5^_@Q4j_ct9KK04LCpUL>r9-s(oe`XA4h6+=WU%a#hl8_;sB#Gtm z>N&ZhgPe%JV^xwFR z4qXO`Cvxs56AvcD(KC2!)c-SV22!WH^rcmVKpD3CUK*=n)PwI<=uD0 zc^j)f-)lZMSZV{_Y)&T1hAfa;Xc~sToM5>Ck4mEyM^YsTlY_z+!zo!gf}x)r9H3md ziG2#o4yka^J7nD1^N$^En&l>jK>zfkX2D`k_Rpw*BEPE%l_{!${CK;mOue^t=Dlp` zzl$la|Jo3XbPl;Q%Pc0v?&B+^NsLo(Q2CE78DUw+YGk{b%+)tYaQ>ZX+s4=-5{Fd$ z(3tD$^gc@mf8_C1KZwXKC?$Z@S$=!cYXZ)1S9WDq*ru;9yI7t#~#!tpU znygWZwh3ujQ^#LF$h;EplDQDu{9{61UST2(o8eM96TTyvH?omcWPbqxuQHas)4h?T zQL+n=^KZOAm$a;uv~wef{|!ic>O37E_Q=T!r$o#ZB!vHtLy}syL-6%*K$qM;lbeE5 zD(BJnqH+PKCduYW?ec@c@q#k8g^O;|guOxwpV+<(94}#>JXXc{dB$YtwXz8KgDEj- z)(S4{2dQvQpu--u$R^tm=Nz~~sQPZDLXnRoUV%-AvJw=i`JL{&GNLRJ=8`P8pw~Pt z$c+kVF8>zdT>kqdyz4yH{JUm5nJk}SBu@F@k_-YtmI$(q`D${!aVAH) zkBG&`J9IKl#xxE*(QRoKT}~e#c15hA;ptl?dh$ybdle&&zEE0V*~EAH1mWRpw>R|s zoTop3$9!5&f1asMXZURY=xjE6GQ9dTc?ms6!es_LbNi<&s9a9vjG1!L&TKqf=SbL4 zaufT*(NQN;h1QRsR<8=g3Uy zF6+jXW;-2!Co!v@A&f_UB$W5|5@vCwKx>y z2X+4ZS=BpT-a*eNT**(7q{9ipurBXdpqdx!I65Wcj~KyETph!J4LoILa9iCMUt@Jw zK93nVkm96Kb9{oaXEMFf`TW_n*5~A#`uq#+CgnuAsNePIr#_4B_>{X$=G(M{^R9Q% z80ke6Lls*%XEdivdgOo2^$MJd&_=eZ|0Y|eml&cm4nvC1|8_AxKSMs{fBPpeq@@ve z#K?ES*UZqcH~6iyZ(H)jS1Rk5=^4L|_HE0mOem6~))I|yx=@PJRV!G$$WU&CWkQmR z3;L2}+1gmocayN6(AasirZy7#yFXaGA2k@-i_OX}!Q!3n;3cr_X{aXG?qWZQ*9}Zw z!bBCcE}AOA<4c$*4b`0H2mQhnmegIH!?NjqIX~zZJS_tZA`ERqaqvQ}$`aZOy=QYM zeGWy$*EK0$R+Kgh_k$>1%ZEHCN*hXgLY2BoqzU?2|8aqsle!|SBB zXIx}$;-?Bh&Ts6+1}VBe2* z_|Q9@bo{ig?p|vhkCYfe3wx^SliCmA&Mftb<$WXyU6KFCXDg)EOKEjA)1s}iB&C(rikK(uNEFqKxjvT# z*dLRvm+Ukxorx{PBKp;1I}(xZeyP*DW>5pI%aw@CZSE^V(k&2txADQVcWQZTNW*o~ ziP(YX6D8-oRqRY@Gd(v2c{%sktmRRCiVUM!d|N;{VicFX;7J#l2j!1>NChhQ~jC&0x3N}0dLe`wW; ztjMVilHa@~6AFRAFi#G0^k_6=M(fWb9T^N{age3pBAD#%4G+l`(GxE12$xDTuhDE# zc4|7*OQut%FC1{uwK&xj{qv$xZ-fQPZ9jokdei8@wFi3%1+MID$GpMJU!LOv{vk=wgjzQ zHI1QEQ?R@ei)28;eio?OZd7s^h)XB0$spJEMG`rOQPv*^4QYhxk@oCR3GIj!6;ZNjj4DAoD{?_37Xfj;b|+S2 zBW_2}ady7a!2H0v9#4H0I+TKDa*|0>^24T6y+gU|5+-y9S?sUD0Xs{izId`x3R+0g zv$nIbowL19M#HBklj-To)2UJ*A?NFAwaT9f;~EVhlM30T9MM15-aFRx!qN>F99K}mbVXJ+@* zeC^dYXRA7m)zitU-;iE$HLwRepEFl`)z-Ytl{5#^@qE2U+iBl-#GwPQm|o8<1)BG` z=YVY$2#eRqD{T-303ZX3b-UVaSvd0*N{?1I&vj z(~vQ$nK4nTs7LTCLR@L)b<^jAr}KRiw^?0=iJBq>rLL-=AL4dENa9O`4AqLKlvx!$ zv+A|bc7ubve!FZ6E3>fGjan;Mo$AS6e0k>ur+Z>_QU|4l*8`yGmMTg3wki0*&kY^`HMeOy}ev{CR5y z`?;F%&$p>Nrwm}}gq}{OM}w8KlXH5%UG-jh{yGxdDR~S%gJj-W8JUsTl_El;{K-9? z!`ftA;3w`|06YO@4tzi#z#<59E;Uc2pA;2Lc;`*tmZ}_NW!y^gsJ4pk_FqEuS0X%c zuKlv+1QE`)HK&kT2Oru(4HvhrFNfxdSY3Kc*E@4#EZfcX&Vt3wZ{&K%Hj;;3zKLj= zMx|k#wy^;9BzqtHlfU}EUNFg?t2UHQt4Ol<-t5MspZs+1>67XEPa8@0-u(Ce&#(To zwbx$Lf396F$)5RlXof#pnq!Z*hQCt4-_DSTqa%#)QxPu~cwVY8PYec5Rges1T83$ zT<}h-DQStbf2NX3DF9bmcdQf~@ouT4ritX1FH($?B}Kk>YYXuRC#-xXmj5JcbE=H zv=NcHsKMF3bRT`LGM5~()OegnG07v(=BKmtgnP`1Nl68fK<9CSo`KIna#t0o89+y~ zax!5qK#rz4A)sk)a&h^z8Cu|B!Zl;Q)w)iO&S0opr{t#)7P@B6)6DIaU-V4bswtv0 z89As|R8Vg?T|udKDi-r9IRuYTRiG__hut4d_s`H@u<(BCP~L^0dFOV~K*+U^;(*$u zWy3RFk|8oFofscP0h>bEBlwq)CH96|CanJ0sSoFChiqG}^88g|ySaA%XSD`+L7vtdk=3;u$rpR%_u5*! z|8eiu=DjVlxNO`{M`^=-vVM!qU*yVt*najL;x)HgEsbjPEaqAc@)~s^rE?;G)HM&QmnNj zD*)zq@$n;bYkq;!R|laWa*eVSnZ zh?rZl@%z6~9`@kTU`uRk-TdKd^m0M|5_zS*lH+qoL|;D()-0CpkY#H?>q4wV?f1en zm^AFBTzI)Lu@sX?XWxVGezn-QmN5U7;$GKDrbm%<5PDTbs@`hnMKav)8fy!m@*;$!6a=EQXKs0(J#v`{}Hn{z9TWyXgy){5_fQ9;d{ zW5%2eosLMO9YmXGJH%_(4i`k{;RlCEexw$V?ur5w0vz0hG=>+k!55eLD&B-uBy?y);`^P zjHO*CdS~Za#=z(Y#@GL7h2Mzc1`DC38Jzi+vx}K6+5d_!iDO^^nMr8wF@K2N_|>QV z*q8?DW*o4eJ7r64&bs*aUO8n4&`gCE}KFW4reXer^ zvHCi5o<~U>VP(LUg~S}27PwD>Zl5frgE2fDFrF5+c(o#ZA{6dBkKLhnezyH>TF`8sFiJg)lVz=+K%=O zQzb7%a|Ik?nn1b!wEx_6?<%p*A=xk=Y;XQzeItm8Oohx!0?luep z<=a&d^B`CC@HTBXgS+P0&qJhA<%Li}+X*%XQ%-O5C=Ro^Rg}Ta6VGH_i(ttwGqaHi zG@`EFO(h<(c6Mn9f<)x+m_~r#dCA`W4ZBI}2ZZ%%5XW{i$1Cs_*TW1<6 zjq3bi{S=H_!ihgPO5=1IoV$=n*LxL=zcWHt+N0U*Ow6dcT(Gs%o*}h%3>@cgoN2xb z`)15;KJXHQy#4dmyX^ZLD`%WfaJIf+8htC5;qr<_vBTmo=P~2((@6EZC>i0}7e=c6 z?PJ;C%}<5mK&@Vt?x&aF&A$qSC^Nr)W<@#9o2V*G=>KUGO#~!?`gwa zAg-nZmq~gU(lD|e_JyL%=&A~bO2~?*xEXK@$9Ue8OmjG@JM9=ggOcn+OY%cBrsiILU*Iu_d2LQuxKYTGhJ$vORh1SmSK?m4N8WwpqRT?Ra)*5d$sx z8HV|N+JBm+kD!?hS#i}up8#e7Ugl+}`JbcnRiQ`x08ovu81M+)?SN##^KLS#Rsp@p2Z4*S!+{-HgX)~*r@P6bUFN_J&c$GwghF4Ow# z?Bs;5x(7A!v8^OjP)l>)w0E51Wbz>_d#m{%Co5jBNr@budQek3CVK{Wj~<+X1r*p~ zSVfL3R6yk2i}9@%#p>3Q-0CmLLNjzq1U8LN2{mC8IRDh2qrY5NEkRJH&V_djK6Bt} z8HSUXpd>ELJzTDH?nG;h%VKGM|MZz&Y$z=kgr>M-QaI}Sxh|TDXUZ#Ov}J0~>UF4* zy%gwOJmfeIIY=O7SV)YY1(i=upTQ*QJQE24OtUv9_Y|)ugSWll32D8>#Aj%-83g3V z6rXss;63usxzEn(Y_@N7J6?Ft8_!J_uluDN%3oZ8sxZW@6ISFbNsV!9WhaMBnF zAI$%s$SwgB1!=(3srIC`tX8WroD~t{))g(Jt;Ij3FJl%B>W7!8B(RC>pq4S59kt`6 z`9cdDk7JHA?Be za;+-sb+b>Hm~kf(0STzMSO(N+g%}uM?9!b%;G`9#!F)GPP4$u=kMYdXC<=mxfW`W$ z=l*Q<`RW&|Pt}pwaHumGn5V0Ut9z?`{btzlDiR|xflqBhjS_beFj;fL5i}YR8e4ie zgeVDX)a|kyG7K(SI~eUtQUcp#AKptn%~ysVO!BS?Yo(y3CvPo&KhoK`zwejGBGr?C zAljRNLqvQ#Wl4yxId7q+``sAyVXcncD${`866{pkH)0IgQViE??8zV zQ~6Cvzi6(1u$V}#*IHDGLqye_GzSYw<-nYI0p-5)$_&QJRu-6%`*SQFlTnRQZ8J{; z?#u)7qh`e_&)Mb{WY}9aP}L?*K|^Jlnkm3Mad4(A#nO~9Hn}%KtYiF&NMAan?_3!0 z_mLNuCp=eDuH@u$v2R3tu));=_Q^=TkV+lb8uQRK1`Uj9QOr_M1U*{1c%h6JEfi>X zFF-NYRoA0G44Ip8MyITecU(#jr@DV*Uz@@k(03*_cHi^W2SSdF0Ri6h=>!N*vitMI`lq=L3maloy7^ zGtN)FQ(qgYi6))a$EQT_L7&BNrcFTCqpx8z%eck9;eEoP-uD2ix)fkQA|G13eN6$=5lIj*GWQRi})KQuXTHi zH$6xT;4 zTXqg4Zc?&@7>uC#703$8vxFylp1{4*Dri1;*Ch(zY!5b)<&WeZF2N1&V9I<0%KMYU zF)*wnHNGH(8qvj-^d3x!U^-qJmN1M?*MkijQbR0%>Dx7Q0^57Gm-qo=`Z4hGT!nZL zNXWH-li|FVuVJ8!1$g^BxM=SBNQt8I_cKwlj{ot_ zXJU9dV&{r5VJ7VbljH`RTrKKAM(jgfV_E^@efBIGXjp_98aBr{E?_d2@dsPRH0mn=q z*IHlNz8+rdfA$rnPOD9t7RLs+Aj*7ITAkfM^4QmEg#w;jUvda8G1Fg?Fg(ufv z><~hibnY>H`Rv+rZIS=^%r+OD&fVS(Jzd}4jdQnmqp`gk=Wg%D*V*2U3%7Uei`d|` zFUl?6Sh7V6;jj(X-;fKe`3?Qv`+7s*`c+R(s`sBL4UtG5zitswJiVjmD~O>MLF5oN z>$Wx|WhskE#j%BoJ%WvN#w?*6c#jyor$qt-p);bbT6@Ds{U@V<_^kJfJF+ELDXW)U zn5R}XM429?cRcUPyhR3nLNn~(c+ie<6{1DdAcWeC+9-qh#pxr9%WR5icKJD=C6D*P z=B9JL<$$!S4L3glN!UG!MPmm2UQJP--PZd`T!|N$gLG`do&-EbU?YFM3Nz4_FuoMc z**(rLhFl^Ay3=t=Uv4L9W}glFYG)$rGo~Y$r``FmFEU*ytd9^!@;x|-K#V{UZB%zQ zIl~xGCx!+w(t9R%y(gIx8uMgp0El$s0+#A_3($$Hvu~Xz&OBLbXLSUF!o4tyiG%|5 zohKzTgyTC;X*k7jaYd8O@69x;4B=jpwJGNtKcpevBnjY%esd+NvBib0t&5U{STf+W4Ar@wC#$^V2TxU zsPQZo7YZi9!ogzIo4cAwrh##5gzE!pc|$b(*L3zX%n>8lOJOY@_EW-rH5ij2)sF`& za`ylqeK8tNSNkvtlT~SY2%tQb(eV)+JK;=6eK48&h@U3~KpjSaj{V2R@)aJ2)sKdC z4OsGZhkYsc69i;CLDvcM+T921IWRqv-Oo3>w|@WQ&D&MGIKcUaTbmyG*dwI_0*Ex! zmAl?RwJ0GuY6&oe4 zmCB9A<|!XX<5WIa|C2lGMDwK$_{A+~xc1=ooz1G9-Rz=f8+Y#69*d^QUZdu{>h}c7 zJd{%Znw?0CyI0uC(p$HWwVmDF+}!Hjy|b~tR_rW_&(Am9y0h^Sp_d<3D-$Lo@bs{; zV7O@&W~r?fVubVW*Iz0WbA5Yj^RB~%R=mD*zgo!{!yoUZe$e$kw;p_O=T_BY|7>rx z-&lCsdIZ&aqNRRFK%XQDNSH_p^OafuAn-x`SteSQT(pfm)UkIA#`6Ou+#ViTXfBw- zi3C6>FNi!&5=9I<3BwGgqXXH7Po|?Mis{{-oIJOooM{^2tA)Q+^?~P#aXXc_XCC?H|LK=xB?gZgQ9x`_+!*F7TuYGat=~reLsEk=4t_r!? zKpp%X$Z9ufZS*%*?~D&t4$p`>uZ#$$wz;KHnKEYBZm%O8tj1rs&VJ;&!&H3=zb6OProy3xZXm zChq=(61K>&?lxd4IF;t0Kj@$K37z-E6{fzr1+utIth;BdFjXZIDSbRug^zR#&&JuE zT6PLEJ6s|MhOG_XjUux}Ir4{RUwrX==j`y1cWvN$*>Mllu{W!FMrsYqJR>V=n)qOH z=_oQQiikJjz&jV*5`0?!m?M#%+y@rNC@YRU24R>QT^owH`v#l$UH z`M0AHZl5H~l9?0es}-n>Z&zj55^r?5>sgcw(ne~fY*X?J_0(?mJH@e#sWitBkmlLg zem0!%DPG{{m6U{XnSOb88N6#TK;+86&5YZytE@e~F0{sCHOMSujb|PDmoS=s2SOKg zzrFt9+Jn3IdpqP)P`U~ZyMmnP>hpI;W1FjSt#M#HYx|m)5jVSV2xf?GU$%v&uq-TC zn(5k`w~Mmf-rrS8q$PLMy98*YSG?E*r9dDC`SG{I+Hlrf37gOzg)P8rr4PacRiNUV zWEGR2eFMS`QP_hiuojMOLpcJvOIjKjC)IYBfWSfP`Q(i0`eTjxhZEGy(x|jNKvSTVV=Cx%ioX}tUXOY0jx@x>u$6rF(C|eHv3Aw%EFH{t9x4wer zHC=G3wJY+FJ(p!Z<8{WsO+umCbdQ_Y6VMLct0;idIgnMgxui>=xZ1L63TGK&Q4s+D z2iS7Pvi!@EwSylTT#nI+ghL$x`yzyE1U~9kS_?SbG#V{HWNJ_l8>cg}J2xOo1EvE~ zpL|+h&b^cpxh_V)X}fhqT&{ER6Ecq*Kn|`{nddSkNwyBWvyDy$jG75+X>)z8HBT>s z9zt?5+<`U8p6Ev&M8oNzd$Ex7&xxP^=!q9eyZ8XzPpl7qOQyWZ$D77Ai*RSUdwHavAO@G+0S~Gv^fB5^NN4*63H-An+xbdPF z!%%@sguMV?3-Jj;q9-;tEc+~aJ&dM8REtps?XN^5ZT89n;(@5@MG*4_tzF;M^7hmcbEm@^rlGBRj@mYXH^?AWz z{9Zo?R4@vr!)MuZ)g{}a(!$|VDAt=v zjv`GQD0}#Xl*qY5ie2!f6k`%o-A8*6I++!65u)>jZ7vqhz^+dx>D}a5OX3HlY|p)y zvoL#C7Ct;dePXJfS*traJY=`sr~N1TpW~AoU>AhzzJ~EVS_1cSI-CUTU8Bo|_?w%B zEW|uci~-VOxQD9F0_OyrGfpQb{6p^Krt~nYfgNWkl|5x?xV_++i?Vnk`lU5sp`F3C z3~?j=nR-9Rpg`_CkI9n?1-Avo4NRuEBxy={+Mj|dnb=Yqwf4J8 zRNF3g<`AHODis`9^zHg>`>KSi3wqwrSGWz9{mVzAQ&It=&?c5IL4^plEY2eUZw7y4 zC#!UaeS0oyHk=|r;PxDFWG0t&rL#l0a2uKSoK4lABo&>^2F#?7CLg$($~xtQa$9%T zKK)?r)-QU$SpPI1!V}7i91nYgJ$7W;a5Hl12u? zjk9xq8}+H3ho@uHt&-tm0K2!&qnJz|@WVEL-!l$m3Rmi>a!2FQY3HKWMmyy%4&R69 z6GKDs^1m7HjhCMMr@SZ+TzJ`&_#yb%1_xN8T;#h9`>7BK3lT^hEGJS5!~8q7aMse< zGIIqMxm?mMX?W%zOAvo~x+t!ANq>FTd^|Y?T86Tue3WKWOQVz_O@*{((wq2|;Y%Td*HVIDMEVW{H_isI3lb)C|c9vJboCXU5YY^=GAJ2P36dlw=_s@=cBhe3-y(uGgxBKkn$q~j=IwP0=l^i? z!N%=t^?BtIO4*2*){k=88qyURk%5O+yc}Uerz1=O=}o08_v^5hMU{(-7i+ebjkUYI z_3iD=ZJ_2uVz4005YJ+^8vKQRRBDg>;2LMH-#ynqC&#}{oylERD6Si*!RsG%+T;mF zdjxNRRRkVdzD!b$JS@RC!#7?oc?KD!i(%2p_rRh~lH+#Sm42qM(UA)9sH;Mu&Tyt% z-ql;RMaFP09!}|41CRMRNdipibSS)qW6zImD80F`>F^j=`r6v+J!MN6VO}t;prR0y zc3?pgIv4h8aBij8v=>`cZ4Xd7FiZh_(7Yvmz0rc+hEWr?v?=V`Q`Q^dON7|Ksy?AH zUHrP_W^GYADzO#n91<|{<16uAnPXZKOZ;+)uG2$7laGISV(9FYB=~gBO=4KJ5Zc%C zG_A9-_UBPiUx~Gdj)v2NfzynQXMHg`Sz_?Wc_2)60X50ij$*RFE5)i_{q)6TM$#Jx zCtBx}V~f+MXriPU!DrHrS4YhS)W;MVDSojL3m3>Z(ngCQdqJcIrpJyy65=lojtgg! z)OZ;gPTw6mynCjo>0{S_XMRQ$hTjgM>@GVVPP<`tDwca5lT#2$%>J7Xwr|x4V=1)Q zWELZsxS4VeU$agf$bk|zR;3SA7L@pF(Ch~i&0iKd)NrM|x_F&&Y@L`K#b&{5o_AKl zd&PMb%0t75X~&-OM+#Hj4koB#_N|hiyW4NX3 zV*wQf)mucscjN@60RrT)mO;%$$YUVUi#mM^f*msD^PCDXo)L)2k8-}3(bIGKRlBP9 zeG!UUpjNb~)n87^iX(`_pI#_q_@?FwL zor07@uSQ}l)dsJYM)FdN3w`yZrkE^~nuK=03j{AxcXoo^7A;#nQEAfE%XbkyeXpi* zk%3A_=k60S%+t&en{gdUr(@oY=F$chsgGc;q(W+1fh&Y+(!It@pOdR6V?>lm0>4P3 zY5fVdjjS6st@UP&xpN`DCGNREwOJmoA>f9(TMH`WabLEKhHr9uX?Fg&*76cmCcJp3 zD{KgPBbZ>UiJjEmS6lz4c9Vl}kFuRyk(!?Wvp?d->67{R@vUMpO7$ym`!pe4f7eHMCO}bWH(zg9A znK{y+@d8L%%}B!2OG2}wHM%->EBZHEGM4H56Cu8B!2I;xPK>|raC$)fR?`5a+vpu&ZkavDHQ+c*YU*^M&dL&}f}jV_ zJ@t>3;wRkhxOjkQnK?%b90lhXoU$cN0hV>7xA=?NAeQ?{qP1+jXTh~Fq| z`m&M>+9H%-dfLPFK_V8nmhQ?T>wbbu@~p)n|-}XrOo$zAIQ&8@hVNG z?#OOO7guX2;P*-$Od*y#H|_2XDL^^viTn0THFs6CRyfI?snWYKwHjGs?A*xuv8n-r zpS0@=S%6Jpeb`T_7j7$d*L>M<0j38SLiSqIsdaKM9%zB;^iGrhI!&@@p$s%7fVT1j z291caB=&AMu)=bm5z3od zaMb3)rTYnNRe(d zUCic2KQq%T@Fnp9O#K9jIQUer~+BE&uo{ zP7P3B+5peWi<;cHZLET@XP#7-5hq=U;92`v{MI!7Y-p*(qPya4-tgn=UcG*M6@Sl; zDL@WBrEGlfW-l zIf;Je34TadN>r*aUD8l0kYCP*xKQ{4HO_YA zdHU!aGu%*2S0Ub?xW{8g2k8V|Z4RJRPK@@3MM*e#YJTUj!Tr3ix!gle4{R%mta!(T zX-@o8nr~q`lDT9fHg@Xb{*ksusGxn)qjHYkeopWe-AY_jclrL0{+s{h_fqgLp4qRV zGhG~BYg|L&mR&i0?|**!WHR{i^=Ho}`?H4E_iKOozu)fs^RK<8|D0b?9d>mShZGim=JBNI`5pC|F5}f<6NZYAn)9*MnqF`R5iIRjWg?r!y+)YWQ!If`rKs! zp9*ZI`lvF{N}t}GUU*2~l)5U?A`4su=%~aKgUmo`Bvz*5WkDemC4WcT8dus*%R*u_ z@M|!qGC&Las~_wO*rii+$;s&Sbc}l=B+LM}SkR)KV7Aop%NCXvsP5o?PZc9qZe~?X zmI65Tx%GMeD|u--qpr)9G6C9!D;J_VsPQ3rT=k0RxBr2UjO4i$JQHfwuYN*18zxLgcnQsj!4FKEps8Bw z5(g)>(x=Q8eTWdXW?2wOge*@gD=+{=AP(u_F5#34E@`1lk%o1lRmpmikB*JX{b_eR zQN6GX!mI9}q7%2pHJ`WYdlk?7l8bAYk0qZ5rnWSF{UcM}z?Fcv%|S-z;4%;?H|j0GSh6O~7M>&=1l%(@8}H~l6VPH3G!kY4$3E2x&xA#!ke2z5_zQ{ic6s0 zum1=Q62+1xSo}7gp@YBt9+Eft6%x&57xS0*-0A|&zw=+T|DPn#oS)5TUgD@^n!UYu zM&rqociwyN^YQx)ndUEsZ~n*s+>c&+P5(JJl66nfhTms_X8qZ4|ICVWnJA+uKsaBr zB3ULSiSU+F-$ePrXa?;gn+nAd(L|#nn4D1DcE#-ND~7mD=P)EG0A(+T(*C7tc)aR6LVmhIEkf|_C`5{3{Dg$#pAyo4LV=DNsMUfP# z_r;|5$^M6_ryR>G8xhBe=HXVgB~&`tHH1F!c9HtluBq&ZKiN=mleSMy>_&O!u%;T1 z5l9sNXo7#%$ZMLd_(xbOJJXv{yAcb-85#r&##~__lX1!9ib@~nRgQCTrdVQ1H%cW6 zp`iPY7ymXX(oCkIIvWh>C|QJ(?JXf4c-%2P~p_{bd*8u5I*huMLBgl zKh$ocFE7t-M5`v19XXSf#7cUC1O#4hl&rro91b|_?gTDa85G0=jmfa7#fu%1mFP$% zO!F?KhCDeFZDkV#Wf5=<@Ti!PFOS>AgB?idyzx`Ag-vHS%< za;`XEkRC8LQU`m|x0C3%2Kb&#ELJC2(|Iodvp_v*hw}W=n`iW6p>3Z9oXN{4&%1VF z%jM1vMu${=!(OSBvcBZzueF!z0B!zems>|HUdvI+Mhu9mbTzk>6lYkhqxRFcun12n zaGwX{&&=UEh*T3|mLq#BY6DS3cuyn9%RknfTxsdppNNP~}9f18a`z6nT^hfEljM`S*mgPxEioY68y0qpmC>ky? zP}m}4V=z(Z=47jJ^^>eMxX!08R#C+4C~Mo>YoA_DsZ^NWq|FTOGv(!hwS&(KZCCpG z@|1IFL?;MBxZrHdCD)wN-B4P8(3*#!bM`4^$-49!mO`|IlvlYn!-*pGi={6V3a;T91WgnJ~;LO95twb=Jp*+rMR|jkaG#4cp>OK zd~@(wrtf{8%l@gf1k5c-Tg%!%9$0vMp(D^jT)Q2MWxLtxk=i%F?h*@J65~<9xi6Lq z-Qq9ain-sy--QbD#i}{6h1`xA>C^Ju`f{-rcL@-Z@hm_?i%NQ2-PyIplRr!of2NE8 zrm5%s6v#@eOAK-+HLk6am(FgDnJYdTy(X=*7u12GvCp<#fQgp329 zs$}0PG6rTE6pltd$k7B&A%ruy%YloovOD+TL&hrtme%oT0FMrXhOD}WP@}6^`;esC zJ_Z5{B%S<~q&ya9Eb&YU1K_5+6v&~UIzXCW^oHoZhp7=h{{z+8~pIKuP>9rKYQ=<8`+iR`Dtcs*r}C&j5l6|LlzWSm5fYfN~LP4 zRnrtnN$kob(XRXIX1NCugqBpGZ5nW>a43^17S(tv*qVAx(5FyPg!+V(%-r5855 zu?;U~VQ&n3ZP+{e^F8O>`|f-3A{dz^Rl8?eD3uuR-FJVSd+zyt&efd38g%{BhU_Z) zgD#z#s^)zXb8R~6L1XKp1EzX>xgi0w-*ZyW_4eR!Cz| z88VBy5MrvER*I84sbOx`TKsxFcF(pcqIGRvdeBec^X;5RKW&I**e1)V(X39n&+l4b zUmDS%oHbQx;wmQQeAbyO+d+Z;DaFD}E#mxUrBLB!1pbO1J2}kgzW~d`==jHx4Ty+` zW=N#S5PKl0!@#2^3JZQ+@T@)A-P?XdmBsCcRt1L|INLb{c?lqC0%`0wT%`L40;MB7 z0nV?G1d#x<_o+s+jxMo|_6!>$GpM%?Is+T zRidsFJK|#+$rM@PzVQo#1&0LB59bz_LBjGU0Nw0~W^lpEG6FGlXgMBOBF)4mZI*k; zI29-+l6q9OPIf)|vi=^CxP#J3sXpuz%#OZPZPRT*a0LYdYrvm{;d3mkoz>T*1cLyd8QnnLv^@2Q>B?D2Z$HWdcsSpIJ|} z5KvZ#xo-y;uy7$KHakZ6hK&S`w7L2O~vg4z1w_ycsa3XIs)QAduhFbuvsVN ztaeDc$@)Ft0XAxQf>!P3>6f}LN1wQI9fTkJ2aSd8M_{N4yh%W*oy@mAym(nbzKl<5 z5$bK6cES2-qS9RvMW+V`Em2SOTFK$1m_r-;IX8o-3)*xSpXE@mvKQm>a*Ab!h67Z>*|0 zv=#R!dOstcjZh9*6qQ$F4aR~)uhx^>TDBQuD!tmUM=u@ti7c~5^T?VyA-d)gNR1Vf z_rUEmCPex)763xtJ>&M2;D0}YfZVM|K#O0<5S`f`Ww4b3qvmGQm2{Hvs-c3HouU)y zrv@;CgrR6MZ7^k%?gmuPFXAtIs;Tox|M6Dl z(X^$jW%Vn!wq&rf_mpI05UA~g%+&%(y&Leo6_nb@+S+UQ?i$F`mG`-#z>ysS9TX*p z9@ajO81+4PPA0tBfmCqmf^gNNN7i8OV(nNL zJhq*cWe8gTn#8_5F|hr+=8-YKsf3t2GIPr(Z9aIM)|d~n8Qt#Oh0KsSejC4g7$DS} z2@(deu<(1<2t`J+MjEC zN{;tT$jw?xro7`aRUI$&n-Ho?lF`Ko$qd79NvYvkJ> zOL>K#mD?7|3Zx>lI!N}AVTy#)GCwQ5r@3Yo_h2kJEr-MtCNI4s?EU>ZLeVkPsQN7_ zqfSBfhQlN1GjINrB$@o0e&RgyR{37oEMQV#vntEDI>i|%WrISy67GD1Tuq$E`FKjW zLMe!=uZwFv<#0;=J*E|TAQn7fh?+3`73d#ARcCmj`@*j}`z|up5Hc15u=jE85k-U@0>`(l} zOS`cCiVuLk8~BP(j4l~zN@r%Cs1&CpNlf`?W^nPObpM#SU6QLC+v7Nf{XW~;4FHzz zV!8?EcXaf0{Iy|ymS{UGqQ*UkJ#Rq|lZ*$_R-=FG$gQ+_Rtc|SZ2QuX>9cDpyc)p6 zlAB!?#!rk$)%01EBUC9R7fHJGun#l9BxgF>r^>RcCcENfY@>|l;cg_@L7=8`t-E~{ z`G?HPYi(1+4alL$wPyUxH~VODMl*#O>ADGZiVl2@u}qq~Nku{N=31oGmE$E=!wQFL?P-9AggkbL+)&IWh} zb^&Fcy+?4!EMSS!wT5aj@Ye6$?ssRbV8{&6Dt%w9xFM)%MY@j(i~MU|#{?c3`>32~ z&Farjw)~onaOh&ioSpzkKFm^0AuKO-0~6mmu@se#YwVM70t&gsFrN;9H+l)<#%+W9 z8PX9nPS_S)DU%U;!8YqEdSPiwMheBsK}cgrF*q(j@cVj=`g-_2y{W@p%#ZjE9&UcV zoooqNq>w3>kT_zz)QJc)$531?QAVc$Jat~7{=^dUqB=wF&6EqTGDc$q(@{SY!%?`P z8Pp_loIck8L7L&{`|3>u#qDG=6e%!_^XuLFm%Qd)aowf?eLoHnC*b95MB*099-n8j zIwRnh^fL11K*qd^1{wx04CTWl7rP!#I?vlxwsqyzbhl3Uepfp0-|4K_zeXPe>+@hD zx4va!$G0ZWp~Nv%V#3cG;iUh^-~8tfQ^4xN3_Pci!NQOUt7z4utBE%%?7^q^_z&?oQaKm zaCMBq1o)}!H>;zTT< zMPzLSvkXEI;x2ifFe|0Sav6)z^@>YC{Ftp`nlqKfL!A!jN-<;RcIQyhAC~G?p)Ssv z$vC^7uZE9;_`vfk4~tbuHnz{R;pKA}p00MWz{{0rgchZ3vEw0PB2izkuv!c{VCi8x z)Cfa5#A{cVrz^kn;|C|TR;WUUgwDmarVhHVbT^2(w2}Ln>{9&pB%{waL|I=&i!xX5U>Y-^2mWainWoD4Hh5y#{!E;_J#JqPO6`tzUmaq*i0 zieKJI@xVcD!2CL6ks$$8B9P#C^1{B*McDQSk!JQ^QF;4=ZIzKA?Y><2gP|M&KC?r@ zpP2a6xBCd|5Z(m0N3+@1!?QsFt{3Hx3VoR1J9tcpoR-&r#jaC7Qm)*TkSA1gYz12& zrBOQl@qmjhBxJ?0_mjAyX(3G?(w73t&xWS#T#jr3FlFP{l^FzdRFJl4I+ypI%QFoa zJY+$7hDgOre8DI5Ck~Lp=I|?W#m3&w>eeo9;Yaxi>svTZmeWWYb2ai^FzS!KVCf9XrVg^DRW+znCAAvYuCqlFK zWM^k%OP9Is?Qw72>fYYQqsMy|O#I6^ZPquq6>0E@>tBUf?-M_`Y=j`OEO@+J7(1Pu z=tyRQj|lTTf*JdrUkevtP%gM(nql`h;i*!IH`k}1ut%0g=#9SbIB^uNJjM;VGMuhR zLR2h%*Dju#ebf0N0{LZ@vKS=8Ki!qecbzV=xiQ1Ux5Dfsg;=$DxVsTi6iF2GzibMI zKtbEVRG(|7x3#^eKv_)y{mZY|POTjfTAa$Hg(FngTuD;zi#r9-7PGG{j2FA zW)B>r*szSzU&)~h!DiBOP|}fit3+3_5tXxo*2sl|$h7CgMktgO5285;WCdeATxstm zFx)HWf(rHov;Bd0FR%UC8%C z;tH!8({-^veyMej4x|F)Fo=v5%}~bbKtXt-F1EmfHPd z_bT)m6evNPayZunr9y&Ym<87i9qz9u19Sp<>;bK2b~9OA%cdm+is#L(6Ee}Ip;&DK z4`wIeTd>pw8e5Pj@F4D|ur;!$9RD!;(3jz$cnp;5d)K)f|r27sTD{>Jb|)b7*{brN03{(nV!N85mzutOs?^bgjkf; zM)VGDbr~GaopAjDsZZEa36s)Hc)^FmPtinaD>0M zRQMxfyyaspP5c7wrMw8#x!AC8mS~ZJc7@}KCqRy+S&E<3o<+4w>qVcXmwcY~Aiq-w zu7>i~3=PJxZ34d0=0D1Z&`#eim^Uh2E_0jky@ppQ_e3unjH!lJLXBpJqTb?DHM))o zGwvysMpGRSbpTlGn+At6dD8U;y*Ac6!$$oLf}lC;7tSq&FMVNK(Q*iy$lWi`KmvcW zQSAgIF*T%VtP66*<(jB~PB-cMy0wA( zzq83eW^JzVbg3}6LJ*R)Bvsvl!RT#7xk=2{iq;!-`<;CyggEiUlf*^f`&Kc}pG^1N zm5Z9+*;iDi)l3a4MZ}K<%4h)1I4W3hAvkSCMVcytKA=zqb`GDcB)Ur68(v9To1jxi zijL$`tPymla3t9P-MSthn-N=)3MK~=nk{2I87^8Y%REu~>g1fSyNVW;IGI3Kx&?8o zIsY(t{2c(YliY)R5ddL)7Nq~MFP!KR zrje0KElp%J5iYbtq~F;k>iC@p@BgCnc!&RfbYJcT-9N)+Kj*s`BD@vacITpwG4 zoW>bwb~Q_&tG=Y%6W>x3;ITZuL=M+dyX@^gNVIQ|4~z+6TnLRN|?Z| zUGYf>;`PHafE_7?CNhVSZeV2~w6*RSVnTiifJQ*M#1N%i_8gk!n8Ww^E?eW)oMxWL zFhS4GEOA6xE(JxJvd*{Ku_Z4%NqwMOivZzv37)Zy^0G}qpT+yJWI-5ph zrxw>ySDSp2v4?RGRe!@xYK_C+f@SPZs{x>*>dAZ~?ndkMvl3F12UiR;El*lf{GDtK zZFo7u0vLchgv3QsPhl4*hD&c?F*MIMk5=mCs+o|!B#?3>t{jhW_hI@JCRQf5)VT?& z5Hj%akc=lut8~H=$JFoXT%oe-OPLgNOX!zb@DLk2+r9Ju<}Yp&j(Gaq(pYR1S!?N} zuwXlZQ9Kwc5k`lOZqT2ziB{=80e7keCW?^&exDkHTX05QQ)$L?3>U4N%(wHmlReOT zNWxl>PwhdqGqUd#o0FJj8j`nZgDA<;=*x{YMPHTuMT0c^;%(|H5xq86Rc*E>PD0fP zYjxV--sGwIc-hm=zC;IwuiDOF{PkK);U*R(ai_Bw6WG#pJ5R?*0_P_+zq1oK8ETTx zn_gNR&0cB3r-I{~MxQ~iYStad0gNz_h5Z&o(LPirJ+B#xBo8#b3|G_9DR+bvnO9=d zTAFWdzf`p_aizRIX4l!{cs%ox^%&C~k4uA9Ph>$_UpnWvK^u6aS&bw)VB{GeTUZRe zWzyypw#xa0W_2K=$Sfv3Ic4wcv(2%AZh14RttF1nNCVcT^Rk;;9F8KGBg+_0RG8Fy zHKGh>zW2UJG>qCR>?p#HgG&6d*`q?z5;TrD6kjoyrI>ubqC z`>Bftl#$X#+JY$#m~(0-b&l{sC?%UvQPCuy$a^9?C*Z>cMLPiN^!fNg@|Q~+`-6i^ zf<-9jA^&l;X3i#wWbBMOIvc4n5kb}36MECLBdQEt;t-OMdMKVaN;xWN0f|0F)M07MD74eDo0YG~RQp8j}8aAE#$TqXcYJjV-0x=Ty_!$}O z*11Dt&heMzp8G2Il|x`yAl!0v6@Y5Tk0w8_;Xe;-cbgBLhfzMpu!L^vXLE=}MUGb{ z-E)uF=p2mlP-74y4#T1IEf}biKl;(%sV}doWf(_MmQymZv+#y9lDk7Z(9aSO`Ly-8 zS2+r#aDB^dk%x$owr27v*{7?QlNB;0FFKghISq%;=*VLCowui{P!{bBaS;yAlFZ+! zx=QtB9E`4Bj-(D4&e#}s{0@O;-e4le|Z1iFF(A~(T@*5xc|XNzx?oa;{B>jT_OOb15%=! z5Hs=lWLQY1m-0XqoIh;j64b=g5^41o7%1G2Cc444>TFmM^bpcyAooOsLX0d_i;xcV zrIdfou3=Ds-#69KT33APR*iCUOpE90DKf>1d<8*=1PAw|QswRR7z;`Zv&67sq{sxz zTIYoT)96=Wic9re_J`=2EeqBGKL1Hy)12R3?eylEnw}>2Q0>^7-!VlIO1!(xWV5Tc0HrBorF5it-ZAX(KYqbSi7 zcMvtD#+QebF(d-ODV)8Az@M1j6n+>Nx8!1R(zhYj03U!4La!~&px~(=7tFIH4ajW@ z0b+roAIxY!9rBf5RhAKR^$HioXB=X|i@9Z{-klhBf|KSAVORRLQn7;}xYkNAq{a1$ zpy}WPUtsfEoC#d2 zwYXoNdy=8)OsS{9*XdeQC^#A^ul;~2NxWSk7#OdjH=7ilIrrx~4!n`*OnheCla;nn zreB0~uTwAU2Q(P!pZb)Llulp(6uhwn;(8_JJ<{EFka>K~CaPIjddP#^A2r z9R@D1tD^%cRNfCx=k*25enIMerW+vq!|mO@L4ZFo(ByR8TN`BoFk}n{lYNar z^FaNnc7X}oF8i|8g?4ZKNr-1_R0xu_PMP5TR}t{SsA_IP{P`^L{lw zGS<-y&`GErY?K!*vocNyZM~CNGy@o%FW zoea+jToWD`Nt4hfb<(Job#Z}lJQ6!rp- z!NM04OoboU?9xu6@2`vD+5n+0XG&lX*<{;pZ^H$nNH)^3##A0t?VZM41kfyXtUlg! zUHoWrczuR>fgKbkpInnpWb%Ou&-vUz6IrZ|ua)JO98Gr0WRMU&1f)l!lS^t9vuADs zg4r$MYn6kI?3cvPnQ)_;^HqjcZ(8jX zEU2UlF^^l`Ok}g!0Q!ka_4X|RYQcLkPo=2M<@;nNiKGnrGNesM{{A|2tKOkG zy{Wu0L0(t3wG)KY7qS$=9nK2gwitnsqeMuSz}?iL;je~uBetnXMqXMJ zcAYav)-r4+zRXA+zuVEeh*4lrbyx_@z0{SB{Z0pk5F4O{>IA}Izj7oCPUbwYw!PrX zxhw=yw^YyHM3x+6l$M!IGopkzfOMAd`}2^nBm2RV-1%jhMb3KBQ3vF1a4aZTw0(NZL;1| z2g&Oq3A2+P+Bbe|vfdlhG05J!kAUgiLE5501DNV&>nDyPLpNw9<0g^$DbB0ZsG}&zKsM|}{w?&QiVC-i5 zFj4`s2npTDN>;#?)mG!Siple&*T9LDes7BOY>=+_esYme3=!kO)#xr*?se7g`UjYW zBr%I=o1NQB#@TSbBzKHZCpFO*H|HdxGBB@3l#(K;KN?s&6c4 z(#lhLF8WxaZc|CU-JiEY?1F`mb(30&(UMy$-+#&;lh|7Z&JrZzGJDo0sNdxS- zCEtLWIWJRDW#9UA>?CAyv10F@!JkmpQN1>p%W+%aYl*3`jRkjWXDlo_GZp@BWhjXO z1}jbu=fp15g>y&c_n%$Bzf$3jWZr0Iu_cCRe98E`2?2!}C&z+IE-TrWMsv0)8LJd@ zoNy<5#>@#6U9BRMxGLzB3q~tA!}ZQE0;b>`6&4Fiq?Ch%*VadCu!?}!*_)gw^E&ce zCA-_{HPTt@f;XXjgvtT&Qo@=TTWno}FnIFL#oWWE&t6e|L!*k4elb$^%CgaAsTm1J2FePK~^3auoV1)Hx1tF49#qUVd5gXeX^*p3@Uo#&&*>taTI$a0dR z#h?C&|A;92L3JAZV|VXNxklfYzI4LtFi<7)>RIygfzmvs61mP*k4sNgHo;oREUD`m zv=b5?n#KIf&*V>6`!#1n@m10l=8UD+`bgTa4^y(Jd_{t|eXP*uShnJOJCOu@nR(p= z0!r_1t`Ag{ad%#_K~XTnq*<$CRu$2hdxga2xuyQZ7JJC2mjwiPaLa!{e&?h<_L*{MbwDZLfQK8K)Qi*tJu|Vq>Px@tF_g0O$bjPg_Bovw&EFPhQ z=1=WwTXobLEajoHO@y-Nd>TL8phMZHUl}}ruQ0(8BsVVJYZ3x~fkF+f)88<}oJjDD zdI%1AUaU?%r*Ert)I@5ET@z;Xir>xqEog2W2t$+8l&k=^SWlA`^@| zX3;wQxR)sv1_?qwLR2Z}VllbqWE*!+c8c3yJ#ETH3(V7r33%tT*?_j5_jNDY%%xrrPbw}_lNsD%kQkoRz z^ROMig&@!DV>OnEC&|1~>Pe!NCL=8fp^=}~k2y7Xq>voNYSCte`{B?IOIb{^(gJt7 z%@kNf&dA~=meB$ku<35B0=9LiHdJN9vL;jpH))wVF2Q51?5nE2QIl1FQ^*4D!;kt| zB%{@8q{Rx0<1f6@N0~Ux|2D01=4)e5i3`~fhO@W5Z{tH;aFU>E16rUtL(M^aZ{vYK zv@#x5f+@Pz9z@kyA+W~#;cfo+6AB&4gzyu|9jW`JWe&6e<&Ib;dlk83azgasq%5_Y z$Gg(|iV$ZelwS39?yC1qIVU$0270r!=Lt6;mARGPd8-PaW{0faf%C>7y$S@TpGIaR z*jb5Lxe;{AvS7vH^1}utkE67Jcg(#3sp=DJ6X`WjATtB2{F6=B#F>Glk*M(|2_O}8 zVp0i0;$@cFqS&Np@nF4LvL!NGkt?}r3E#VV<+;sg!;3^<1)jsni1=}c7s$8|hw_y!hTbI_Ho65p?P5@@s}ZB`(gvVHSjb@>NKs#n}2-0;wjM`_TN_>Jn+) zw{AtnNfVzT)0hiV6{4!Xk{H~Uv1f^g!SP&Z0v%-WG{2%QeB>Xl)09?cG!bDF z#Nz7Nr|**dlb(F~cf0RS|1JlJC0vg_?f3g}&%HiXYj0{$wq;57$QtT`)3mwe8pPw( zoz+LXsnTT~Dmo5*s-Cg62x4MsK(9ew0 z2rz79C_BZ}v~6yoQKD4O^cw@aP}VQX6B&`0Z!X6(Axn3udvUv%$-O4dyv&cwPu{&CncrmR8legApFF zOn7vG0By=d+u98C+Fq2dt0b|=Uoe&gv8vl|pVl+#wUxxlk1@u4*D7T!LjLGEmEq20 zO;D*iE%2Q^sgbbUh^sML&cTtx(o#mr#z&B~WB*jc1Dyendg7L#N> zSBsJcWF*GiTu##cZOoRh;xB(}Ox94(lHVYlh44o3y{d^(H`<1*?6Y-iTNl!D> z;q~*R|F*WL^D`kDfZ z(OIj!i)LS&W;a)drCa8jewI^CQwhMPCf!WZ(i6?B5E0tiCtT4r4}Fx zWSyf{t5tW4qMe4Yc6ptyS~&Vd+wekF*;MF~fNTlLmhUe_;+`oLM#X$LxlpZaRp!Nl z7eJKuvrrOr)9kL|H_dpGM$cln0Yai6K%3!m0R>~UGvdK~@hojrMlVxc3JxzJ@eM`1 zqL-$e5b~et1ud3>MXPfLR7djJRe%~d*(U|gf<;Cj6~7A)PJ`|F@PC;sFN&}Kq&%-0 zXV(M@5CN5xl6)695xSq^vsYvtylbVEP#G2w+uCH!yJF8pkP%w)LslLl?BDNyz zYBU%$xLB@Z?Sq9M%NsV-H$C(*rSx&3!Bx-Ni76P*}=KCD($m!7VZrjJFAB_6I17HXRTe@S^cPX zwrV}Iv%Io&k(NsS+M%kS#?IdGT?N7g4P$4kG3qtk|Pv zogz?jCC|JI3n-7JSE$|f`wG6+I&{9&M{|?)EEXQGi>=KkPOq{o=k4@SiNmQ$UXva; zUfQ&%*hIB5+yLgb_}&F5lbk||aslcGCk*CleR}WC`(J-z`J;Dhh1LnLRPow;QE&9! zT${rqUIt;RnilH>tz~}cRWEU?RQiV7@E52&({jY2Kr72DL6$-C`F&s>xO&5>%{4R-5hOi2j5I8h!BJ!2+3`u&S zHwBZaz08LpKmF;Im=Hih(_BWGskoi!JZeoQ!v4F2HJ1Uk)1P=TLwx|x>#0F${{M99 zK|hU_5}5gyrKb1|P(PW5lDq$BL^&~vTE!vU5TgKz{n4#=8REZ>4&$jp&7h6K0M;#9 z;IFXYRISFcuk5?!j64Oim}B6fwtQUF5|+p#Cy$uOtHC58h%5LbNwT$&ridenv_7{a z)WaI{++QzFuo2WKeo{gvg#y$j1BX|vqxefiIu;A5g^{r+=4IkdEDYIZi|_8Q+ZCW` zxrjpr$}x!r@=gKphPc!H+%AJlohGh4j1g;0*{4%FmyK6wx-nCQTQXD03v*^u%%mYF zq}XMwYnIvNC`}rlTwWmz#taCJFU#X=NC%DnviW%+8tE56$v@JZD4NRr&f_*bymk~b zP}wJ?5_--d)+1G{RU(CVp^P)q)OwyNCL~k>p`rv;y&$%VK^s%@G0Vu`V&PeADxNif zLI1ai z%dfJkyKc))>h0*m0z}h1Bwz&uIhQm0;*(XQ@zJ*_-Xgc6aKPwWHV?!q`U&~JWARUz z7qnb^9Y%sygFvDYYM8{NTEq0PY*@i3tSNOUqspl<-(qCHX3C?F3<+s#wx1?*`NoKiI-9@!1v zaU?zPAYCJO5)JbWwcr{BH&3MFGk!U$#Xk*YRFt zrJ{eCQk%v!3j%2sMJPwcA5PC#)ZU(4-tYfzV`rO_I0L{o8-Mw|fi=*v?R$a{nxkjb zzaxFG?W5K~i_bJ@`pNgpFSs}oal{e{H_;qTaG{ZPpu2 z-OpxnA!VW~NH+Rgb`hz^`V#D^FJR#zvr*!h<&(195i376R5C{QItOiT52{Sm$R*GS z5dL|9iY2`6fwdXT9NA(gX1!cDX?JxQ?xq-dhq+0bxT)rRn>MBr-Xc`J+FCxYez=a< zKwMfho_Mo5aiUfIfdwjsK&(jQEl46_N=(a76BbhCgfFdumPuEkN(ee%Ng(|H zP3~93YACd}2s0?9vZ4hPwU$1Z)lx4ID)A|W+7AfcE;@{hc8b0ldL0!$NXaJEnNU>f zXVo%jsO+M2CN358@B1oqm+GiunzKTyYn#HkDsx)>M-&xu9~yv_;xF?#czkd<9vxG& zioy}+QCA`*_oO^%ud1aBI=nKsLIQ|Zv`R3k5I53&*KS4Yc8;!9)rjd+K3daJDYD5+ zOF|+K--MolWo2>$4vIsqe3ou9bkF}=s4Xq`^MASqy&S~ z)?pym^qOE747uX4xv83NAzt7CDRS^~5?)t#Me|RJ0L{Ns@MrZP=ek+=b$k{wJrVja z`|p2TAi|gF$N;1y9Ee0TfX?ik~N@7z;w@9^(sXeP##L_38f@O4_@I zMy61@MGnyQd`R&beI-r5z?&M{yjkj|wi7H!yJ|SnP59MB^LU$)t!?$*Mk|x++ECAy z>CYR;SN>E3kng8VzZ3`lX@ts>xA$b+g6Wq&U8|H+IiWfK!q~qma^-8uFIpH?_EH8w zwP?j0=w3ZFNa6+I#7l`bh^tj0iu5s;#w`0W_w+%Y3+@gzn~DlE)09!LOae5Y)eQ1+ zA{2YiDdKI(75rtKuo?PE<4~?bxTS+;V$1AJK-G;u_i^r4kg%?FVQV4}nKpD8i0V_C zAB+9rr#xTsT)x7Tq`GK7%{ePaSXzDa2{mH_O3lbyB&7(-UwqP#w{DhpUfXRIqbD=2 zF6_7@PPs-vI6LKpa~tMt8Ol0T2e6I{l$I+Rui2(Qwyl`Q2pw7mkAINKtGu$lygj-2@8oKftI-|0kxVuq!NL9cr> zIv$czX##-eAVQWr0Hqe2jqskDS|p^Gt>__~DM_fbbu_RvuTOYWZ96q$FSyoQ=~q#h zMgnhHMZpA=wA$D3QO!ewzb?sRD4Ojnl{ytl!C#l;fdHAHsWS(E1EBQp|_*xI{1hJyG!m&Dyg1pp;c}N+FU-KF)Y2L8bJZ)SSX>hWmwA?QWw$s6amV4hK|p z-X$qK`KPTe)nC_$YSEEOL2R!7%Nwo3}(-8^9rh!q}VwMCMZ=TE=rMy#= zO9K^eb%T1-XZ#hPW}AlzXC=w{!+XfS^vgPbbPY>4bWrg)>O6D91JOinHT04YY(|YO zBxw|B0@%3YHo;q(b;>+W8fjt&{qxaFtDM_KDObaK18(k$2BCeJN9ygj>yyJHWD1U% z$6UCw*=Tp@Gkq$_(%wBO)RKBGudqigTit0PUXE}H{5Pi6>MlSpaVy%rNAYw;~@ z{8rr5hV|#E;#Nx)z2O;tpS!QnwV6`#X*VXtXJ!+@tgn56FJ%X%_nFI5GF9)`b(C1O znX66h;s0vStTZ5NPNV!m3xQQabC#p?JgRpA?{?=vz`Rgfb%rfLp?KDC4J_vw5E?`T zss1c2K=KP9y2ap@wXP;m7yU=1C0T8&PV`Xch(;-`;d55 z-ePBUseE54PG$6Pa6CRGrwm5`D9f&clo$uNDZXosaVz!fXRkDMwz)BVi*&IQGGy-E z&y8WORR-a#NM>UjQH`tOX0i{0Wz~XOqHp~hM36(mQ;CcUC{1g2{c?1YAZ(awC!&ky z(Qou8XfIj~aLRU0TUl}YrqM$$RuC0&oI;lK61~V&GJW6*P)SzQDv`>0tyJvo)<7LP zC1&K=j#|l|z#YBn^THl#$o94yO;wRnI*Xn;g6j#!m!7E|Gh1QhLF+hR&_Z;4`yDQi zv5T^}tV=52%CEB8up-3wY^q}dYD_(t9DKtj6FDIH({u}AAeTN!!8up;bH9WAST`}` zI5xX1g}8v>85h)G?x5fqw?AC#{xq(k;IgnGH-Ly`O^(%5^o_qlt3>Men;y80%JieK zX*Nzmnow0rkGX!_*|rI{6KO-3$|TMvsdgS)z?UtkPIKP?4W*gU%)p*~#})8&+*|yR?cH>MpD`W1c9VcjHg)OHAFc z;o9?sMp0qq_od~|bvE~D{m}Wh{U+Ty=3wce$|1MSaN*||HP-Gv!Woh*ty~kF|0J4( zNb$Gd2Gz)Q8$90G*xlIMl}ZVh;69Vd#R}0TTx&2nxu&EPH>ZJ$oVTF`q${n8Q6lE{ zcr-foj4i^zhezL0)&k{O)lSp}nLMxv<2~hicxDt|)FrSf9K|9c-R~xoGkwLpQOpKpI9ZyfPrBuAWZA?g>CiO%PxGA#XrmnuEOMaow)taT4lM+33;_Twp0Y|imK zp1k0z4+v29*`IS|84vFD-}m3g_VdGgxdBI9Y!vcHvo3s;LuQe9Hbk|+Rcn)0r#WA0 zakdnTA-ap5@bYLyJz(RD>zPiVwqV>Vt;`?&KFj(j&$@kC_g`~al88(7vF=b3rB5Ph z2s^)1{l@8Rbf5SXffc3;*7X=J=JD&qdl}uIB#$FDxR zKY8%-)rXD#|K4AmeEOgKOK-iUe{U}#M>!tdBt~I1xF$eXH4-5ggy){>up0$%d9O3P zz7ki`ou4jBeQ73r0xA%QI^1hBI=?}N2+pTn`|)ff*4EY0b3qNO z&#yN&9uL0U*!<$lz1yju@4vm%dG&)U1hkDjm`+d92`GMi4E{u}`p*XbGk=>xsH_Bu zUH2z1$Di(QZ*gDk82OQC;pwv>-hoOC+t_;YXt1~X*~5(_ib*!A2&YL=CGkx6nKFOs z^#19_+TJE5da2jD`&fT}Wv=ipD#)|R36kk4#5XUv_+Wcy@5}8kwzpOv8ipNTQUKvv z@>g5q&v%~e?mc-VeB)mSs6&?zY=#D7Xt$N}&s`XRf3vy1_oWR|ZbUIqu?=a2x~OL( z9FM={Mrp}9;-`6{_t*QYTWeo#?+hMouWt-K-~6);u6o&iJb1YA`JRnxtUTnzG-~6k z^_|r(25TEzdmB4x21gT|38PD;9vGMVH%295gR;ef#m_hZ{SCy`9yyUvF-G zF<9T+)sC*)_+wm8+zmfcA;9^cY_31t80_wCJhpExp1qom4=Hza(`S#r{O#`M+HU&N zwg>#twb&RbPEU2`WSV+(nx_E})>gThWA*+z#B_UYV|RBD{pE=-Y^*suyut$HY{_(J zok!_|c8E9toh$B02h}m<#&@I@k$I(7nrE-$@f-HBx2$s?$hR3P)gX6TaUi2M zxmvsF!!xX>XN|-q!$2ew9$eU2PU2C;(F{K2)_`^01L zd@eq>+x@Evq4zIGmk}{xUE35=9B0heUetL^*sh_95LJ?g4U~emlhdl!Jr^Ybed2!j zf8I`q6uPVfTc4U2dj`@qquT3$OUAaCd#@chO)kue3BhTIQ`nKGBPtbHj-mr?-mU`( zKXfbN29#wy>`bpuume6Haf@Fz`M?KP-udYL4`4zU?2Ak_sMY}B0BLkgW;Oi`k*mg1 zmO9Q1rqXz%`9tJUw4QZ_-vUD2HhcLBkrm0@a)VE}bATgpIk`UJmI?ZiiIkj@$vJm~ zpW1=oA_iL$*LF%m${4Ei!}jFgmY`eUYjCHYC+=D^4psT9vbkWO|5M~=utL$kEf8D; z(XG-edsb6@6)F1o^Q-6@B{@M1nUu;pHhj7T=2m#R> z1629(T#9=GJk3X+5M^fQAU)N7ZhR|S2t-WZ1xQpXEK$6?LI{IxOk!P zH>^iz=?#`6@QH+{s2dlU@@hVRxV`G@F}9ppAbe1fXyWP=H+koee(}L={FT>OE5xo% zVk805xd!HVC}hN6=?h9pgpdc`wy~g+AL>hBX+s^Q1ivMq=^q%n1hWfzF@I}Acvt|A z-Mt;O1slEIX@juUghSDxg{^JdLLlEwS0BJs6Oh=WN#ebAwbZr_rg4FXpeYO{-FCEC z63BR3kcgOV7OriA62L=z5diXvWc;@VeH3vs0Q;|3z&>}Lzcs3L^SAW$UGDcgdWui1 z>xPBnpWOM6hshJQ@;Vld!hmw}&H44g$@z<40LDi5|L`CE{eR`}{|j%urGFPYqPWz8 z>pUt)SKSdst&%A|ulatm$=MkulHz;9J>2zEMIXrdgXBxW)uY~=4zI7qr_;N}?s2)2 zB7iN2#1@%L!UWUdj!0bZnk{G4-I#6xQGo;IfLihP3(hwe2-}jrnH+_2%kOwyG)}#wVJYljLoAi25jc=8`AVa>F*$kwadO{UwNETr@svk%-$_LL}EwN0j_?L(rCEBq6K z&JMAjnp>t5sB>KWq*C+^NpPytjPpQJl@{g-Dq&~xBv4G z{`Dj%EDl-mV^eYRLQ*)Jd~iHD{P6hCKl<>n(f@z{yZ_?XKmVI=y=DI{6hvlNeO**& z#H@TqB-GUkPOO*^H-LUSI=m*{WBQ5+i_6LRHtK$8hUR_ZHu z$1Z9HnN&3d3yCgChnP)O`;Z75oF`#A8J^=uvGHh3L#h*<4!K0stU+6&mrzAkf@$N5 zIbYDr)wJ^n4+agdFeGX4F$uw{%goBG6(zyPr+V>9jkv<$j^;H!KRmrYGE3g@lJWi3 z^b_zs@U#;Y<1T&yF#a5^&TTznopz%dB_sPGcvUc3TJZ8^4g{bOyqwB#ACk&1QbysD zlx~53Fn!6*svdMSV~Z5l`F`q@aNU)6W@kdhLCusb^D%L# zmqaK|E(w>Q2f?XB%8#7WikMVCBux!f==?O|dOP~C zPzr$RMwqK-%ZbPZ`+xmZch$pjK~?tp$#E|hYt?wo0=l}hv-;ZwGHNk=5w716Se)4F zMuu|_>5=kgS_S*>#jAvTNETb1%CF{UM`%Y7X>DVCwz&f{|A+0c6pC^KA5h0WCoAN4 z#U1EXhSfma)S=8YL4y_mNJ>gOkW77PL1N>dAv#9?Mz`*CPGFpFKGgWqB@h{j z>$e1+1YEZTdNB_FTf{BztSuqh%!6LM4tzG-;*mP3G6PZvJd8cHI+#Y+%9E1k=_D_l zwpH6?W;7yRe>_X|1mzHw>xV}#k}6ZcI;)W72DFrKzLl=z-qNn(t%;huNP)(PY*qcv zV<7Ev%OIWZPjzO+3_{H6$Hm-OtHD{#N=lag&tds&cr*~}5#AeUWGm2vo^v1?i=+w= z2JWw_yVcW%m#q~Wkh?U8s=K)7=Yx7Os8}3RmgJz+-~y#A_MnzeS}zH#NplocxWac7 zx;48|ETiHI{kc78DbiJq`Q|&4^=pX^xPAV5#8DQ16Afz&XxxGUo=uJ{X{VXZC`$Wt9~DF ztEgY(Rl3MYOB{o(w{@mPt>kw=9b#79H?- z7U{e@?YnFo^pk8D*tBJVkcbsbMl4B|Al*A0>KiiM(fIJH-`PnXbK~qm@yt^W8yi_j zh&2owt#_jJi^4|pSxv(&7_-?`@_>vbE>-{(>KGBYC$ zH|6OVb%Az-l$4t;Cz}ak1wWcApJ{UeJ)sbBmBxD-=)DdXrJf!c?2o>sFF@*H5_uZJ z74C992i-MRz$pPlFMKn+fHTVAA&0i5rC!up4lfT|8}hu??r?kwD{gJ-uWGF?Ca16F z&NjUqptkF8A~e&-Y(RRejZm73y>A^iwVdA%J$pBpPeIz&Ui?*>0D}f>i|tI}Me(S` z1h47l{f*#rS4o9(R)9{gND#Sbfz7fqKEhYslRuSEYq_dhbs0T4(HjclutnW=)#O zina;E(^;Q6-U8QA!^2vX?ywk_>ZrE@BWZl?bih>MtQYswDUumUWBbs>h)foMUG@pB zV(w|)t)ViPUd97X^rmQJ8kNy!hvgW%gFH!!D4Kz#Hok^IL85~ow4+ZuP|}`>(9htZ zft$l=XZj8oC`xvaM0YjO|C14nGeWK;;1nIrFoFO`ymtBbm^i|Mv-r+~mFhl{CL^e* zF$14~4@f8T2IG|vL@;UVKv$E+!>~RdgbwBn!Yd!-uyqInPK>g){cwAyxfF+<4|6zg zki$?IuCFx6_sY3@35QHO;_x6o4OoCL*OJ|tJF*e;P7iz_WVwu_Y*)~LuE(t#B~VIh z{8sTu4;`VMHSu|-udGzX>3B#4O1Jv9e44Mk``bO zf4n&Y8!1TyB%`mojUC6Dw|777cpA?B>wq*ba<9d(ars6#l}7lza4L0T|3wO04uL3W zpp<|L3*teE0RE_sLa%{%H)D*8Ek6>%Kfj?On|!F4o%{AzT{FI6#awcbGa2 z1H74xSvPtR;Dt!poC5XqCcqsxH6a}J{Yez&D7K(zQtXVktM;a7l^=SW2AxFTgr66r z(!6OLdTGWLx|dId^NJp1lEvdU#`|LIf`k@hmru*{Ry=3$n~zTx<$t6TR2`qtALgB) zI3*6D*O4IJ@Qmc?B!mmpb)=I}+(MFltT#F)#du4el(bnw{Rs?`|GFtBasJdLFBTk| z8_o@1+I+H5Ama+yKEsOJm92I(sf*;Z;wI=^Xdmc!a!xxo$EpiWD=A;EqhB}G5Feo- zK0}hFhAjPylpZUT{uTbc6Ox3nmp_}WF@a2@yt%#diEGZH==G2ozxg$mjp|xzGUpjuxxYTy@`O+Cs7n4AokF zsSbt8Be7sU#p#`6rqq_tGwArN6cv^5xPEY*c-2nZYsmM-Mir9FPax-HqhC7%n`K!- zmZdBtVQG|{lS=GJB~TQg{_FVn29^>!6RysvnrVS+S0YpVK_1B+@H+jFrP=&khv+N@AC=qDIM=zgoS>UV5b?1CU)ZXY@m6#b{bxw$-rIZ}iO@7QA9pY|? ztlRx^^>I~P1VoYTl=*!&`S!uOqO-e>H}QDakSN|HOp14Ffmrd@uq$5e3De?v_FUOG zkKK=D?~q1)Qri?PSykndT=m2dkItCcuE0)xliX*6PA1~%l$K4ONp^#UL3H%${{irG`X!vKr&K{&k z4HkcvYQlo0pK?>1fBtD#drmlaI^>^r9o9bxFLyi?z`9Tbnu25E!{U=W`Skn{Sq?VK z*9b*?!&_5~z9p9y%)%G!87B5ll7-d>&(NmjMb7yqzt?vThWOG$K(NvhM7L``)8=rZ zG^#FZhE4|b5p5P8qE>w9htB3{4HzxFDMkC`9PEYE+9E{5i*MxwDC^tvpT08BnLViT z#M4xmjc47*G`+~i?U2o+_~xUxYqC-Xn=$b@BKB)cH1}6dHb1N;oF6gcJn3R?)x={n zY>zAjMmdo9beVV}W%rPe?49l!GdpA;(n~Z9D+eTly(&w9YGiD>U`d zt=ia_VBmXniYrwlVy!%RF?e$_P4g05BC%8}t)U3qL?3$|LV8>3 znNO~aB2DL3@B7}Mubp&ZGOA7B$2-;m@WHKq$-3eBYqJq z_pc;FGjZniatOeL5)_t8U{K)y;z#-3PevusIDJb)&SQV+y>m37VdiJ)RpcCmrBF>Q zjUWs5Vn8>`5KWT575!*84Hsu z*jQ47b~>oSUVBy;$W&Wj^tsmsONIDIit@}ySERXAUM0mt8MfW9`d~VsooA%hOr%R& z3EUN2yktU8?rn>(+j~U`d9y`71Xk@5NG^{+K`&NR+EG@|3XTvGghrQnGI%4~w)!CO(jhfdq zy5y)~eX432nrZ7`bU2g|CMJ5<{);t1IGrbl_&HQ&BB>LW%_spwWF-|~GCNzE>%iq2 z(k=JnJ;}HGULzLs*L&PN`wmc0+N6?w!^9za^#=Keq8tT+NY7l(8ey$K2IU}Qrn4Kb z*7DESGgc^3Wll#(f-?nL?7S=Ak(c>zs6y2VFRCADE}}rb?@P0PgN~^C9QI!k{zTbD zt*Tmn%%jF9;)0JPEC_~q*B1;^Yg2yAMN+Y(Dvzu-pga8Wk)Uk*6rW)=9iTmh!nzZu zX>(f^e_f^oI?yCQ1igf>=5Hc-q~NIORdi17l{ow$7zngMh+dn2p(tcMv{x-eyxSbE zOX~gw-&RPwj&A8M1cP+{3#Rogg=milM4w;UezWeOa& zmD)mst*OnA`DJ#IA*{8psz=JA(R(!48c5)Bwclcog8OCeumF5wQu(E~6`a$$9x@gs zC7fN?hcc+9E-ETMQ~8D}6x&aB);0!P+k1o0x1VgSGs)DyZR#;mS%k*Brd6B1W-n8p z)9ILuReib*DtIaRFLc!kB<|3XnR4TPXFb(Hw@#u2gO{SgQa&Zexi`Em`TyK?Ew0-n zjY(E?RY{;C&4@|^Qg(ISr}fdlVs;%ac;Pb8Dad(#g<)TuK!hK}%Y5hhYN{&?RPRA# zC92Lyi^2VXC!=?8Tj_ymVY~PnF8fpdpE?M~DA^RSACWxxVx*BMSaErMVf9*{A@|Y6 z_${78;Ter;b5H8q(#+3vOT@u&meH#)v2`JGy*1hN&Iuyp-k)n+ObA zpaZH_x((KgU&5|5rL@L1_b4558#{O9(dOdx`edy8I_!XGYE;#>UaM0Gk!&o@mO%H$ z=grC#2cqntw2I6_XvVFD4K-{d_PVf-sC!?tm+B>N6@i!n0wMS&@CFtGHNnWsC_$%yBF2{nkh%Me)}juI}!NTA1PTaqxPhv2j5? zdC6A!I?_Os^rsoskV8{3V}9AIDO-l+HPw;HvabilWpmyg-#HCyndg-4Z>fqD<|pXGpb%zEsgsKWd~w zx@#_(cOhMC(*ESb<@o_bRE9^IxgKin%!0C7FDfY}LLtt*f{5mX1bmrTiH{XZd~jK9-=-AGcJ!JV$sM}=f!2$@cZZQ&Z9 zPMu9M0sbkTw#DI5MonPxtpFfYiG!Q~79$yACt9|*@r^LqUPL6#r>qz5TjMa-wLF0a zsySyWyO%}EBqv_?6ma=z?2oG^_t*WuMxvf3OiM)%D&UlnV78EUgLRr~g5~VC%!6VL zeq4pX-4FvRU@2>-Ajz{P>)MDmTo%$kH4`JUo{<;Mx6cNk8weCkReyqFIrHqn`Xb3w zdtc1FkUsUMyAw=H8_jlWr;@|Krf?_eiI^}i{ck+gcn)k{4NjY-RxcM}I2~zcp~u)) zGK(ZNfNjv(@DFB4eptcM>yac;prwPOE_B(J?r*AeiPSQ^anQm^N-1AwW#G36rgmJM z;pfhGz-O3V3RR#&LaITflXW)MH@P298R1dqC5b6ef^@@;of&Gs4M|qEUb;u4Q;{;O z7okc(-E_Nv#a&=7x*SH5r|3oi0A7_g3cW`XoF*(mEX_9`BuU`z2mmc@3(>zkJlt1c zXBXqP{VF%mIDu;l&y?}BpxgWAgwS2nielB@KQxXd4CKl`(h^(JjD2lD$EE5Gtb^?UlMAyvib(aT=>?;w;9PqOlRM#W`bz`!+&N zCUsCGPLpYj)daD3b+K*k3DBNf`OQ~^$^xTk8*;LeNy=(e6p(n+>G zi$(TW99WvbC%6GqKO8NW?~3zrO4ZEHj!G{jSepb15ypXXExB8Vy%4L@pt2C^b9(P} zi}%UOr~Zq_O%XL18keivOr+GTMOcBs-peXYdqo#D9VdNQyq8`boK7-I>zn(?Ec4zW z&)C{yYQ^`=5w9=CMK%$4(^-O*E+v-!Y$kk+;zLDv_!fTx-dUvv7Ysly>jd_I^*i0u zf|&s1yTqb1y}l4yf_cJH5F8_p%6jwSQc`6A6MW?2^K`99saXTxGPx64$C&Qb6&x2)?@KWy9f^XnhPO~(KR<^2c! zC&4o1%=mpa-B?-5g1vZ}o_uX_^zbELWYNGMDbtb1N zILf8N{NvyR`d||)WY6u8P&ggSuJIn$XH@seg?@1B(R<_b_wIDw!%zO6Dbyu82voYG z%Ryy$QcJS9UnH)?twRJHvriZIogMJwjsrU(3gZ;-^{dvvuu#K>-sU6QQ|*gYr4c_X z21cu~VY=$TgXY_Qp}}W3^RMStf$srN@9!ZA>a=GXbuoC;Yhs`St28| zhQc$=R2pYHwN1ciVnXXUitk7$;cR8Q;ZeoUFQy-+vJ1Dl#L zhFM`d9Rj2!VG&ecq5gvw)CY{Z^5b=mfZco4dITQKI|2{>07pO(-#)8M7?(Z*oCNAx zFE|PgI@`h8QFu^bWc?^SXdHzHjiV6ntD_JNnn%G${)vvlgSMma;LVT1gJf%{ZN`s! z6w;K_Q3%v$Bea9vDpTqrAZ(9=O)NM&T6G9j-*?mb;$);h$##emTnjBmr4*AsE#;WF ztHn4}-z%3o)$O?MuWnjM$%ro3oV*lu0)O9q^t z)tIaLHi`n#s?W<=s%aW!dgP5WQ>_cdQfvR3Hhs85&m!g*DL>83Y%Jd9g>+3iO0dka7h*J zDsbxPVDjge_kJ;a|MpX=SkmV-r zP|L5E2b>v=(#>HN+~}$Lu^=?Rh7wnFRBetL?s}?k zWwW$2r{Z~PYIkhVM%IPyiOjQHO^ut#+(#SdgxmO7@lamy372M=fze(1vMom0)UK!U zV}kq!!fJUx2y2i1Cq4wxa=Vp5o;v;LbtShcHkJXo>qHmu-L)hUI*s1a&nWS3TcnYl@zG6svXu`63>9FkiGO(-@289ugPw??!&5P(% z1Acz%tzj~I@*)up;h|uHHCDI-fMJF>Wm;QA5dKcg%tIE7Z(%#uDV zDv;M#FX2R;$4vQu{dfNTU2aJuFCM{^yWt*=EN`7qZf^bdn5Iu{EWcL8f+@(@Cd$E# z;tFHKvY^=#riL?&AF}K7Th_)C?$cIXXm|KePe&uJ;gMCdYq)Yav~l3id=3~YTz6`c zRAytB&6lz+&-N0)wHJ<}^jrM0?E)k)-@z$1Pu|-7Y(*2tzOoJK{E(vVerp=V?^)C2 z++0`-I%qU2`#`hHYDWa`>pxlD(CN$4!yn{G`)FXy99mG##tvu94(cHHl-)le!k|-< zYM?)L=Y(`K8EGZYtF5UECKz$u9*AI{BpYNcENej>`ciER_@Tc=ujVY%p|Y+y=&4q{ z59L+rEv?HXQxb8@aaif{6!sE<%X|8+X6zwlyrN$=x8t*2`R53ac} z%P;tqYiZw%@_>m-{T$hSLqRNYT)v2F`?;TCj_8WSL3^p*RE~kTM~ERiK>-It$`B24 zdAqLCXzrqdvXUDc~O8VXVmT?)3<3ke13icLF|1&voGy1^&hSN*k{CKuq6bDVzV=f=l!5hR73{|@qGb2y!- zSYY9)XReUZBuS_1!#1}bKiR9s-`jk&v2735w3`nwG?yF`X=k~M2bt-bS>up%j*;B~ zIPg-SOm(rVm-(2I#L@ZF-R&*J#j^ugoUU~;+qXGMM10|A_>J}@PVRdIfL1@eia69N zkX7yzgNr+BPAAY_2(Q34Vw1U5&TlOc*~POY2-aN(ypU&-=BJaFkXxNiI|V6Ei*hpK z9;R@7MG-siM9+`Poz92Y<%8L~CE=zI!);(UKE{X6JPOw#>#q&w4RoI3tfe9SYPk^v zpDXE3At-he_@_>&%Z!0WL%Iq&?MqhZ58Q{l31IS52oR_cpIdxs`3_%>>10K^WaD+1 zZ@ugC-A%A5+cL!pwElvNy0~6dbL(~`Vw4LkTkxq+=XMGub^&AtsJ343Q=sLlPm^Nh zymEkx0?*Jqk>WralT#Z3gvGOYbrv?yv9`;3X>rh2m)s7D(v9Bp4VLpkhg$!2V#;4^ z%X`pMD{1E6ibX37$o;1NO!G@ts7LP)HBlH7Zfsb=W_bs^$V?NC(o9Jz*;iIF* zBnxmWqS?_;*k(W4l`Y#g3Hv(c+Q2aGgs6|oan&myUrnxtryhvcOH}cKqEca1ep-2^ zlYAu-Z!DAnh%OoT@{nONO|#_IK|3P=K{8q4Be&0z;kfxZB8R#+Kc;4qL^k9uJMh3a zolmHa-MOqs(&t2mqAXkP74vMd4W=C?K~AT;rBi#Xs6ZF(PDZ}-1*8}+_n#JRU-@P$ zm&0l^OEP2u<#k2DzQ~$&yE&gBqi1=%jbhAh{S57J#+8n?)8v95LYrrf(QB&Lg_0u7 zdJRn`*jP=Kncu}cUB=ISqNRb?G(E{1Ajp<0Q3z_6=$!56@kfuP;0XFQCp$i(m1+{t->;5295;W@u9UiuXrE zT0!+P(xw$cnMlmb^R7sq{Du5$!d_Tpyp<81#wPXsRkG7~3S{)E_9fTzm^S63NtW^t zi`Xs~MCt$y^eU#RPE81z(}|?7ascW6ZGCEZo!{0)iAbjgz1o@*`IA)`2?6RSIOUF{ zRuS1{cLozYXsKeo)RW7`(FAHne(KCwj}C?6nwe3wiZW&lw~IMS3dVIDnbd;@63FYr zmhVk?6nf0!e}kl@sGX9cRi~g18TD6(G!v;murY5q7~SF^&_VIbzOJ7E8q^;-`LUDjS?Pb{gT^gw5hFo(&ZNw(f z&EEON2k#rjzgp47yOQG(eZ4~xEji2QotIR_@v4YstMgm(P#`B~7kVMZ(p5ZlD7{WF z2vA}GKyc8t+z}8zwXA{dUCZ8&SL(sz;FY*}-r3stYGX&8X?zeK**|yd{w34*vE8|q z8smWs0^D?ded3y>OSKq22{u+oJ(D1(RNXir+3DpJc_Nzl6fCc5GM!R)GGd`M;u(uT zab;@5bM;~&vv(~tKk4}=3M3JQprEcS5`+;VpA0>IpL!tIT)^HBcl^RVD{bGk{VQLT zu8^fm*8Ir_7wHjWA}ioLtYK(K0CfN%pLuz4l~hXJd;i*00Z%ji%+hqcD&| zo2GWcI#GZT;h!?Y>Y2Jc7|DdH!O-QItg(4vo)e}Lr=d=v0#{PEjVPjWH4#*an7v2d z$?#mzCO5%K$lExolY*4Tr6ri3WUfhP)Xg@UINhvR3lRvZ)Sg32T=PBCpS{!yv*#{( zIw{wgo7#NXWYG9>OqQw!n;;@;K>F|cB6R`_3gAo4t6-hz(VahLEsce4C!?Bejz9|< zh0%PfBLNN?-kRw}Wo8(Ecnr`D-ozslwaZFRu#Lfy^hHm+DNHZg6WM|+Z%^kOOAk41 zquT@phgt9Q&7Ivn>w^=4Fu}g{oE3cP>aP;ZoyDNigDKw-U}NOK)A@JzOaViJLEA_TPUo-+(jzg`dbmT_UWJ+Fi@fWkVfB<~l}i?76OyS6CdTpVv^! znl_khXLOM|NWC8p>lQq_H>jUruOwfbv-QOKW%JB}z@3i8=7`Rjkl@>eB$9i1 zY<`FMn8>5+sa5HiT;8=mvG7YZ5tnu6S!Aaro}vSdj6iHki*!KXz7yydAFNc_2iWo< z)+%P9TsWHwQ@$FTD-SP$vIsm+TEx$#1`{s0;2!fnv|o0~eq`TZs-D0%RMO~+^<9sw z@%S6-Ip#mT8;1iLGgP3uSb(}u<{?U896m?n*)V|&7sY{?&;&ppO{^mkPVDP1dFsMS zO%Ouqp1|ag?5tP0^_7o9aSd3iq!x)I&S+-P0P+w>YI=WxVs1@NHb&Q096x4GW#|Jk zNQB~G3JW#x6%>>X8=IA0CrxazN-~(@&PG~wb;XQ4@pn_fnL8s1qAF}*iiJ(8T46Fv znY5Z4Q85G>QqDP$079azcUp2UpY28F93Lx8dCQn{VtF-ljc-q(8#lBc?x`?44oXC2 z(9uN#b&Yy^pv z!D(76(OPo>PT~p9Dui>y8WY;&f3#C`HsO#>&dE^oX(au_Qf8$naDbp&U&Z0dN7v76 zr6H(unRq)-HYHvvHcqG4x=Gk;JxMehom`)ysQA8_(2E#qgeD*7v(Tf;dzrI!+cVs1u{v56kz(XAO`TNm&|dC4QTRS_gOaI;*G-p5g?we3swV zzM7$vX86zkzo3OqLtdLOh6HBJ$tq--o{Xvo?K<;73LNk#hVNmkFcISr5#*fks}n-a zC~)9pk$^58_jCS@%2FMItr98KpQZ;$w*>BHbs@gX_!gmR$cF??kzoDOC}XC{n2HXJ zN-O^~s!EP7SFBvZzH6BjiX3wRbcfguO3~!FEhsfNY(R=FXh0-JCL+a+p!4)YhV7h8 za8jiJvGC(*iZ)j+Hxa?F%eO#o3(7jkZC8V+X7W0B<7Hl}?UQX~M$Ap!w}83VtPqXnKsw<|88tt-Hgg6cGAbPSDe zG%DN!N;R?Y&fM7l;PW(A%l9)J!X~FLvw+(ivSz&ZF`!L%q$+;F65!(=18<>54>z|6 zFk%ihD5w5gn2eT7YMq~+w6P_SYu?k`Fua6G9;uwC$$hXG4d=O68~xAouBK+VOIfQy zPW-sdS)rG0GXIToLfXWiFVE?1=OoE34gg+)heIxx)CCeKehfvxkqhxEuuQ12j0?m9 zefcJwgYE9tx?cnHRv2W1+xM6r9u4G%NpNpMZA04T)Y8GA2Vn!it3&bSSN4v0~j0*PfX2KU95X zBA=aqVzlIHErn0Yv0?UX^qmoL&+&pGM&!;OnbScGjI8Mu=yvprH|Oe^&B=KvVF)r0 zQ+F9Pg91Y+H~J`CDo%+>E@}Y-;&_UOHR>amOadAZ=D0RhIAJG!)o4~x&IiGY$YdI& zT8zOZFKR|O*+|9miw_+y)w_Vu=Wc-MsFwk$9?N0XhG%i6nNN<4S;W~C;Lx_Y(aoI1 ziTbTV9UUdFeBCa)AhyTJ;-(xmga%025pikBlIS0gSp zh1EXV-rC&T-m#EqR58+R&qjBTFXX^3dr-LT;Pc0-C+SVX657^`LX>M8R(dj;K6^DC zACf=ei43iueEHkm&9&V@^V9R;i!p8*#o{mcdTaHo%`faewXy>*ZhO};sWIrIjYr!% zza8xE@zt8vRBKGRFI^K?uD|xdfJo@#qo|@8diVT~iI?78{bFPA*^|#d-`Gi=Ob(Rl z;`%^i(CyFulzZF;Ym__Q(|FMoLgtldQ?OGx(fB6UOKyIw2>l#z((<9*d(`}Ba(O}O zsbvBz`gVKg@t3Px)E`~ns3whIN%7~q9^~LFnBwsoSnzsd!T3o`ZCA)*o zEe|#q1Zth(kv+qBO@#bX{XO2E~jU9Rt}{${YY`tV_EmoJEk zpAdX-Hc*MV_N9KcxxTSI0J{cHb~o0w#EUpB_mA#+9ONK?*684I9GUy;16|$s)kXqX zqZ1KY%dq!}rjdt%7)@~o0Rc5I!9&PL_>WGY(NXlEPx3%r9Ex%iH{@kd9yxi(O-`U7Zj1VKq z!^BG?Ls$l6O@Yh+Nn3J15@>gs2o{N(#yN#lB9O#SZgyD_!&pWQv4yfCr+@D%^wwXUpX zGA@|gl*yX+5_;%}f)9J;@EJIKdc)zt0irlCSt#pC*Ozr&%}s)HIbOCch}*Y%1@Aw` zV*cHz&z-_#6}^v0EFm!3d{=MtU20>R!{@-&w1gJ0S`a~PUu-YuYKm<$@WqEK%VUWy^j7-QYiBpYEqrwp*k_7@Z!(d7OtkQ zO1w8a_@n5d*1?}u9sDjj@U?G$^|5~AS&{@iCBSKTv;uA@hgBjDcOjn>Fg1A@Ewq(V z{RlE4WU2tMUB;ZjoK{PmSFH6m_wW$Oe%xz$Rf0}P#fDON@a*Ea_kkAk^VErt@w3i{ zeaufE*#G~6|5s8<2;5JB==a^zAsJQ>$!@uGijB!Q%**@seK)p>2X#RHymOzi;is4& zm{Wew-*jY0jA!l}(p6 zQj_+`J3|_%FA3WuA}f}`dPK~Z+@vTe0;XP0F3F{*xsb_-f_hb|LG)*Z&#WtjmPN=o z`gP7vR(piH4w;ZpQ@e4=M=EN35Nfe~d@Prx^F*=vUj4 z!T9Xr)H>^NNw#9{1VB)1dY3DFukPy40kIUgk8{%Gz^5?nqtSZ|M6(b=a_o!HHr^>b zi=(EE#Sm$`*|V&Wq|!G4sJ1!0wwgnr5+%lwr_~FcKbogkHyvb()tF>{&S#c>=u?cZg03{a zNz|t83z6MA7)10^%WC}srE0)9P+O7#GrY~PpMKHz#+*_XR7gs#1`m$pw7w;4TY6Sz zt>ge8i)5&BA<=QkFM!y*QVb#g6z{nveW0rF+dG4=HaC9rczb7$tJzn-THSoO`q{$` zBk+*;tx1PEX?f7$3o=}V{>83XrG`D|NYN0HPIL`gXjV+!l;H$b^hVx&4!+f<(7dpm z;x?7nQuPe!1wtyxq0_s+zG4C1))a%7$e4eJy>oQ#+Sj+hJ{6dp(WGNL#2&be=ewu<+jwrv^=ut+t^ODZ|3k%j%grr1< z&NT_+I9}x_g@z8}b0W&F4+W_s52z-nM4df+_=rxoeYXY=W!NB19no=)!PS`>1Gg)X@d$Z(p0DZ*?km|9^P#e>gO z?9_!eTM_rr6S^4q^IpP?_cG&;^`#efG^>sUbVqUoAgg_Y6Uq)LOc$HJUBM>6KLH}v zn0XElUJcLq52!BCvQLkrLDcDo!`=N?*2(FqZT@X7(yp}Ge8}pBcAc%SS zDe`$D5^d(v17@mo$47KQ@pFSm$_MW3Vjnyj9gu>{{I&;FrE!0==Ll_gQt_JjJ{OFL z#iU6ywF8yk_T%2!U~dG3_-Zb<*kWfxlZ6xe^|^toKnHVI@dhy z0wAH6Bq$QwwJOFStRvOwQCtI|_?`?PW^Y3vQQ47g2I(gTBdUvrntDl(?YJ($T+g|2 zK2)cwF#jBOw=*4MIpLzT%v20NBN9V3%Z0jx>pmxD6K@#ep<;VweKck$X`ou~0?C7A zCWwIHCVALutH+J_{Y;=5r*Y|;9!9Wz)vGN9`mH7CX1I~EB@pu?6C(lNSJDK1KlNkO zs}Mrd^D!DpT^wu5n|T>RvyjROvtC=rSiiZx28r9+c>HK(>uSm+GI)c8`8fPPp|LG(GsYpcxuLalAxHl2mVh+eHP1D4*0gwexpLV4r^@ z!91QW1LC~kTj%A8TNX1`%B|bukd~5h56bP$OcNQfBaC1Fc+ zL;7J4C)43{3wL;K8$F4Jg~KQG{|{DQ4(cSSj#q`dz0U1FQ*&I9p1R zB0rFF!(!mpCsljYT;o?-rMJneR0}3Y9wBa$)TRXz28eQY85Y81fdi!4Qu)mdCI&+J zwXBM`&NVrW{2Ei_iyF_*Gbq*!kFWyc0-cS4TI7>=+Pd(Y*~lBj!c&*l2kNl*VK8Mb zsS!%G5$RB-&nHV33L*VCU0MAI{?xJy9C3rJ-!37syZ;AnJ>YJ#06O7cu~U$agvqj? z87p#fl5Ac~SdB{9wa>IEE*13kLbfiMx`L9Ss~5JcN)U9Md}uNB^3H`PQ^Hdn0XfeW z^*)emP7AcmJS@#zC}_{3*vCwns`M&`UPM{lgczDRbrykHejQ7_SA0R`D1CDx_ZALB zL~aT70m_#q#)?az^VCkYQ4pd8aU(J}+aqgvFxg=R#Ks@Z^gMP^H!ALNp4>x4kNjDC zWnuO)n*Of6e9n1{cl;R#e`RHbaLSN(bnri+Qo+H=c*R_#+^Hz%1&!!Q7!ob>2{opi zOM6!So=gM`Zug&V zF!gG-F}nBlS;zj{r)cI&`DcdYJlXfx^5^#+Z+^YHxfY)_rCs_(@=*EYfkAR#_Xd#x zbSU(exRam@O*R}A@|k3YSKN_SzpLpn*IT;e$pO7&xO@bv(QGaLWe#n19#7GQ6tbZ0 zcQc23My=p!;9*Y=XEU*x%QYe%par(gq7fcM5VI5h;9m`Ay1W#I&W@DH@p_H*d{BIl zH_=1Zs-tYBgha0Dctl3G_sWsZe-~O&yv@oHMfzXHhNhLM;RBW8C3c)CdgLV!)C!vX>#|^p-ePX{*C0g|eh=+aN z_qq}8))#3D7}^6z;j&%cwru#gL-CM1UKhX0-$ODDA$p@mFRV2*FoK1E!k+wUPo9za{E0A1F~)zwm1= zn{7lGQ?DZRRj(>ZUi2D#AgN^zwpyRjqU;f-P<5EeY0)bUmh{Nmng(-uQAWU`munen z{+Fg|Jy%fEOnyS=b80# zNNc8RFVR++O%+BWX5z~rATT`C1wnSpcFApC&$3f2 zB^l)_euG7eng;Wcuvuq6fy_pwch6;|6(^lGD9~%e~ zPoe+%ZUs%xjr6-Vi6+^s4Z_R(C4c*eOId5}^8$;OaMn1^{w17L=vpZz~TI3vd=+FGKH~vF;U5f%KlFqB@%M)l{4M#`AoW8_E2&~$ zvwfLM?Pto}xM*}lm9kq|Avu7;%b(z*0zJ#G=fdpLcDg6RykI_N<(l}!+yaoAT1VCU zI6TpVZCqjxWX?s6jbO^er&b>#@-e;IcZKynW>#3|^X2lzvxcidl*sJ!K5T_YsC71w znbfENsZhy`WOzQdW1`!lhFIK`pP|S|abo+>1brOJ=L=#{ z-97m!F`pP|(IlnPf(?7P*9cv2SZ|_U?sh#whQN6Iq||0rI6a}89ts=5Jfl=7W@<`& zHU`J#xZ~cy)*N!{3qnxlgNNCY^xZEi!B%Mat=;kvFU=XCka%KE ziJjU#AtE5yCOv!$Zx1(OQq4IdgCn(O&e`|1J#dmJ_bXxMmIYuUU^Tu#3zyeJ$u<~nO}Ij z(b2bK!kQ0}p_(m=SKc<)P@XlLR&UbiR>g;A<|)p5${`-?QkHyAW;CG)9s@8{_a9cT z9B+6lp3?AH=?o(tOb%2%W4KQw6B&b|2fEl+dQb8x69qaxGR86X3Q&h=R~qwg3#)38EQ4Gvj;E@j>vVJoF9~GM7#n>MszI=Zl5cH3(B2EF zvx)DwM_(0@o*|QF!OCf2Wqqw{v#yzLRq_-#q4ZqP3{!5(^r+imU)$VjCz{>6n}kKs zIt3p|xSYryXSu+^r-b(i0*2uhu%#W;_P7?1q?Wm$EtJ|E9M$G$si_~1R2CFtT1+kW zLvd=HqCK+0j2wAmXh$Ya(4yFzq?)+sY>6Sm&bCX~pH=3DZ9Y!7210be)#@I-V->OG zOqjTv(rnx^E;cS8Y2fy(nmH?jdhxl*#vVqD9&`||$^nJq9q|CLCCcnlbq8U{u3r%0 z<6_65UAR~e^zQV!_ed3DDo9fyLq$&()F&z4)6Wq4N#!QpMf32q8S_cR?W{Ja&Ocrp z$}a23Xus(-@?0EplJ7juRo$Rr7zFy@;+`qAGX!hpy)CQ*@!tS&p^@kZY!N|4`;%m3n*=>y*_BKw1r$5Wp)z352S%0^#JAib$6&(nlUEbP^wP<; zFL_`Sff7)%l6f?s)wNIoHfRN=c2m=!3O-n@K1y(N3xV|M?l;f6avJHsVltYPQL;kq zDgvcaSUH^q;VSzy&du#2MrI7S^3HjjSptZILb$-@DPbB>Xo!p`2q6hr7qbcQFj_7E zlsl~4H)l%y&ey{O#fLbMK+R5%{2;c+#)wF7!J zqRW}359@;H7b;G5wl)84D24rTYi*m->&*UFX!r|d}s>T~Bx+Y&l->^4mZo$kGh6wPG0Ggr9+9Ff@k3P=!1i+lf7r~BA4 z+lj~O4X*~7&NLb>%&e}^s<(4ho*7PxGR{ByeZ`%B?{2$FBpBh(gD4tP;sc>!(a&z9 zpL}p`?a@=gkhD4$69gtT%MtiRmwQ)@H^nwd!h11B$eixJICD~>ZjnP3=Yx7bl-(+) zk~o>c8@zZuse3hS=jqQGG4rfqLv8PmTwY%PdY2VTsjY8W3GLqM1`tJ%N)%m@~7EHYqMz z6K+hIKy~Zh3yk;dgGz#TBU7WS;}UtMTBe=-l&R=wn10R6Q<4Kj9kwOpz$XzSK_pTa zqC_9UqM(&i1hj03(6*SoeWNfK<8Rw$Nx$n^(Cg}B8}g(`eweC>n8O1r6A<@6AY361 zbN9D*MF|n>2JM~f+*8V8f0xh=@4(T?O47r8dd{KvTb#*8TU3#tcv(muxg3E)&2O(c zm{NExgtPUCmI9L>(nQV8S#*1k)7buHV|VQ1#R2^uI-c2JqFu2zdpEDR6c^Nxyj6hS2K| zHxwhSL;Vx**>-0!I(vl|(O2KJHsqD+YM7aAEQS^Na=dWYK`RAkI}IJHa1j1FV?y}i z;>JXFw-oSQ*$S!AuKU_nCS_=?rg&vZ&Xru!MMSgJ%kjyJ6>stisXuw!a7(s1oy5`$ z#*|hAMc0m5H=pF8fQV9V{arglQh#rR6#+RhAeYA7=V@#vp7#SdHP)jK#ENL+04Tg> zq=-mg)e^kbg?QakCZ_&U-K>Tet5GrJ}0#JWB2XXhIA^ROY=(U}plC zFQiiW`@}I!7=lNoqbXg;zxB)}A*i3p+iPCFkW8sKkqWkvRG{Q3iEMUCHJ~1b@2)sH zUs=M`eH=9M&D>toaj`D^7|i=nerdn|_Zz$teQ zP$=MMJD{yfKui|)&Pw=gZ})EWKTttP{}*ndNa^X3o~HjROD0Z}X5tzSc2C@cvS@J= zt{Lz0j&oT7RdM z`Oa_$`!MBRk}IEJSaFyLT&i^!&%8540r6z(%ni-DX0zys_*ZolX*FCjfGi=T^sF@1 zdGSh-JIK*)(1Mp7c6vAax3m?qU0W&+Kg>Av-AU0onO*5L8EgvI9Qf+TN+3po;@~KS z`dcjpGKfjVqHgK-Bdgn(f#hpVFTJQ^4Gig(B74c~LFwnzSKTouJH5s3#UaMn5?N!0 zraJ%rD4po(aBu84ZCC?&pUXTpFe;t=ZwIv1*=IFe@TXtI<>$o(g~+g`Vf1|+!S%?R zs)Jt&OO+xQt1x-*9zrAc{aPgpM2j6OZI^B=l6I_mgDW1C;mMpX0a9ox>Yh5@h45(C zuF5!4c#K1M?rg`bEP0IbKm_$kYQlSBj*9O`QzA+X;jJ@`hWd{7^>XG0|6eJfLIumP zkFu$p9ZG^)4OJ-cKfbBTkq3J#ny^T+FmI2Wm*9>WG?4?CeqT#~vMFK0WPpICFr)$U z8q!2F;Yy=ffj-d)2n`?{Y8P0M6aQpiOtVo2;AGa*Fxn+jT6*fs%C{A5^xWErVHMBI zZ}oTjHYSndzzbBJ&r9@~@(HJS^`!HUe|_VB`e#*n50i7HNd+lUpaq6b<>TYW!ID2o z)K3YGQrD7h9SMrDzw-2(MGyR{vKq6wA2dgeQ*o=1Y-kSbQm$m`3z}WqlFn~BAIm9a zGIVPEbR&~rgFssr<*5?sz1XmhDWU2nJV3XIa{-$s)7gdVN99Z4~bolELK@LU$J+awa9yD7+yXK6 z#DrplwqD@G&|^U;=?(5fLja&dNz_rv+xDv7efm~+%AoOs)zQboTV$4Wl1B2YiK&Ko zO3B_H;Rrf}%L^RqFsk&h_IAU*+oS;Ea8DI0R5XY%KmF7q0j{+~^DOSEZyND>g_)yS zH4%ToYIT{OT`LY^B=aIhEJ+zwEULsqki17}DK(F!acrz^n`$rn3t>nV$)q|bAQ5yy z*^)5K=sL!ZH5DFLEJqlFe(8}EzZAW<4q!883wlX>l8dBv^J}iJ=g|`QSfh4u^dEs# zy4nnEvTK?x_zB*vK;&)eDTS-I@4;3vy;xla`8F8+Bsay^KA8dmhfAvdBJUtTpgrwb) zTe!m`wC&^D=qN^^6COmCd6--+tOjp6wXmYZx7%A(#L02rlz*neC7?i#&LRj$A0cpJ z>QP;8sv0s2YEt;7%Qq3O^}qn|!AwRhMMoOwQ1y~vmW(k3y^*`iK&lHGo-5Uq<50b>ZAoUh>nlzQ*V$QW3W~TQw4o5EUwC8m`s)pY1JZMAbOfB98W(R zLTRj^;B%IwQ2c&)e`||kyVTMAM^EpopytNv=ITQ_Rd14oqQ2N3gV-q0=8!#{B&Gl< zd3y!jDAJl9O9noF7WJ4rmoe93oX$_GuWEA^1#4mi6riOPbNf`y^=6Jmi#931S9nKS z>lb%F7jFQJnnFqvlBfv(I=6apcPlp%-Z)W@IQfAnj)EIs4LK!J6Ld@@xJt*<%kc@O zE*^@Qz?USexVa*ZA+>cE;1wL%4k|5#-hp1#!%HJs+xGsHQnQj9>R4E>G;0S;2rTxP zmeQ;&8(WPber;J@*`#)|0X}FZAs3Zd<%EJaAhjYN#JP8M)89CZ{R~H2VGCSD0dB@T z)C3YpSe*V6&5WDc_+^l=2s`AVZy z*qR`$d7&KTS(&r8HZ4Mid}TV?zS8CnCW7&2N=HOs|3XHh=DC|i(!pxO>^TzRn_iv9 zH%dd$N(o{lL!ubg5LF@`N9mlx)lF2a6B9>d8M`Gn1RB3tx`M2yy8aiI2`0w{s~QI7Pmq?kID>(NoQLxkv5Drkb0YH z?xX%-X6W7gU}nYuOW-Au2Ci|k(vwK7cFPDjc z$)U)ju@hbyt<%6^?X;EjWUEkPDPtsG9iW$PLr2|gGf>CmoevIY+stv@)K|+YgdW*_ zT^==`*g?5aI`TS!bfzq3HZ~ppvXe;@EVusY&?B3 z*g`8>CtY@+o0cEjgS#%lB&Ej6R(12rF+Am(Qq|J}9yEYk^%i%JU+LzJ8>(+9SxFv; zi>>eytAjIKQ25>W^6&lqfB2UF?-y_VhyVDW{-64#fBO7`NsC;K>-#EiFc^-nhe>93 z-Ieh{{Kf{>Ctr#GQ*nQfWTlVJc0b;kOh5eg?Rx+J!GH1oKl$>n-g-;_TwmIMm8n|{ z@;5RnzKs-$wS_Db+?5YqUeKe?dNt!tk5Yb?M}WYx_3{j-=7w&E**nEY$M1)HGv$I1%u-6+ zb(_;Woe@?)C<*pP?rb7sAkzE4mPfVhU9k|7p;btzYSzcnLEs?UYE>Hv@N2Q%?N6&O{fI;iAzIoWrhPlk+1C zXw}?!nY-qkR-N#o`oUcI0bX$r`uf)TlYFatP&h~emZg_xiFS0jk5V^Hn%76yLb@O8 z{ARP$BT~B0Z+E09j`N%OELx`~8pq7C-^fsh0&W$SUs#qEzFIbFA<~vUF&U3tLr&HU zN7rTFWW;KIEPM2;DS@%AH}&PWjOQ*l9})@xTo=vHUTTJEJ0Y(^VGK3+sZY2#sdQ^K zC6U}Hq!IfRWN;4EXe}g(EBkbTtPsd`<3cb z86zhYn-ktnPN;Yeqt~vS$lKJ}jt`^oh0ugbW^wYD_Evo(dwty?a)jntC21NLGFz$ShQA>|Ln>wqMM{e6&A#P6XV+ z5~#xQUNy7#+%-otL6Ru)vGLmB^vqHVWjQ0GCquJO1kP;K6Yt2~%2?}d;somAHs3Rc zj$Rj=>8VXQ-XsZzZAYYBk(NcOO4^ zya}xtkp6W5M=@H_qXV?nVxg%VI+z1EWG~a(3Vj-;x`2Ch8q_?I@{j`i@s<%ywk&$H zgPI`X6RCz0@&*a=Ijj4E;wSXf1akwMwl6LbLPQsBr(pFhHvubGeY{J_)O>X^8<#MD zz6}~RR|IKuQNwNnzyM1%&lZs6=?Gmf0S?=as^@?})mzKz^UG>#6`&E98nX*FIgmzB z6VVbTG|{2f83#BCO$DN)K3!3`P_J3gtGa2V9{Rc}G)45UWS06Q7o6+Vb&}Oezc)CC zbKQFva%6c8=3pUHe&1i9U&7ED_SNWvopi2#2j>pZx2U3}{}OC|`(s6$zwhDZac4n! z-t0q?UdP{0j^;l8&_4U{BX#kSkfBbL`aw8;;^#Y)lM^bvG-rNGqu#W!Z{BVh90h~a z6f{AI-Z7utBp!}jfVQ^+jGkxeA_YtF1I^O$G_6Q+u(D~=6q};zpt-vwT&E~FWns9BHNoc z4D~uP#9BWpv3P#Kyi+&qPM2hicJ5xM35&;kdj6*bBe6ZSr1j zwj71!du4dy|GM=P_R1M}>jA6p76@fnW&ghI%U!x#@9SKdg8ydrD#(@r{jytS$WqgYaS7eOq1RBWl`hFMe-~5gO#hatC24NWak_*#Jkuo1Hl=X! zRJy^7;FP-mgi4>BP?(%g8~wN0-PJw~QbkHcy~^&`4||)NVtZWz(n$|CYjeqPRosSa zZ4piy<_Vja!aj9rThT4Xas}juGRsLLP<4kwMdAig!gs|^ zFTL!XQqkw?rxeX3PRWMxJcMoa2)ohD`IdCPDP%Wg5aEZk5AAj8%5x98nBxfqu%rPJ z-0=u_eP8tlZCA+swtZnS|5lmPg8oz85&{Qg>VHMDz~mK-gKHNQO^WeE$wrDOHSgh< z6t|G$i$|Jb6)%AAo}z-awNT+cK{kQ{I=$OBU`qsczq0Z%!vkvETU58Ib+NAp{0nZz?u9yzqjJ*N7x%MrFqx;kZ72_0eGK$?E2l!RMQ+XqXST*6+$3^U()4 z>TTAaJbA$D)!*hOw?4+{Ziul(!t^e(k(TC;?2h39F}(Pe1rIMOu{Kr0igJ`#mepIXdDih0d6(IQsh}RSdnyVc4?8^wqJ>MJgT1$j8mjH9t)v z-C)k26qW4sh-PMp_Kham8JxmbP3nY^0gF#$MV6{%TOneAFc(KT?lkS4QD4>=zd$Pl6L&E^vvvs-(8&S2efl z2EQsLF1^DvsXyfj<3FLarI$$tm0dJMuJj7A7b#O-0iEGhFAgl)G;jpBUYBWDg)52h z2RHqxB3in9Tisg!UfLsc6%C(!KpIW-tC)bwh{bw}rEUqP_#|Fa2B}QkOI#t4WSZ) z-Z1u>4I-AZ0M`>3z+1%z*%t{lITk1e8-VOwbSD#rkQK*`{CS}v&Lh3(h+#PSiw%Ify8!#Cx!ingZlWac~)9z7)zEKPLr9Mc~3NkTL`fTCsa%g`Q5axO_DvxQux;BQU&~&{hRZ zOtsSVEi_69?y#$M)g zPZvuG9QTRkSZ)yXMHKL#wmU3;c455XfC_cNweBt4%1V z!Ilox&77(NSFefx5>-`2m!-Sx)w_B?9vlZbnqvuwEq?xIviWf=7EVKks z7ml(%DiE2U{c;A{!*p(PVFVd}^#Uqkd#vB8Zc|W=bYHs&OSiBf7$3EVu(TP4WXZI? zr@f7>DU>m=Q`=04gT6;PQOs~i)OwsH z9mUvG!ya<*W^rh<%x4-EBG1lr@^Yk|Pg~EvO4np4k_kbosGe^KlLu8%UrE$<*8oi| zLfa7N9Z5IfdO$@rGRTC+FqecYqXG!FdIS!54(#*s=wOeAtAu+^J^g>Hrff?homoXl zivX`JSg=KjIkjb?>`CO4yylCtg7H1%DjuSgg-GZHq9|}aiOEYgS0R6zo$l;#!d2l8 zWGwbkqC{<07A0YF6VU+%=e*~To4g}EVJU_ordiUSD_;1*ZE{NESE-9|;rlB;U&QcB zCieGr`ptw|T3SclDC{tJd4w$46^pEY8x}S2c_6Wq{@=b*qsjePl4SIw(_^Y?6Xoqo zsrN)>8LbV=t3mu8yZWND4Tf(gHB&dT)$tO{^VO|{#P2#anzG6ZzRx)VA>X<6;Z4|# z>&}K^5W-OoMR-6AGnghRUrMBqQzZZrVX<6N9$Hr^eLfT=ZUH#v_6Jw6g2L;_6B6s^ zCic^!4_8hmD~fQjj+{Zgf8%{{-iM4*r@kD{f_ETY2ukVq|72wH{x(6h5oJa5Awu)VC=q#|;aBAHzcZDeedYwE^!Ig&v)vuvecn!Fpr` zT~+sl*lZ^SVx`WsTrqXBaY|K36jGV1*gjKXqFQMxWok?@AP>~s*kI*zJN=XIln=pd z9;cWLDnRT=XGapD9wO!HWWY&z6=SNsOL4RcVRl|koca#ck!HR_Sr@u$i5Uh+6`Fvw z9!;?S7*H8hWV-5ifwTsnN?C!5xeG0$pb9n}afSM!u*XQQNiRzrEjO?FE#y^^nELLz z{%gYbz!5cBpL#{_^|TMCG&-s<)TI5;?OINQ>1P8J3e3jiM_al+ElKjNb#yYfceSky zz_gH*IY*o(L7cGF6>rh@Wk_I-$En2hKdx!I%)XyTz@=8$yNk^6<3|(FEa{g6V*+O#(+;@NEa} zG@O1E=gRXstM>*#zRs-H~*p`?LC+_sYLh9?vY z`K#B(<0&U%SDsCM<&0Uw%XN6=is;Gs=oGe*`ql<4vl1*AMB0>@1Z~@lI@qWhv^i3{ zjB7J!HH}s#3Qld>$bKv_4Uj6X5qvPe>-ENt9ikZ0`pu#+_e7{-o_?`EedIf03V*&CXE@Hj^6$zV62CITaxk0I@UW zEL!<_L{EhQTGqtSOAuxzXbuhMmXuGFT^(UO-56oq43Ec37}vG0Ff&H?v}KI8UZkCK z2s?|D&+jJ6UWiNlS)z1tawAK&XLlCLs3b(bAw_ej=Q z(UNsM4CZ$C)d)e{yHw&cx*>iuK)Np%CNprCgTU>s4)4HfqPfisuL-UW^*2-zV#{~T zn;ruaXt9dn52voEDC0*QSLYYVa0RD+l%1>ygoypPy$^9t)Is53D8 zsxH6a3};wtIc{PATlD5=CG{ji!toV(-NoPq(^sGpP*z`uzyc$mZM$cv8@*_vJbPV` zDIs;FnM$S7*wLK`9mUk_?iNiK^z_}*_2jX~32E+(K=?6X|C$==4hXIaX*%Gp%jj&) zpFY1+#LklMc{%Mg{Qz<7^1o#2WKnVEk@z=%yy6P*2gIlbaWQX9wfjR zM0iSF4B*EfhHt(Y2eca}g{?B9WWSQv$AmQ`_-7Hz#ET@jnRs7aETpv-maL?G)?Blt z^Tf<+_yL{sOVkKrP^3KB399WuDCOBHC0QVNlUs#>nCxDlV5$~mfh_cGATLCq7 z7Sgn~-B2PIoB~_$A-Y8(fXNIK#z24IY}~*@r8K0{0vqFCL2j{_X(!8ggE_>|%z9?b z!}C`eAFd~2cfH1ot^yIoLWmyZv}Ms^rwB2*1T|_kIz2b`Du;9j&lFu$0Tyl{u7^#W zNTD#QGSuwiYMT}3hTvcoV$pec29mKvqh**SHe(gA{cs%)eY%K5u84HBY!9^YX?mCH#I z8T>wIGPo=dKz?9j>zbRd&*-3>XJ$T9NlA=TW<#XDCU9Y6B9>3T;Cc1Bj&_VR)6$ZC zQ7!rZ??8s`$%{%%sCe~7J+j|#79`6%UrT! zv1JH!lj<3S^?Z{MM#92y1}Q&~A(T+Z*D%ltCNkw!HZdzx%D&sC+xESr(rl)3DS{`{ zX`iW#z_Buez?Vmd-Vv}Ghj&@&M`da`&HwM+(FDm{+OiJ(P9ZPs88R!TOp>6rr2%ovgi zGNXt4u2w-Gk~eUM!Lh5}vfdZp$%3dxY9W7GZ1sGAuNr`IhsWGujPXB;Ge{}NbEbEW z(YzoqKKls#JtFHM`4`euXc&*YO^vW85B>!?K%tZa)D4)Mm@U>Nk{%F(kw84mk&8?S z%oKCvHLcv4L=?|4`i$WN*sB%kdJw)$0a_Kpu~n=U96ZGLhd0>};NWD)W&nqB4Wk*v z%o#BTw7n6HSn>^DY@6la3d(NjPYj({@G=osQnuZmoE?mIV^9P#i*q{3*;Z@SYgyUB z7;n@NM6lJl4^4wedUbX63n(B}OF+_u$;Q!*Ot|l?B4HNx4@EVfym}P*RLPUg(Q_&- z6HS4hUX}30?}=nUk$5Io#VdKGXBOql0ZLYYH=?6BhmdnB!D!+(Y1WTVc|#^yFmFn{ zqxv&J+SIjA*~#FyI%mn7X9O5ECFrmVudUSYZQ1bJ!=c!%%I=2~meo@C=1IRzII=RO$jqf4=`L~mG# zo(my5djxKZz$dSxGZj>RYNHv?0l&@u2fnMn*a*-cb(ZaOqVHcx!b!t?D)^;HkNBtv zaO7;NH`BHqxpbZok4@#!Oe>wmA~dprkn{l6*!OXb+u2PIX>}>ivl-6`x~NFI#IA@g z;ahID-Jj|n9%p00wc0p>PD%stveJk<)nM*O@QRQH^$xY*J86Y&eHt!RO~vh+Lu4nq z2J-zA*hCjyB1|E;NCAopf-~&CV2=}Z7nGp5Y11IAiI7Gpt9ldUD>@43U38RYu0ZeJ z;aC9@vwDH#0rUV&2WL*dI8Lgf`(ukUo$L1$Q;c2L-1=0f3yZ;m(~Jt7yl16;A(@{%_&Rw6}l zF3Jc%%~(jf{K1LQ99{3wJ?5qm6gd<+A8`4Cn1_ty5zBPR+}VZmj-dxA zC%?qpxXbuSmP->Ym_rdq#>6>X#|IlP%Qy;#g^Ve=F6uodX|h*w(a5!u_XNpL5_L$* zdA2;xf4g8P6euKV=iPf2j+0wPBo2WfsxTSXMz#?@x^H{zBbBmhc5~I>GvBUVrmcO% zrGx3WafO6rOV41)9>2(PbS}x~WJ{nQc)iF;{9_ z7n8kfQ{(I-<-A5&ODF)|3nfn7yZ_+9;O_c^2UgDT{Tsjj@S>F)(_K@RWT7RX1TC!d zSSl4&8ZU;k{Gl7Kx3D>6gRl18cDQf+;YTaq}+ssPB=Odyyy z`t7Q%jTX>npmZ3DmCko$XE3@_))pau!cMMZBn>6co{h=#XAvy3-V?07mS>;#<%bkn z_d{SFF=>-opQCBNRWDV)IivRb`}qC6cy8BLf_K%m|u zP!!!BEnNqrk$Hv{$F5FA-7~#W7r8F(BC>*u{6uuc{>!B8zfczY@2mJPM96-dT)mj$ z2)O7wOi>cJzJLtTH{H@Bozc%8D>1u|n*G_(7=ztk&b@5yoB~n8>33~}&9ESd?WY}U zceb!y(A{YF{la!Zf@9oQ3){Wo$lkn4X1b00VqrTK=B?V9DWXZw9h<$Kaj5C5&RsGNQv_Yv8|A~RRZ12!OCd$xJ^Q4bgAn*# zz86ox_Vo@ijADpbxh$W11tW{Df^c`(K4&ZnS=yY;Qy4<37-dLl*FYj#^ok0aC{o>T#KTVTV2S zHrIUv7tWV3?0f}B1=Ui=g>MZ9)vi}f|BePaBVCCgQ^_BaJ*J?2_m%#Dl5~|@Cvwu2 zvv9gcftf{u#^&gdvN&d?@A~2asEurig$1LN`4R9qJbbw;YU`shvV>TOt z7+o3F7nqrhf)|+!iOe%Bs`^{_Z~7refajW&r%r40EdX!xD`4;QRpNf{YC5u;ZX85HOdQX|Rg)=P4tVCN|KFrT94_>1pVomj z5mvEaX5!>}8g;^QqR$@{aTu8=<^WVYi{-fQQR%<$W`_lK%8TVfrf3s(ytF{dOe<6r zKNM=%JS~!B!3R}?@-qSW;9G2SUCxsRyi!qgfaxo7HPt>z2}qI?Sk0FJF@;m^W7g zS5+YwSRpeGm^qSiB2eJT^!B)2q7;S-$m-h8zQ}tRz0H`(nyn47E-N>SrT)-GP_u?$ zow-IKGnt(g??TQ{C$!XmK##(}$HV!-+1qSe>!IFUonLk(GSmgPynb8ooI=eK3@<~U zU9NIVC0@qulU$Zb(raqP0k_m$s!3Hd)em@+q;)DsHHSdo-Dd8n%=i`C`8OA`!09muhQ{z+@crVh! zlRph|VwU%sW_*dMUubr(vwsfGG(95$m0P30G!|G96cA6Yk2!V`m#_t~v^JtFy^3I@ zOmC5s7GN3}SFEPHAB?vbwvsWk7{Q24;o&M?6$AxFgAc6fqyMk}#sB{QBp?0KyAAi} z{O5&(e(&3xXVX87&L$s>W(^1ZKm4nI>mU60zj^B|{d0AhgWm7Ze53Ap_X=0+NhJUE z{L4C{iakTlhct30Yq(VqxJVE?+DE~Yu8ho&jZub^A)bFJ6Be@9p%jg(oSHhYYN~-} z4{)?C@OjVN(Uds#Yin@C4 z)%N!3c<@WUBt%Fz(9HyR`+j@-^9PUbtUef!S2DP>x^@5V_I3)JmQ=$%vfK5+gMEq9 zi|Si1Eb&nC>ClSWYQb0;y~5P?_FcPB?H&&_0Y{uutBYx%5!|A#j0meP$TIPGbVP9S zh+vEqw_Z)2XOx+=d^P}Rit8~^ofuZxAZ0kcbL-}9eu?+1`$66oP68y8;*Yh*VU{+t zm%fS3TP!Y^P%$FBl>T&ooA%#7vQoYhBkt?CJRBaIoRzTD4Dk9`bB5j8)qAQeQG5)V z*~E!C^GQ0UFj8&KCg7%S^i7c92J*UUkJwr7PQwQ zt1I3(ZQzovuI|2TeDgzB*^PoY|Iyij7WVY_u*%)QjtFgbpYMx)Rpi-9!T~Jyk$3$v9<9cr zs1ren!^dB0XOj{L5ex#PPn}~J;p+J<;awwrMQhCfmTp$pYHHwZqNkh{(NT3L8WJ3H zU`MFzQyn39z>P@Zh{pCijeLztT6l$YxxD79A)oO?RL;L+;_*j$>!nAwYBn)z|1?P- zirn$NxUdo1n=dep_{-8PqF8h8BtzzY&Q_>IXlZ*%ZzbRMxnjuR@0~R22*va(d2lYS z>e9uiVzL@l;7@b$dufDJo^>OSZ+h2De>j4rU;fD=YfZhCuQ4-LA(>AkI7^9QOIF*s z5VX@>bq=c=WK$X-t|$bRnViu+gqE5|F81Ev65bP=l|Ulw1rCm=*Zsm5Z~OL@hQQmG zb{Ukl=co#zavz>=KU5(7DU#B4O3G6Vzf|l5oZ5nsU*kL1HO1@At_x}QyBq!3=A~E| zW&@0j((K1A2~5t6GvurN0o2Rii)^^@E})rN|QMm8yrx z822JLs5;sbnaMgmW{-^Z?w&`7B&gfd#0&UAOdtRLMw^gGje)X^diQTLZS~0|2seP; zUTd{Xjx<0fGHt2RTAeL3R@gVOzD$oadQB}AQZ^IhFX|Ka$^V6fh}on)yCeyz7>^OE z+CGE@ETLB#!AX|W|3#!KxI@Of6fNn`vqjUiZD((RF+4Kz#;85 zOb(o2WwPN=O|!+GNVZ(5uR}lR3fiRfZn6TbL$VyQiNn$_6=q8j9zw=EZJN+`E2U&? z29qi7p2?B*go6CC)7x%QHp23b`mf32`1HwB-pmvBDQfSLbymB_IwR@X%`#T|$d)m-oWKKhVTX?g&Xs z$+b4GCg|rxnPMvT!TG{ypz!Or$naY7k9VkiwYb_QcX;X8=!QaOy=~>z~b$uOJKcW}(@b5vedJ zF=(S*shZhzTL~7ZkhC*Vduge5b)m1&7cfhzq|dr!3VA0}dnK^VQAHahn?;uJ%@#6u zK`N&sE+S_@kUr%T`r|1}HZYWHffesE6fV}{ZlTL*PVwo_WqolJy9}ADnL;W-;m^-# zQyoYx?~hIyMMjda^z&Pc{)~y1P0h>z7W%{m#Dz&>9cWb-l7lwbn03XDRl>2{5g9$q|reHH`+M zmzr#}2n!!*3{@$EzngPax(eo3*VspIz;^aLO&zcJZBD*Kkg8}}knQo=2Z@OM(wJHEOJUCg!Eix)y=pe!!^=i>Pi3!YdfBSTkM1GvH^u#)oamZVy=E81_X4dY z9(CijE4bfYq>_9`IPzA9bMWSRym3w*PMh+2Z%_ zO6aN&JAXngXTf5=1gBPu3bOL#cN^;i?Eil7uGr|Gb*g6S;fgi7_u%nrY6i8cn#fm; zx-%hiJcBoSzX-GzvLEPVf{%@sk1?_dcre;Wz^TEhvJA*;q@dekLbk2M-U_Ntm5P_u zDz~0&-hcFYKto5{TG@rI2b3@4+#y2_VBriu_7a-t`Oie-lwICoi4LT@Z zP#dDjFIau`Y7o!}`j@#W}D$Plac{08j| zijRrzW_;FP5>0uCuqx9^A-vJ?Ghp{UzFXD!ef`7KbyIobWECSa>sGnAK125m(!q zS&_Y8U* z>zs3353kDZIrn&{I_dLcmaFm+8&8@`t(_Y&@SWO|Vuj{#Fk{rPUmPd@rg*U6liCsU zk7|z=d{&-3|Ehd4|B-QOvZ_>E89x1Nm?#fHBmE{F`NmDyJ4O4Qs{Kw|d&{P>_J|&x zp?|4qAPI@f`xD!_n)*%pD@GN&1X*49km@1^R~M-de`NVAsSiWIM*sR5P3`N1NODSM ztnyvUtbs7_2x1Im4dZ(xN|Jg%Gl=Mr8l zLXwXPn||GvrX?rqEdofOgQUsG27%4L$?tDO3s)0ZE9Jl%OUoBi#p z<3{)Y+yC&+zno0odQ1OYTh={n$Wlq)Y}NapC$0Wr%(bedPra~Y7q)9tEtgu?=6a?K z#W!Z7^wsS(8L%;_V=6yU>h$#vH;C<_rJWv)As>f)#|@U_eY^35STR*#;Mog|Oe+T< z+yNh}h?muPQ(wx{Ch0m7+c_OJzB0Mky-P1M(A$G?7fstOU; z6~*Nhz@DlOGv#{NjHCSh>d~0(L4nA9MD5J5b~|;$Ok-6CG$Uq7G0nZvF?U>Wk2Zw? zj)$^-z5TZDNP@*zl5va`om1=}iY?5pGZDuV6OXvla}T?4z0(bcjwt7)drgqFZlm9U z>Ge+@5AHr#-P%&O2>U9jdbCGMI}%!VEB;Q)y1gd(jv_j$sMCGC`LOYV+=Xwe;ym5E z_VYfKcTzk1+_A#?H~c7G;sxQ?Yd3yS=Z1IMXgKpzeCJ^ z0GjSMh=AfARYlWu7nydyxc9(YF;M&EW?7QF&@}`qEV!T}xPV!dl=2$INvgT=Qtl&4 z0H_))5=H8S7u65u@(%LgoBJQ#CBQ|{(wiQ@_0c#&X{-x}MyjM%5=($Ysi-$gd?nMt zg65{Pnz9fG9m0pBpOs!)@MY0L$dyWpQHynIw`{al>d0^T#Kf;=fG-DoOk^4tPo<-- z1pye{FOh19Ns42huuoW;B;S-+QZTw4w@^R87RZrcVrHlKrfJNP%vn5ZH(p%nCht$0 zt|hx$WN*I%eH&9%)^kapr9oK+|L^~St6F=FxgqgU(&8$j_mvD6ltC^%DQ>G1yF)G*vB@68ysDu- z(@rVkSr?Vz=Cfo_&9qn{kP}r!qZd)-669j@tz3W%U%oOmox9bqQ|wZ+tXm^yyHf-( z6vO4S4h|dIq^;6m-9(6)G6+{-ebZ&S1g0e>wE*=>cdJI@D_?0bT!+h{W~#k;y>kj$ z`5)M`ayd=~mUm!@{>@b@y$jq-QZM>Z1TQp$x_ujLgyHCrx>r^`K=(L!gm16-0(32Y z>7X|tVO!o|%HS01QB$Fv_PTr_us@B)Yy8@{=|_4w)L-F1AqPX_lM zKYg^uWVc+P(m!EU7-?MJHVthdbRgW*i_o zQ~$&0(rom$93#b=K~JXu^NEj{j^ow7ua2FKWimCeS*)I0n(S+niP&?qMD8dVf;UPw zSEIw1Y9`zO5NqRUkQkjPHG>0F=yFC0rNMBA>W`<~s-njg*{M1J6CQtQT6ygmspW&! z2b=4wYrh+;|MvdYlda28qGkE%&PL-lDr&{IWuT|YQkpicw4}3^SQ4w}Xrx`a|G2+@ zikt@pRtv#ic`{-~g)jmLMH*y4P#3oTH?=nPnhBIhsi-WYU?@q+^**08e7PzvveS0@ zy;k=XTJc!eiMu)8&PEXPN~yw2G?|u zTWwanypn8`iK@Dl)9jQq1EMycWVf_00)?Ze;+KD$SQ$4l(?beXy6lZ{xfGgCaEe?y zI#E=tZmG7sVy&1d9-R^@bmoi<`Npr!`w_kWXO%pq`gb64WgIMq3vBxk1PF5Te}0BwfjEMNf-zB8nzKp@!7^PV^2sS2W0K zPHt(0sgPXD#+p))#!$qe7+Y4KXik_0FiE=Qh^OWa)_*>qS`c>9={_8z?33yd(NTLY z%j@)L<<8)2u?aH#~`?XAZU9a-LFFmx6lLXKy7o*yf2572Qx-)%3;=1ziGk`i@ z+m#HHSMY(e{8%b^x?NpFC^ffo)|_oDBf4yG+fV$qLi)C9l2O!%(|3{4ib0l{nUenK z{sZ6t+^q{1!qu?Q%RsBQf?}dPxwY5S7U7Pq?ki?XLSSm5cURJnkh~e)t;+t!rX^T0 zik|?{J#Z*ZQ9L|d#mYF^+eVKUXB``xk2lsgpD2{=$>8xLw^104lz8VxrZdBZ8DTYJ z3N#RdHXCwBjWe=np(&;P-kpr#3SQbdME$SKPSf`{)p<7z=%u++PcmT&9FmR=5Y8F3 z#^|>{dAVeH1==>my>a@l+5Qdvpm6m`=QYB$f%65FJq06xC9RJZ_93#Y_#Zp7eDH{Dbf3@Iw963tt_j{ z&0ftsg5O8i(WN`&yz=g{eltW``)%GRcRsVva~E=V6A&Z?1d2K1woTya!#ZYFUU|o< zyz1ltaiUwjz`p=vu=emUJeCo(Q zxq#i{oVCENPpneCp89sCTDR`=ahX99b>K&QYR{9Yp~3}epb4?WKel#2CrsrPeGvEM;WL%=v`C?EV|2&%wtjf(xzy{pWRHLgZ1+; zSF1P*n2oz${JxdxL^oZxTEq29#&B%7IGt0#hjv-_xiQd!@8U!%M8vUQ9hs-B#SJav zyxx&1rDhsV!@>YK9Qnh7k_E!1S24tEd%n(%FBrf+IoQL!v!3v^upVqZy%YR%@buB@ zSF86QtloLB?uhFyxO=#hw$2FXI5Z}lF9ZduwP+aku`FQ{#t3w~j(oGC9`e%ZJjo8} z-aI>{21AFPUB+Y0izOBhTe^@#a6lgRQI^tXy3ff+#hp{_Jc^ZVR!m$Qod`*G4t6s# zau}l_+nY{uJSneG^?MQ;6JWt9Vyyz_+E}-C;rgx3D&eEguKG1OO*h9rt6W~+n9r-a z9!r2NAmz(>+xX^Ke3SPX;!ch{jZIslT`>4-YibS6KQ;!gLT3qLxoJ&!5;R+dFl#Qr z&-JW_J?mLBAcZ7>TP>^4SW=GR=_+gLE_cDs*6xTyqvH^}9yB?B!X7;gGNNOcCSHsn z(FEVk)&y@!9+g>)iRYOS1{=4;h-vA{+41x7F4sddtZpji(*OlGyaP5{9Pn+#Mj7{3 zbd(KNi4WC;3Uq9W8w^nO4ecnJniQ#xxBwiA&swGdR%P+hBh@H@ln_CPt`&p&c_t=1 zNW8D%)9iEyj43gyc?1(ec}w=ZhVsAy?B zc`3{&_*gKN{H%dBLBI+E-wZFiv}-jFm06+1VO;z!)!YHo&ws}{a#xEy>bZe) zVSS(PQqAw;GF8zK@J@HNiWarBt6m1nogFNc9P~;MSKtQs^NloPjR4K}AQX|s1zT2Y zleSGCNAT?U`QXr<=&cc_bFC^~Yg4c@jkLF5biLM~icPeD?VXZF!T>CBj5V)qf!$ROol0|-5 zv4G9ixWZc3PPplN4l$guyhG%|dqm8Ruu2eZ6@VPd6Kj;)@9k3o|LN#C1uQ=0l6I95 zg0;PvoF43npRp0VX#$kbDb}prnH-KAz|>HPZ!n|yetdMmWqP>_W0Ps&O^Rfnl!9nW zRHV1*CIGqUfevNy$yA|M0?UZ|e`Aue;N-yrAr{PB49toR)F&(-QVsAn1MTagWfaS5 zSwImfX<}2F``kDhLdYoloX0VwMeA@7ZK@ZFrL8DWV4M2w#bG zA$vMRzG31*sLRB&tXh6qKHeTwnxa~)L$iqH*o}4{P~tnmxqMOPMt_zQ^n8TAvTMOi^${=O^r@sMzv;v5300HcBN|S*q=`xoG%m0$QP}@R zbFy1JHHxUmc!a#UI-L&Bp1FltcX~cU{zy(vb1TWZRvQNLpL(E>h^MFwoEmZ6rp#7^ z8BKJ}uhcYW2-i}?sCx1^2@kAHlYf?9h`;H4d5`M96yoAU<(IULr6g;QFU2^p50qeK zDH$h|1*bgTmMX0tS?FzqD{Iw7vV(Rh-h_M;eYEIe_J*}6W>V)%mzN9a;%76#GCC5` zQ5wt1glR>%zChE~TMLj{ngK2>gLqi6_)T_pNOZS^8rcFv#!C$Dx`Wu0u(^j|U-dQ6 zAyB2P6A2|zKod%V2O@n)UO1&C4=m}~Z?hkEW*FXx4A+EAW3wK}q(7NX&M4e!j_2y~ zcbVjf*+P&OUqz!kde0OzsL}{mxt{6DH|xaQSxij2wHP6n&FNsXAZ6_zGO9-iTA%WA zY8g~gv}?{k{P+W@a@yB=+KTq(9t}t&DpPvE*P(r^J}f&4G2ZGo+9t))^i5@zKiR=nyYcwQgpmH&fzQu)L|d^vrH|s1|sO-h+WlJPB+NU-sNg4v$vEQLC1n(61ngZ78^8*wKJ#Ox&#;W zlCzdxF(ZAWlEIe0@`iLv3Gm8MOvKqS!%PrKln{MHYZX2J@+`%Jg{q|X*30$#oT6Vm!(^dy^VE9HtI$e{9pLkr3Y=Qhka z%keuD?Kz%$+TFtt3pNnQRFcSY%n*e2%OF8r&NL2GnI#ur&z0>cR%f0zoq*!m0vj9! zL|-0JT|1!JT6?25BW-|}-)K(XqugrsH_~w|Wca)Uv%9q_>tb;+ro*!VG?0j?2};Er%U#Ux<0IP#byDD5c)_Ei;)Nu~1<2Ugo(0ZOBV!u~$I3W? z^T`8(V`K8%GsLN+AUOgCD-I3w&9m+FT%y)HJv7^{u=kzLeeehd3$F4nmw;pD_}Yr@ zP+?vmU#5P z0aKfDV*>NmoX2w7+{#b_)s&cLhtz)uQ*g6pi93=| zgf{6Elf}*KehgitHbeJkqG=8z&bR(nFP;(Hr%}67`-?Lv9BFIq8%I8p+NUJjwuSW(=Lj8&|@-)UM2Gc{(Mk=o=8SGT~pm6M4Jptk8= z7YNE}GN2xH?yTQ?yt!__J!Sd20B&WJgrasii5b>+Unp6;p*W*~PAE|^HNn$dai-+^ z@kn0DcTnA44bgpfB&{A#rc@P@5aU4IOckS~gmd&Dr<0LrW?rTAi` ztwklyAyKcd5o`KjPGZQC&ROfZ&Y>1lS#gp@M+8Kt6|i{g(|=voR#tTT+Qu^9(xlRA zGFtYW9fAHbxRoY z#b&PVvX+)?4}PTL99~*S*Ga6a=uHj1>_3xHwmWDs9V}v+39*ya+$$8kz6(KQBlk5I zi<9ViZV|^yEVd;UN{^$(g@dNTxC4VNSbQ3<)hDo=F+&Nprozdva8*T{n6+%-)U_$= zpcIX&7^BJ}MJWjpGbf%08p?LK<<-_pEbZi)qZ|a$f6l#HRNdhk@CKOIu42 zot88Umyoi6W~&2A3(6=7(ujWgj|*{vgJ}cv0wEh#iz=W+Wf4eI=WQmJTz>m>omjSsSfm!IU0%qsD7C1pIt&cC8) zF4c-i))YAl?BFTQSw_2v^I@AzhcID7IHZES1dPAA|E0p@&b)07DsWI!^{QGSN%_0x z`E#EjWm^0-jD$#SPi^r4$uiE}_--R2uV8h?B`dog%5oT_AT9NK#r zLP2-Vs@X*o=24GNg^u?o*HzFpF`c6Gpu2V_ULQ>%hqc7A82>!(nR;t*&^k18st=yS zHlXHY-$h`3qd-(-o%r68`W5HH*q4+!`o%$QqayilSrXpEAQBW5#D-JCsgZX626p5P zpO<$!UyKe^$nA|EMBK_;DAnkkV|V~eZ5xqxFN-y;efbQ-)+A~yK>RgRv?YfqfX z7=?*qXuN5CPQXQMQfHT5O^Y42(|s2^auUlj@f56m#`5!M^9jHl%ub&_AActt2&rFa zVD(X2>X{3`3G9@UkKFQ^2X7X=RraABL<^9FkF?6F$&#(oAt{ummIbN>YxLXIm`BF~ zq}`Ayp|rQpSlrfSey(cYFoa}dbIamXFAM-F{ucPukCxLKanBr!Gbk%8R{0Xe?&b`L z3VY|yU$O~ge1SB%Sg5%Kz9joi18?;0y#0o!8Qu-y!L3=Y4=mY$0{E?=--(dgmey+e zYR-XM;JuKlF@sVc0VmWbM3>BU^{g51eg2^?;<1a^bR;9tVJ;S1co9>Lo`1;ckVxFj zAlb}ZmXC{$xjhE)Q<|6+DUduh?D(~^NuTi-A)D?~pZCbMEM3H~8TTarkQyUy_xt_s zk6Jp0J~oa&_RPTN_{KyQWU;u`>$xn6Zq5^UAZ)W$mSMIT#~GGpVsIYKUfhpJt=nt9 z3;yQr*5`1Ytpm zt524|KdSrJ? zDED3hvDbU5uIU5YoYtGtt+ZqFQ!8CqgsemY7e-95D3(--A{X4Fi!G=)l3Hr4!lzK) z9@?qKuTk#vm%~9)<^AS*O6*vMH;Y#r9cNm9iEV6Ob#z!;b~(*kYuA}+$+woSzLuTb zot;`C>4AHCY-7=062d;L9q>!#Sv1y?wooO<^8MjtxYP!yE#jHTPXw40h$$9pac2@j z#(UqTq8n=6Cy$o6z#ajE6s^fC6Eerj;sZ%a#x=3gr#nj*AZBsOTBHfNl&qsp3}-|$ z2~e7o29K$Vr)j*HM0#^L)(ZnUx?3G0@v%DhJYqFH9QTQzbkloUrRClur?bv@o!EQF zNX!j~0Yi8Gx^qLMT7UkAc)Z+oiPFWYvje{*I4yn@cGw}#8g<|EyQD9oAWY!Y!=rry zwKQi*ae5Qe4l#?Vz>e}n@)pOI)X8Ifn$K5eAJ^Zta))#VFIcdfK%rP#%ZAb*haqVD z>OlyJjB*K%@g_6ch>ZC3smMNfBmqHRzAb-{c(S%Uqmxau+eA%-UuXZ}c5ANLm95+#wH)<=W8H|g{dfD`3;z6Z737@qV z%kWQCs#3|OP;kNwok}Z%wuO$MkEJalu$luY0miCFd+`MjSx^6N6>c_4ap|In0qSDo zd{=FIpfW-GQa$B2UAI}ASU2#ck)Vury-ys*Fg1Y52tZViP>Q3QD|8^B@n)3`G&2i} zsK*d9;~4Gbu%D&iglbSboeSA=Q%%c|*HcBye%-3zqv58o4h=q0loLVNjAk$#f*EUK zF(ez(2oPhgg}pQumge)e*+Gh0T!!3@L8}5>bU9aS%TyZ8078_An0Usbl31D2Cx@!ANGfhs{wz%{$G9h-~AhJy`_IHb~T`TfOO~E-<(V)2XAv>w+sSo@AJDV21UxM zw)O7m6n%EDvV(@_D2H6-i}TipSmfeuqMJV`XhvE&cH!BN{mvIwpDrb@SitZhm@}Q6 z?qkh+K^ELQ@Zc$d!_|Nm1ZUb_XJ&E3XNILRdv|^%>wHSbklo|?bOia$7rJer{;of< zTikFBbuF&}t;gI%5zk|0K)Ghn(KX9>9gT_B-~ktOyYt2-dSnu7=SKh52mW+;u!H#> z(coY*)#qLP`$qrc4^Tq)t3P-A&yRUJeqQrzt;3LC#}w>bfbUUrc_(}jkI`!9sn7LY@AZ7*47g+ zs6*yPdOCVOjq6%RM{)xx1G93H5hb2pQ^eG`yN{PMribdb> z7Yz=v3h-g~^vEdQ-ZE^s3d%nzyf0(r|+>$WM;RtKp0qX$3c)n-8oI!r^aU}1Z9|Ujq}E3<;{K?pM;pb83OZ= zTYpB!E4!0pauhptHc$jDi)2f15WDBD7R+TMw9U!X_<&??xU=2|TS2Zk_2}e&=Wz;z z?m+)Khn!@3fJy7dRnhUf&@mTDot9WcRF^@;SLZwxvVLbEHWx2JApqprDH6^}RMXvA zmBko$OX`6DFbfB>2#fySDdcAtKHG}`Ka=fk8IA9)PYd}-ydXl!(Qpo^`C7$?OPbq7 zj6msgCJdJ+?ht?`{b}X_Eyf4Us$iWz^9XGDhH}I7%tb3*q>b8;-)ix6tH-iaI$zZh zC~A`Wo;;&Xy<ge><+34o< z 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%0A_|fdw0Gd^Ley4n4VlqfX9soUI5mH@4lv+5wiJ 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


qD&IEqj+4t74J)$k~KN$ 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Š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%0ApjR9`vude~7c8uyzeQBSv~cB%RJDJLM>(tiG+ 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 void: + _unsupported_engine = not _supports_godot_version(Engine.get_version_info()) + if _unsupported_engine: + return + _lifecycle = ServerLifecycleManager.new() func _enter_tree() -> void: - _build_dock() - var prior := _bridge_status() - if not prior.is_empty(): - _present_error(str(prior.get("error", "The previous migration attempt did not finish."))) + if _unsupported_engine: + push_error(UNSUPPORTED_GODOT_MESSAGE + _pending_swap_hint()) return - if not _editor_available(): - ## Headless imports and exports must never mutate the tree, not even - ## to restore final v3; the next interactive start handles it. - _present_state(EDITOR_REQUIRED, true, false) + _startup_trace_begin() + + ## Keep main-thread result polling off until the vision worker owner exists. + ## Unsupported/headless/barrier-blocked startup therefore has no process + ## callback and constructs no worker-capable object. + set_process(false) + + _loaded_plugin_version = get_plugin_version() + ## An installed tree that was just swapped in by the updater (or by the + ## v3 migration capsule) proves itself before anything else is built: the + ## live tree must hash to exactly what the signed manifest promised. A + ## mismatch restores the retained backup; a missing backup keeps the plugin + ## inactive with the exact paths. Dev checkouts never self-update. + if not ClientConfigurator.is_dev_checkout(): + var was_swapped := str(UpdateInstaller.read_pending().get("status", "")) == "swapped" + var outcome: Dictionary = UpdateInstaller.verify_after_restart(LIVE_ADDON_ROOT) + if was_swapped and not outcome.is_empty(): + _warn_if_verified_elsewhere(outcome) + if not outcome.is_empty(): + _post_update_outcome = outcome.duplicate(true) + _post_update_outcome["outcome"] = str(outcome.get("status", "")) + var status := str(outcome.get("status", "")) + if status == "repair_required": + _block_update_startup(str(outcome.get("error", ""))) + return + if status == "rolled_back" and was_swapped: + ## The backup was just restored on disk while this process still + ## runs the rejected tree. Restart into the restored tree; the next + ## start presents the failure from the marker. + push_error("MCP | update to %s failed and the previous version was restored: %s" % [ + str(outcome.get("to_version", "")), str(outcome.get("error", "")) + ]) + UpdateInstaller.persist_next_start_enabled(PLUGIN_CFG) + UpdateInstaller.request_restart.call_deferred() + return + + _continue_enter_tree_after_update_barrier() + + +## An update swapped in under one Godot and restarted into another (seen on +## macOS, where the relaunch goes through LaunchServices by bundle). Say which +## editor is waiting to verify it instead of refusing blindly. +static func _pending_swap_hint() -> String: + var pending := UpdateInstaller.read_pending() + if str(pending.get("status", "")) != "swapped": + return "" + return ( + " An update to %s was swapped in under Godot %s and is waiting to be verified;" + + " reopen the project in that Godot." + ) % [str(pending.get("to_version", "")), str(pending.get("godot_version", "unknown"))] + + +static func _warn_if_verified_elsewhere(outcome: Dictionary) -> void: + var swapped_in := str(outcome.get("godot_version", "")) + var running := str(Engine.get_version_info().get("string", "")) + if swapped_in.is_empty() or swapped_in == running: return - if not _meets_floor(): - print(FLOOR_REFUSAL) - ## The user's updater already overlaid the capsule over their v3 tree. - ## Put the final v3 back so nothing is lost; without the fallback (an - ## older capsule) the bridge can only wait for a newer Godot. - if MigrationFallback.available(): - _present_state(FLOOR_RESTORING, false, false) - var fallback: Node = MigrationFallback.new() - _parent_node().add_child(fallback) - fallback.start() + push_warning( + "MCP | update to %s was swapped in under Godot %s; this editor is Godot %s" + % [str(outcome.get("to_version", "")), swapped_in, running] + ) + + +func _continue_enter_tree_after_update_barrier() -> void: + + ## Register only after the update barrier, but before the headless guard: + ## `godot --headless --export-*` still needs to strip the game-helper + ## autoload from exported packs. The export plugin is otherwise inert. + _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 + + ## 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") + + _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() + + ## Capture desired endpoint settings without probing the OS. WS reservation + ## is an activation effect on Windows (`netsh`), so it runs only after every + ## owner and the Dock have been constructed and wired below. + _endpoint_policy = ClientConfigurator.capture_endpoint_policy() + _resolved_ws_port = int(_endpoint_policy.ws_port) + + ## Construct plugin-lifetime work owners before attaching the replaceable + ## Dock. Constructors are inert; activate() is the single normal-start seam + ## that Phase 6's durable activation barrier will guard. + _client_jobs = ClientJobOwner.new() + _client_jobs.snapshot_changed.connect(_on_client_work_snapshot_changed) + _client_jobs.status_refresh_completed.connect(_on_client_status_refresh_completed) + _client_jobs.mcp_status_completed.connect(_on_mcp_client_status_completed) + _client_jobs.mcp_action_completed.connect(_on_mcp_client_action_completed) + _client_jobs.post_update_repin_completed.connect(_on_post_update_repin_completed) + _client_jobs.action_completed.connect(_on_client_action_completed) + _client_jobs.action_timed_out.connect(_on_client_action_timed_out) + add_child(_client_jobs) + _update_manager = UpdateManager.new() + _update_manager.update_check_completed.connect(_on_update_check_completed) + _update_manager.install_state_changed.connect(_on_update_install_state_changed) + _update_manager.activation_requested.connect(_on_update_activation_requested) + add_child(_update_manager) + + _lifecycle.snapshot_changed.connect(_on_lifecycle_snapshot_changed) + _lifecycle.transport_ready.connect(_on_lifecycle_transport_ready) + _lifecycle.transport_cleared.connect(_on_lifecycle_transport_cleared) + _lifecycle.startup_finished.connect(_startup_trace_finish) + + _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 + _connection.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", "") + ## Replay the custom-tool catalog on (re)connect so tools registered + ## before the initial connection or during a disconnect window reach + ## the server — send_event silently drops while _connected is false. + _connection.connection_state_changed.connect(_on_connection_state_changed) + + _telemetry = Telemetry.new(_connection) + + _debugger_plugin = DebuggerPlugin.new(_log_buffer, _game_log_buffer, _editor_log_buffer, _surfaced_error_tracker) + _vision_routing = VisionRoutingScript.new() + _vision_routing.log_buffer = _log_buffer + set_process(true) + _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", + [_client_jobs], + ) + _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, _connection] + ) + _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("game_debug_control", "editor", &"game_debug_control") + _dispatcher.register_lazy("get_project_setting", "project", &"get_project_setting") + _dispatcher.register_lazy("set_project_setting", "project", &"set_project_setting") + _dispatcher.register_lazy("set_main_scene", "project", &"set_main_scene") + _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("physics_shape_generate", "physics_shape", &"generate") + _dispatcher.register_lazy("environment_create", "environment", &"create_environment") + _dispatcher.register_lazy("gradient_texture_create", "texture", &"create_gradient_texture") + _dispatcher.register_lazy("noise_texture_create", "texture", &"create_noise_texture") + _dispatcher.register_lazy("curve_set_points", "curve", &"set_points") + _dispatcher.register_lazy("control_draw_recipe", "control_draw_recipe", &"control_draw_recipe") + _dispatcher.register_lazy("tilemap_set_cell", "tilemap", &"set_cell") + _dispatcher.register_lazy("tilemap_set_cells_rect", "tilemap", &"set_cells_rect") + _dispatcher.register_lazy("tilemap_clear", "tilemap", &"clear_layer") + _dispatcher.register_lazy("tilemap_get_cells", "tilemap", &"get_used_cells") + _dispatcher.register_lazy("tileset_get_atlas_tiles", "tileset", &"get_atlas_tiles") + _dispatcher.register_lazy("tileset_get_atlas_image", "tileset", &"get_atlas_image") + _dispatcher.register_lazy("gridmap_set_item", "gridmap", &"set_item") + _dispatcher.register_lazy("gridmap_fill", "gridmap", &"fill") + _dispatcher.register_lazy("gridmap_clear", "gridmap", &"clear_layer") + _dispatcher.register_lazy("gridmap_get_used_cells", "gridmap", &"get_used_cells") + _dispatcher.register_lazy("gridmap_list_library_items", "gridmap", &"list_library_items") + _dispatcher.register_lazy("csg_create", "csg", &"create") + _dispatcher.register_lazy("csg_set_operation", "csg", &"set_operation") + + _connection.dispatcher = _dispatcher + add_child(_connection) + _startup_trace_phase("handlers_registered") + + # Custom tool registry + _custom_tool_service_locator = McpServiceLocator.new() + _custom_tool_service_locator.setup(_connection, _log_buffer) + _custom_tool_registry = McpToolRegistry.new() + _custom_tool_registry.setup(_dispatcher, _custom_tool_service_locator) + _custom_tool_registry.tools_changed.connect(_on_custom_tools_changed) + _custom_tool_registry.mark_ready() + _startup_trace_phase("custom_tools_ready") + + # Dock panel + _dock = Dock.new() + _dock.vision_routing = _vision_routing + _dock.name = "Godot AI" + _dock.update_requested.connect(_on_dock_update_requested) + _dock.client_action_requested.connect(_on_dock_client_action_requested) + _dock.client_status_refresh_requested.connect(_on_dock_client_status_refresh_requested) + _dock.status_snapshot_requested.connect(_on_dock_status_snapshot_requested) + _dock.live_server_probe_requested.connect(_on_dock_live_server_probe_requested) + _dock.lifecycle_action_requested.connect(_on_dock_lifecycle_action_requested) + _dock.dev_server_action_requested.connect(_on_dock_dev_server_action_requested) + _dock.mcp_logging_changed.connect(_on_dock_mcp_logging_changed) + _dock.log_snapshot_requested.connect(_on_dock_log_snapshot_requested) + _dock.plugin_reload_requested.connect(_on_dock_plugin_reload_requested) + _dock.settings_apply_requested.connect(_on_dock_settings_apply_requested) + _dock.post_update_action_requested.connect(_on_dock_post_update_action_requested) + _client_jobs.set_client_health_blocked(_client_health_is_blocked()) + _publish_dock_status_snapshots() + add_control_to_dock(DOCK_SLOT_RIGHT_BL, _dock) + _dock.present_client_work_snapshot(_client_jobs.snapshot()) + _startup_trace_phase("dock_attached") + ## Activation barrier: no process, socket, or probe effect begins until all + ## owners and the replaceable Dock have been constructed and wired. + _activate_startup_endpoints() + + +## Endpoint selection is retryable before the immutable launch policy or any +## client migration worker exists. An old bridge keeps its own ports. +func _activate_startup_endpoints() -> void: + if str(_post_update_outcome.get("outcome", "")) == "success": + var prepared := ClientConfigurator.prepare_major_upgrade_endpoints( + str(_post_update_outcome.get("from_version", "")), + str(_post_update_outcome.get("to_version", "")), + ) + if not bool(prepared.get("ok", false)): + _present_endpoint_setup_failure(str(prepared.get("error", "Endpoint selection failed."))) return - _present_state(FLOOR_MESSAGE, true, false) + var override := ClientConfigurator.v4_endpoint_ports_status() + if not bool(override.get("ok", false)): + _present_endpoint_setup_failure(str(override.get("error", "Invalid endpoint override."))) return - _start_migration.call_deferred() + var resolved_policy := ClientConfigurator.capture_endpoint_policy() + var http_port := int(resolved_policy.http_port) + var configured_ws := int(resolved_policy.ws_port) + if ( + http_port < ClientConfigurator.MIN_PORT or http_port > ClientConfigurator.MAX_PORT + or configured_ws < ClientConfigurator.MIN_PORT or configured_ws > ClientConfigurator.MAX_PORT + or http_port == configured_ws + ): + _present_endpoint_setup_failure("Choose distinct HTTP and WebSocket ports between %d and %d in Godot AI settings." % [ClientConfigurator.MIN_PORT, ClientConfigurator.MAX_PORT]) + return + var resolved_ws := _resolve_ws_port(configured_ws) + if ( + resolved_ws < ClientConfigurator.MIN_PORT or resolved_ws > ClientConfigurator.MAX_PORT + or resolved_ws == http_port + or (bool(override.present) and resolved_ws != configured_ws) + ): + _present_endpoint_setup_failure("The configured WebSocket port is unavailable. Choose another endpoint pair in Godot AI settings, then retry.") + return + resolved_policy["ws_port"] = resolved_ws + resolved_policy["capability_path"] = TransportCapability.path_for_http_port(http_port) + _set_endpoint_policy(resolved_policy) + if _connection != null: + _connection.ws_port = resolved_ws + if _post_update_action == "retry_endpoints": + _post_update_action = "" + if _dock != null: + _dock.present_update_state({"post_update_action": "", "status_text": "", "label_text": "", "banner_visible": false}) + ## #691: publish every environment/setting value before the first worker. + ClientConfigurator.warm_env_snapshot(_endpoint_policy) + _lifecycle.configure(_capture_lifecycle_plan()) + _begin_startup_release() + + +func _present_endpoint_setup_failure(error: String) -> void: + _post_update_action = "retry_endpoints" + _lifecycle._block_without_effect("endpoint_setup_failed", error) + if _dock != null: + _dock.present_update_state({ + "install_in_flight": false, + "button_text": "Retry endpoint setup", + "status_text": "Client endpoint setup failed", + "button_disabled": false, + "label_text": error, + "banner_visible": true, + "post_update_action": "retry_endpoints", + }) + + +func _client_health_is_blocked() -> bool: + return ServerStateScript.blocks_client_health( + int(_lifecycle.get_status_dict().get("state", ServerStateScript.UNINITIALIZED)) + ) + + +func _on_dock_client_action_requested(client_id: String, action: String) -> void: + if _client_jobs == null: + return + if not _client_jobs.request_action(client_id, action) and _dock != null: + _dock.present_client_work_snapshot(_client_jobs.snapshot()) + + +func _on_dock_client_status_refresh_requested(client_ids: Array[String], force: bool) -> void: + if _client_jobs == null: + return + _client_jobs.set_client_health_blocked(_client_health_is_blocked()) + _client_jobs.request_status_refresh(client_ids, force) + + +func _on_client_work_snapshot_changed(snapshot: Dictionary) -> void: + if _dock != null: + _dock.present_client_work_snapshot(snapshot) + + +func _on_client_status_refresh_completed(results: Dictionary) -> void: + if _dock != null: + _dock.present_client_status_refresh_results(results) + + +func _on_mcp_client_status_completed( + request_ids: Array[String], payload: Dictionary +) -> void: + if _connection == null: + return + for request_id in request_ids: + _connection.send_deferred_response(request_id, payload) + + +func _on_mcp_client_action_completed(request_id: String, payload: Dictionary) -> void: + if _connection != null: + _connection.send_deferred_response(request_id, payload) + + +func _on_client_action_completed( + client_id: String, action: String, result: Dictionary, prewarm: Dictionary +) -> void: + if _dock != null: + _dock.present_client_action_result(client_id, action, result, prewarm) + + +func _on_client_action_timed_out(client_id: String, action: String, detail: String) -> void: + if _dock != null: + _dock.present_client_action_timeout(client_id, action, detail) + + +func _transport_snapshot_for_dock() -> Dictionary: + if _connection == null: + return {"connected": false, "server_version": "", "status": {}} + return { + "connected": _connection.is_connected, + "server_version": _connection.server_version, + "status": _connection.get_transport_status(), + } + + +func _lifecycle_snapshot_for_dock() -> Dictionary: + if _lifecycle == null: + return { + "state": ServerStateScript.UNINITIALIZED, + "server_pid": -1, + "resolved_ws_port": _resolved_ws_port, + "can_restart_managed": false, + "can_recover_incompatible": false, + "normal_start_released": _normal_start_released, + } + var snapshot: Dictionary = _lifecycle.get_status_dict().duplicate(true) + snapshot["server_pid"] = _lifecycle.get_server_pid() + snapshot["resolved_ws_port"] = _resolved_ws_port + snapshot["can_restart_managed"] = ( + _normal_start_released and _lifecycle.can_restart_managed_server() + ) + snapshot["can_recover_incompatible"] = ( + _normal_start_released and bool(snapshot.get("can_recover_incompatible", false)) + ) + snapshot["normal_start_released"] = _normal_start_released + if _post_update_retry_episode > 0 and int(snapshot.get("episode_id", 0)) == _post_update_retry_episode: + var episode: Dictionary = _lifecycle.episode_snapshot() + if str(episode.get("state", "")) == "BLOCKED" and str(episode.get("reason", "")) == "launch_gone" and str(episode.get("proof_pending_reason", "")) == "capability_pair": + ## Only the presentation changes; transport remains blocked until proof. + snapshot["state"] = ServerStateScript.SPAWNING + snapshot["handoff_retry_pending"] = true + return snapshot + + +func _publish_dock_status_snapshots() -> void: + if _dock == null: + return + _dock.present_transport_snapshot(_transport_snapshot_for_dock()) + _dock.present_lifecycle_snapshot(_lifecycle_snapshot_for_dock()) + + +func _on_dock_status_snapshot_requested() -> void: + _publish_dock_status_snapshots() + + +func _on_dock_live_server_probe_requested(port: int) -> void: + if _dock != null: + _dock.present_live_server_probe_result( + ServerLifecycleManager.probe_live_server_status( + port, ServerLifecycleManager.DEFAULT_PROBE_TIMEOUT_MS, + str(_endpoint_policy.get("capability_path", "")) + ) + ) + + +func _on_dock_lifecycle_action_requested(action: int) -> void: + if not _normal_start_released: + if _dock != null: + _dock.present_lifecycle_action_result(false) + _publish_dock_status_snapshots() + return + var accepted := false + match action: + Dock.LifecycleAction.RECOVER_INCOMPATIBLE: + accepted = bool(await recover_incompatible_server()) + Dock.LifecycleAction.RESTART_SERVER: + accepted = force_restart_server() + if _dock != null: + _dock.present_lifecycle_action_result(accepted) + _publish_dock_status_snapshots() + + +func _on_dock_dev_server_action_requested(action: int) -> void: + if action != Dock.DevServerAction.STOP and not _normal_start_released: + _publish_dock_status_snapshots() + return + match action: + Dock.DevServerAction.START_OR_RESTART: + if _telemetry != null: + _telemetry.record_dev_server_toggle("start") + restart_or_start_managed_server() + Dock.DevServerAction.STOP: + if _telemetry != null: + _telemetry.record_dev_server_toggle("stop") + stop_managed_server() + _publish_dock_status_snapshots() + + +func _on_dock_mcp_logging_changed(enabled: bool) -> void: + if _dispatcher != null: + _dispatcher.mcp_logging = enabled + if _log_buffer != null: + _log_buffer.enabled = enabled + + +func _on_dock_log_snapshot_requested(after_sequence: int) -> void: + if _dock == null or _log_buffer == null: + return + var sequence: int = _log_buffer.total_logged() + var reset: bool = sequence < after_sequence + var count: int = sequence if reset else maxi(0, sequence - after_sequence) + _dock.present_log_snapshot({ + "sequence": sequence, + "reset": reset, + "lines": _log_buffer.get_recent(count), + }) + + +func _on_dock_plugin_reload_requested(reason: String) -> void: + Telemetry.record_pending_plugin_reload(reason) + ## The reload frees this plugin, so it must not run on one of this + ## instance's own frames: defer the static call itself, never a method of + ## this instance. A deferred method that reloads synchronously returns into + ## a freed script and takes the editor down (SIGBUS/SIGABRT after + ## "Bad address index"). + PluginReload.reload_enabled_plugin.call_deferred() + + +func _on_dock_settings_apply_requested(changes: Dictionary, reload: bool) -> void: + var applied := ClientConfigurator.apply_endpoint_settings(changes.duplicate(true)) + if not bool(applied.get("ok", false)): + push_error("MCP | refused settings change: %s" % str(applied.get("error", "invalid settings"))) + return + ## #913: the setting is persisted, so push any opt-out to the live server — + ## the reload below replaces this plugin, not a server it merely adopted. + ## Best-effort: the reload may cut the socket first, and the reconnect + ## re-asserts it from `Telemetry._on_connection_state_changed` anyway. + if _telemetry != null: + _telemetry.assert_opt_out() + if reload: + _on_dock_plugin_reload_requested("endpoint_settings") + + +func _on_dock_update_requested() -> void: + if _update_manager != null and _update_manager.has_install_candidate(): + _update_manager.start_install(_preflight_update()) + + +func _on_update_check_completed(result: Dictionary) -> void: + if _dock != null: + _dock.present_update_check(result) + + +func _on_update_install_state_changed(state: Dictionary) -> void: + if not bool(state.get("install_in_flight", true)) and _client_jobs != null: + _client_jobs.resume_after_quiesce() + if not bool(state.get("install_in_flight", true)) and not _update_swapped: + UpdateInstaller.release_lock() + if _dock != null: + _dock.present_update_state(state) + + +func _on_update_activation_requested(package: Dictionary) -> void: + install_downloaded_update(package) + + +## Hold successful activation behind the client-migration barrier. Rollback +## resumes normally after recording its terminal outcome. +func _begin_startup_release() -> void: + if str(_post_update_outcome.get("outcome", "")) != "success": + if not _post_update_outcome.is_empty(): + _present_post_update_failure() + UpdateInstaller.clear_pending() + _fan_post_update_outcome() + _release_normal_startup() + return + var started: Dictionary = _client_jobs.begin_post_update_repin( + str(_post_update_outcome.get("from_version", "")), + str(_post_update_outcome.get("to_version", "")), + bool(_post_update_outcome.get( + "replace_owned_mismatches", + _post_update_outcome.get("manual_migration", false), + )), + ) + if not bool(started.get("ok", false)): + _present_post_update_barrier_failure(str(started.get("error", "Client migration could not start."))) + return + _post_update_action = "" + if _dock != null: + _dock.present_update_state({ + "install_in_flight": true, + "status_text": "Migrating client configuration…", + "button_disabled": true, + "label_text": "The server remains stopped until configured clients are repinned.", + "banner_visible": true, + "post_update_action": "", + }) + + +func _on_post_update_repin_completed(result: Dictionary) -> void: + if str(_post_update_outcome.get("outcome", "")) != "success": + return + if not bool(result.get("ok", false)): + _present_post_update_barrier_failure(str(result.get("error", "Client migration failed."))) + return + for client_id in result.get("foreign_ids", []): + push_warning( + "MCP | the %s entry named godot-ai launches something else; it was left unchanged. Use Configure in the dock to replace it." + % str(client_id) + ) + _post_update_deferred = [] + for entry in result.get("deferred", []): + if entry is Dictionary: + _post_update_deferred.append((entry as Dictionary).duplicate(true)) + push_warning( + "MCP | %s was not migrated automatically because %s; it was left unchanged. Use Configure in the dock." + % [str(entry.get("id", "")), str(entry.get("reason", ""))] + ) + ## A click cannot prove that an external client restarted. The enforceable + ## boundary is the one we own: repin its configuration, mark the update + ## complete, then start and authenticate that server. A major upgrade can + ## select independent ports; old clients must reload the migrated config, + ## but cannot hold the new editor endpoint behind their existing leases. + _finish_post_update() + + +func _present_post_update_barrier_failure(error: String) -> void: + _post_update_action = "retry" + push_error("MCP | post-update client migration blocked startup: %s" % error) + if _dock != null: + _dock.present_update_state({ + "install_in_flight": false, + "button_text": "Retry client migration", + "status_text": "Installed — client migration failed", + "button_disabled": false, + "label_text": "Server startup is blocked: %s" % error, + "banner_visible": true, + "post_update_action": "retry", + }) + + +func _on_dock_post_update_action_requested(action: String) -> void: + if action != _post_update_action: + return + if action == "retry_endpoints": + _activate_startup_endpoints() + elif action == "retry": + _begin_startup_release() + + +func _finish_post_update() -> void: + print("MCP | client migration completed") + ## The success marker stays as the durable record of the last update; the + ## next swap overwrites it and preflight only refuses swapped/repair states. + ## Recording the migration keeps later starts from repeating it. + var recorded := UpdateInstaller.record_clients_migrated() + if recorded != OK: + push_warning("MCP | could not record client migration in the update marker: %s" % error_string(recorded)) + ## In-editor updates retain old script graphs for undo. Keep their backing + ## files until a fresh editor process can safely prune older generations. + if not get_tree().root.has_meta("godot_ai_retained_update_scripts"): + UpdateInstaller.prune_backups(str(_post_update_outcome.get("from_version", ""))) + _post_update_replaced_version = str(_post_update_outcome.get("from_version", "")) + _post_update_reprobes_left = POST_UPDATE_REPROBE_LIMIT + _post_update_stale_reprobes_left = POST_UPDATE_STALE_REPROBE_LIMIT + _post_update_replacements_left = POST_UPDATE_REPLACEMENT_LIMIT + var to_version := str(_post_update_outcome.get("to_version", "")) + if McpServerVersionCheck.attached_bridges_follow(_post_update_replaced_version, to_version): + print("MCP | AI clients using v%s can reconnect to v%s; relaunch clients still using older versions" % [_post_update_replaced_version, to_version]) + else: + print( + "MCP | Refresh the Godot AI MCP connection and reload its configuration once to use v%s; relaunch the AI app if it cannot reload the configuration" + % to_version + ) + _present_post_update_complete() + _fan_post_update_outcome() + _release_normal_startup() + + +func _present_post_update_failure() -> void: + var error := str(_post_update_outcome.get("error", "")) + var to_version := str(_post_update_outcome.get("to_version", "")) + push_error("MCP | update to %s failed; the previous version is live: %s" % [to_version, error]) + if _dock != null: + _dock.present_update_state({ + "install_in_flight": false, + "status_text": "Update failed — previous version restored", + "button_disabled": false, + "label_text": error, + "banner_visible": true, + "post_update_action": "", + }) + + +func _present_post_update_complete() -> void: + _post_update_action = "" + if _dock != null: + _dock.present_update_state({ + "install_in_flight": false, + "status_text": "Godot AI installed" if _post_update_deferred.is_empty() else "Installed — client setup needed", + "button_disabled": true, + "label_text": _post_update_complete_label(), + "banner_visible": true, + "post_update_action": "", + "outcome": "success", + }) + + +func _post_update_complete_label() -> String: + var to_version := str(_post_update_outcome.get("to_version", "")) + var from_version := str(_post_update_outcome.get("from_version", "")) + var text := ( + "AI clients already using v%s can reconnect to v%s without restarting. Refresh older MCP connections and reload their configuration; relaunch the AI app if needed." % [from_version, to_version] + if McpServerVersionCheck.attached_bridges_follow(from_version, to_version) + else "Refresh the Godot AI MCP connection and reload its configuration once to use v%s. If the AI app cannot reload its configuration, quit and relaunch it." + % to_version + ) + if _post_update_deferred.is_empty(): + return text + var named: Array[String] = [] + for entry in _post_update_deferred: + var client_id := str(entry.get("id", "")) + var client := McpClientRegistry.get_by_id(client_id) + var name: String = client.display_name if client != null else client_id + named.append("%s (%s)" % [name, str(entry.get("reason", "not migrated"))]) + return text + " Not migrated: %s. Use Configure to replace them." % ", ".join(named) + + +## Sole release point for ordinary work and the server lifecycle. Keeping +## these effects together makes the post-update legal ordering reviewable. +func _release_normal_startup() -> void: + if _normal_start_released: + return + _normal_start_released = true + _client_jobs.set_client_health_blocked(_client_health_is_blocked()) + _client_jobs.activate() + _update_manager.check_for_updates.call_deferred() + _start_server() + _startup_trace_phase("server_start") + _log_buffer.log("plugin loaded") + if _telemetry != null: + _telemetry.record_dock_startup() + _telemetry.flush_pending_plugin_reload() + + +## Fan one immutable terminal update outcome. It is held only in this +## instance's memory, never EditorSettings, and is cleared exactly once. +func _fan_post_update_outcome() -> void: + if _post_update_outcome.is_empty(): + return + var outcome := str(_post_update_outcome.get("outcome", "")) + var status := "unknown" + match outcome: + "success": + status = "success" + "rolled_back": + status = "failed_clean" + "repair_required", "quarantined": + status = "failed_mixed" + var error := "" if outcome == "success" else str(_post_update_outcome.get("error", outcome.replace("_", " "))) + var from_version := str(_post_update_outcome.get("from_version", "")) + var to_version := str(_post_update_outcome.get("to_version", "")) + if _telemetry != null and not bool(_post_update_outcome.get("manual_migration", false)): + _telemetry.record_self_update(status, from_version, to_version, error) + _post_update_outcome.clear() + + +func _block_update_startup(reason: String) -> void: + _update_barrier_blocked = true + push_error("MCP | update recovery blocked plugin startup: %s" % reason) + _disable_after_update_barrier.call_deferred() + + +func _disable_after_update_barrier() -> void: + ## A live tree that neither matches its manifest nor has a backup to + ## restore must not run. Disable the shell so nothing is built from it; + ## the marker keeps the exact paths for manual recovery. + if _update_barrier_blocked: + print("MCP | disabling plugin after update barrier refusal") + EditorInterface.set_plugin_enabled("res://addons/godot_ai/plugin.cfg", false) func _exit_tree() -> void: - _free_bridge() - if _coordinator != null and is_instance_valid(_coordinator): - ## The coordinator outlives this plugin on purpose; it disables the - ## capsule and keeps running. Only stop listening to it. - if _coordinator.state_changed.is_connected(_present_state): - _coordinator.state_changed.disconnect(_present_state) - _coordinator = null - if _dock != null and is_instance_valid(_dock): + if _unsupported_engine: + return + set_process(false) + ## 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: + _headless_disabled = false + ## Ported from upstream PR #936 at + ## 537a490c865837bedb96042d10ee0fc74673cd99: `_lifecycle` is built in + ## _init(), before the headless guard in _enter_tree() runs, so it exists + ## even on this path — null it here too (full teardown below is skipped). + _lifecycle = null + return + + if _update_barrier_blocked: + _update_barrier_blocked = false + _lifecycle = null + return + + ## Client work has plugin lifetime, independent of the Dock. Realize every + ## thread before either owner script can be reloaded or freed. + if _client_jobs != null: + _client_jobs.quiesce() + + if _custom_tool_registry != null: + _custom_tool_registry.clear() + _custom_tool_registry = null + + _custom_tool_service_locator = null + + ## Outer-to-inner teardown. Dispatcher Callables hold RefCounted handlers + ## alive past the point where Godot reloads their class_name scripts — the + ## first post-reload call into a typed-array-holding handler (e.g. + ## McpGameLogBuffer._storage) then SIGSEGVs against a stale class descriptor. + ## See issue #46. + + # Stop inbound work first so _process can't enqueue new commands or + # null-deref log_buffer on the next tick mid-teardown. + if _connection: + _connection.teardown() + + if _vision_routing: + _vision_routing.shutdown() + _vision_routing = null + # Transport is stopped and both worker owners have joined. Release the + # ordinary plugin-lifetime graph; this is not the stronger hot script-swap + # authorization (prepare_for_update_reload still requires clear()). + if _dispatcher: + _dispatcher.release_after_teardown() + + if _dock: + _dock.release_editor_progress_dialog() remove_control_from_docks(_dock) _dock.queue_free() - _dock = null + _dock = null + if _update_manager: + _update_manager.cancel_install() + _update_manager.queue_free() + _update_manager = null + if _client_jobs: + _client_jobs.queue_free() + _client_jobs = 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 + + ## Teardown follows the immutable launch plan: keep-on-exit or an active + ## lease detaches; otherwise the exact owned-process grant is stopped. + _lifecycle.teardown_for_editor_exit() + _lifecycle = null + print("MCP | plugin unloaded") -func _build_dock() -> void: - _dock = VBoxContainer.new() - _dock.name = "Godot AI" - _label = Label.new() - _label.text = "Preparing the signed Godot AI v4 migration…" - _label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - _label.add_theme_font_size_override("font_size", 15) - _dock.add_child(_label) - _retry = Button.new() - _retry.text = "Retry migration" - _retry.visible = false - _retry.pressed.connect(_on_retry) - _dock.add_child(_retry) - add_control_to_dock(DOCK_SLOT_RIGHT_UL, _dock) +func _process(_delta: float) -> void: + if _vision_routing != null: + _vision_routing.poll_completed() -func _start_migration() -> void: - if _bridge != null or _coordinator != null: +## Attach editor_logger.gd as a Godot logger so editor-process script +## errors (parse errors, @tool runtime errors, EditorPlugin errors, +## push_error/push_warning) flow into _editor_log_buffer for +## logs_read(source="editor"). +## +## Limitation called out in the issue: parse errors fired *before* the +## plugin's _enter_tree (e.g. during the editor's initial filesystem +## scan, or for scripts that fail on first project open) happen before +## add_logger is called and are not captured. There's no public API to +## drain the editor's already-emitted error history; rescanning the +## file would re-emit them but at the cost of disrupting the user's +## editing state, so we accept the gap. +func _attach_editor_logger() -> void: + _editor_logger = EditorLogger.new(_editor_log_buffer) + OS.add_logger(_editor_logger) + + +func _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 - _clear_bridge_status() - _retry.visible = false - _label.text = "Preparing the signed Godot AI v4 migration…" - print("MCP | v3 bridge verifying and staging the signed v4 tree") - _bridge = MigrationBridge.new() - _bridge.state_changed.connect(_present_state) - _bridge.prepared.connect(_activate_prepared) - _parent_node().add_child(_bridge) - _bridge.start() + _ensure_game_helper_autoload() -func _present_state(message: String, failed: bool = false, record: bool = true) -> void: - if failed: - print("MCP | v3 bridge: %s" % message) - if record: - MigrationCoordinator.record_status(message) - _free_bridge() - _coordinator = null - if _label != null: - _label.text = message - if _retry != null: - _retry.visible = failed +static func _mcp_disabled_for_headless_launch() -> bool: + return _mcp_disabled_for_headless( + OS.get_cmdline_args(), + DisplayServer.get_name(), + OS.get_environment("GODOT_AI_ALLOW_HEADLESS") + ) -func _present_error(message: String) -> void: - _present_state(message, true) - - -func _activate_prepared(package: Dictionary) -> void: - _free_bridge() - print("MCP | v3 bridge activating canonical v4 tree") - _coordinator = MigrationCoordinator.new() - _coordinator.state_changed.connect(_present_state) - _parent_node().add_child(_coordinator) - _coordinator.start(package) - - -func _on_retry() -> void: - if not _meets_floor(): - _present_state(FLOOR_MESSAGE, true, false) - return - if not _editor_available(): - _present_state(EDITOR_REQUIRED, true, false) - return - _start_migration() - - -func _free_bridge() -> void: - if _bridge != null and is_instance_valid(_bridge): - _bridge.queue_free() - _bridge = null - - -func _parent_node() -> Node: - var parent: Node = EditorInterface.get_base_control() - return parent if parent != null else get_tree().root - - -static func _meets_floor() -> bool: - ## Test-only: lets a scenario exercise the refusal path on a current Godot. - if OS.get_environment("GODOT_AI_TEST_GODOT_FLOOR") == "unmet": +static func _mcp_disabled_for_headless(args: PackedStringArray, display_name: String, allow_value: String) -> bool: + if McpSettings.truthy(allow_value): return false - var version := Engine.get_version_info() - return int(version.get("major", 0)) == 4 and int(version.get("minor", 0)) >= 7 - - -## Updates are interactive-only: a headless import or an export run loads -## enabled plugins too, and must never swap the add-on tree or restart. -static func _editor_available() -> bool: - if not Engine.is_editor_hint(): - return false - if _truthy(OS.get_environment("GODOT_AI_ALLOW_HEADLESS")): - ## The override the v4 plugin honours too, so CI can drive the - ## crossing in a headless editor. Never set it for a real install. - return EditorInterface.get_base_control() != null - if DisplayServer.get_name().to_lower() == "headless": - return false - if _args_request_headless(OS.get_cmdline_args()): - return false - return EditorInterface.get_base_control() != null - - -static func _truthy(value: String) -> bool: - var lowered := value.strip_edges().to_lower() - return lowered == "1" or lowered == "true" or lowered == "yes" or lowered == "on" + return _args_request_headless(args) or display_name.to_lower() == "headless" static func _args_request_headless(args: PackedStringArray) -> bool: @@ -186,17 +1218,590 @@ static func _args_request_headless(args: PackedStringArray) -> bool: return false -static func _bridge_status() -> Dictionary: - var settings := EditorInterface.get_editor_settings() - var key := MigrationCoordinator.status_setting() - if settings == null or not settings.has_setting(key): - return {} - var parsed: Variant = JSON.parse_string(str(settings.get_setting(key))) - return parsed if parsed is Dictionary else {} -static func _clear_bridge_status() -> void: - var settings := EditorInterface.get_editor_settings() - var key := MigrationCoordinator.status_setting() - if settings != null and settings.has_setting(key): - settings.erase(key) +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: + if not _normal_start_released: + push_error("MCP | server start refused before normal startup release") + return + _lifecycle.start_server() + + +func _capture_lifecycle_plan() -> Dictionary: + var policy := _endpoint_policy.duplicate(true) + var http_port := int(policy.get("http_port", ClientConfigurator.DEFAULT_HTTP_PORT)) + var worktree_src := "" + if ClientConfigurator.is_dev_checkout(): + worktree_src = ClientConfigurator.find_worktree_src_dir( + ProjectSettings.globalize_path("res://") + ) + return { + "http_port": http_port, + "capability_path": str(policy.get("capability_path", "")), + "ws_port": int(policy.get("ws_port", ClientConfigurator.DEFAULT_WS_PORT)), + "expected_version": ClientConfigurator.get_plugin_version(), + "server_command": ClientConfigurator.get_server_command(), + "pid_file": ProjectSettings.globalize_path(PortResolver.SERVER_PID_FILE), + "startup_report": ProjectSettings.globalize_path(PortResolver.SERVER_STARTUP_REPORT), + "http_port_reserved": WindowsPortReservation.is_port_excluded(http_port), + "excluded_domains": str(policy.get("excluded_domains", "")), + "allow_hosts": str(policy.get("allow_hosts", "")), + "keep_alive": bool(policy.get("keep_alive", false)), + "worktree_src": worktree_src, + "ambient_pythonpath": OS.get_environment("PYTHONPATH"), + "disable_telemetry": not bool(policy.get("telemetry_enabled", true)), + "automatic_effects": true, + "defer_effects": true, + } + + +static func _supports_godot_version(version_info: Dictionary) -> bool: + var major := int(version_info.get("major", 0)) + var minor := int(version_info.get("minor", 0)) + return major == MIN_GODOT_MAJOR and minor >= MIN_GODOT_MINOR + + +func _on_lifecycle_snapshot_changed(snapshot: Dictionary) -> void: + if int(snapshot.get("episode_id", 0)) != _post_update_retry_episode or str(snapshot.get("episode_state", "")) != "BLOCKED": + _post_update_retry_episode = 0 + if _connection != null and bool(snapshot.get("connection_blocked", true)): + _connection.connect_blocked = true + _connection.connect_block_reason = str(snapshot.get("message", "")) + _replace_server_left_by_update(snapshot) + _log_lifecycle_block(snapshot) + if _client_jobs != null: + _client_jobs.set_client_health_blocked( + ServerStateScript.blocks_client_health( + int(snapshot.get("state", ServerStateScript.UNINITIALIZED)) + ) + ) + _publish_dock_status_snapshots() + + +## The dock shows why a server start blocked; the editor log should too, once +## per distinct reason, so a report from the field carries it. +func _log_lifecycle_block(snapshot: Dictionary) -> void: + if str(snapshot.get("episode_state", "")) != "BLOCKED": + _last_logged_block = "" + return + var message := str(snapshot.get("message", "")) + if message.is_empty() or message == _last_logged_block: + return + _last_logged_block = message + var retry_pending := bool(_lifecycle_snapshot_for_dock().get("handoff_retry_pending", false)) + print("MCP | %s: %s" % ["server handoff retry pending" if retry_pending else "server start blocked", message]) + + +## Once, right after an update: a godot-ai server at the version we just +## replaced is on our port (typically spawned by an attach bridge that lost +## server A during the restart). Replace it instead of asking the user to. +## Any other conflict keeps the dock's explicit Restart Server authority. +func _replace_server_left_by_update(snapshot: Dictionary) -> void: + if _post_update_replaced_version.is_empty(): + return + if not bool(snapshot.get("connection_blocked", true)): + _post_update_replaced_version = "" + return + if not bool(snapshot.get("can_recover_incompatible", false)): + ## Any other block right after an update is the bridge's backend in + ## flight: bound but not answering yet, or bound between our probe and + ## our launch so our server exited on bind. Probe again shortly, a + ## bounded number of times; the re-probe finds that backend answering + ## and takes the replacement path. Then the dock's Restart Server is + ## the remaining path. + if str(snapshot.get("episode_state", "")) != "BLOCKED": + return + var episode_id := int(snapshot.get("episode_id", 0)) + if episode_id <= 0 or episode_id == _post_update_retry_episode: + return + if str(snapshot.get("blocked_hint", "")) == ServerLifecycleManager.STALE_PRE_V4_HINT: + if _post_update_stale_reprobes_left <= 0: + return + if _post_update_stale_reprobes_left == POST_UPDATE_STALE_REPROBE_LIMIT: + print( + "MCP | a pre-v4 godot-ai server holds port %d; waiting for it to exit once its AI client is relaunched" + % int(snapshot.get("conflict_port", 0)) + ) + _post_update_stale_reprobes_left -= 1 + _post_update_retry_episode = episode_id + get_tree().create_timer(POST_UPDATE_STALE_REPROBE_SECONDS).timeout.connect( + _reprobe_after_update.bind(episode_id), CONNECT_ONE_SHOT + ) + return + if _post_update_reprobes_left > 0: + _post_update_reprobes_left -= 1 + _post_update_retry_episode = episode_id + get_tree().create_timer(1.0).timeout.connect(_reprobe_after_update.bind(episode_id), CONNECT_ONE_SHOT) + return + var version := str(snapshot.get("conflict_version", "")) + if not _update_may_replace(version): + return + if _post_update_replacements_left <= 0: + return + _post_update_replacements_left -= 1 + print( + "MCP | replacing the v%s server left on port %d by the update" + % [version, int(snapshot.get("conflict_port", 0))] + ) + if not _lifecycle.request_replacement(): + push_warning( + "MCP | could not replace the v%s server automatically; use Restart Server in the dock" + % version + ) + + +## The server the update superseded, or any older server of our major +## version an attach bridge left on the port (a client pinned further back). +## Never a newer one: that is another editor's server, and adoption or the +## dock's explicit Restart Server decides there. +func _update_may_replace(conflict_version: String) -> bool: + if conflict_version.is_empty(): + return false + if conflict_version == _post_update_replaced_version: + return true + return McpServerVersionCheck.is_older_same_major( + conflict_version, ClientConfigurator.get_plugin_version() + ) + + +func _reprobe_after_update(episode_id: int) -> void: + if episode_id != _post_update_retry_episode: + return + _post_update_retry_episode = 0 + if _post_update_replaced_version.is_empty() or _lifecycle == null or not _normal_start_released: + _publish_dock_status_snapshots() + return + var snapshot: Dictionary = _lifecycle.get_status_dict() + if int(snapshot.get("episode_id", 0)) != episode_id or str(snapshot.get("episode_state", "")) != "BLOCKED": + _publish_dock_status_snapshots() + return + _lifecycle.start_server() + + +func _on_lifecycle_transport_ready(ws_port: int, ws_capability: String) -> void: + _set_resolved_ws_port(ws_port) + if _client_jobs != null: + _client_jobs.set_client_health_blocked(false) + _client_jobs.request_status_refresh(ClientConfigurator.client_ids(), true) + if _connection != null: + _connection.authorize_transport(ws_port, ws_capability) + _publish_dock_status_snapshots() + + +func _on_lifecycle_transport_cleared(reason: String) -> void: + if _connection != null: + _connection.revoke_transport(reason) + _publish_dock_status_snapshots() + + +## 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. Positive means this lifecycle owns an +## exact process grant; adoption always reports -1 and cannot authorize stop. +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: + var policy := _endpoint_policy.duplicate(true) + if policy.is_empty(): + policy = ClientConfigurator.capture_endpoint_policy(port) + policy["ws_port"] = port + _set_endpoint_policy(policy) + if _connection != null: + _connection.ws_port = port + + +func _set_endpoint_policy(policy: Dictionary) -> void: + _endpoint_policy = policy.duplicate(true) + _resolved_ws_port = int(_endpoint_policy.get( + "ws_port", ClientConfigurator.DEFAULT_WS_PORT + )) + ClientConfigurator.capture_launch_context(_endpoint_policy) + + +func _resolve_ws_port(configured_port: int) -> int: + return PortResolver.resolve_ws_port( + configured_port, + ClientConfigurator.MAX_PORT, + _log_buffer, + ) + + +func prepare_for_update_reload() -> Dictionary: + ## Refuse while frame-yielding work is still live, before stopping any + ## owner. These probes are non-mutating; retry after the work completes. + if _dispatcher != null: + var handlers_ready: Dictionary = _dispatcher.quiesce_for_script_swap() + if not bool(handlers_ready.get("ok", false)): + return handlers_ready + if _debugger_plugin != null: + var debugger_ready: Dictionary = _debugger_plugin.quiesce_for_script_swap() + if not bool(debugger_ready.get("ok", false)): + return debugger_ready + ## Stop the exact managed process only after the non-mutating probes. + var lifecycle_quiesced: Dictionary = _lifecycle.prepare_for_update_reload() + if not bool(lifecycle_quiesced.get("ok", false)): + return lifecycle_quiesced + if _vision_routing != null: + _vision_routing.shutdown() + if _dispatcher != null: + var quiesced: Dictionary = _dispatcher.quiesce_for_script_swap() + if not bool(quiesced.get("ok", false)): + quiesced["reload_required"] = true + return quiesced + var cleared: Dictionary = _dispatcher.clear() + if not bool(cleared.get("ok", false)): + cleared["reload_required"] = true + return cleared + return {"ok": true} + + +## Refuse to start a download while an earlier update is unresolved or another +## live editor holds this project's update lock. The lock is taken here, before +## the download, and released by every failure path before the swap. +func _preflight_update() -> Dictionary: + var pending: Dictionary = UpdateInstaller.read_pending() + var status := str(pending.get("status", "")) + if status == "swapped" or status == "repair_required": + return {"ok": false, "error": "an earlier update is unresolved (%s)" % status, "download_root": ""} + var pid := OS.get_process_id() + var lock: Dictionary = UpdateInstaller.acquire_lock(pid, McpPortResolver.process_fingerprint(pid)) + if not bool(lock.get("ok", false)): + return {"ok": false, "error": str(lock.get("error", "")), "download_root": ""} + var download_root := ProjectSettings.globalize_path("user://godot_ai_update/download").trim_suffix("/") + if DirAccess.dir_exists_absolute(download_root): + _remove_tree(download_root) + if DirAccess.make_dir_recursive_absolute(download_root) != OK: + UpdateInstaller.release_lock() + return {"ok": false, "error": "could not create the download directory", "download_root": ""} + return {"ok": true, "error": "", "download_root": download_root} + + +static func _remove_tree(path: String) -> void: + var dir := DirAccess.open(path) + if dir == null: + return + dir.include_hidden = true + for name in dir.get_directories(): + _remove_tree(path.path_join(name)) + for name in dir.get_files(): + DirAccess.remove_absolute(path.path_join(name)) + DirAccess.remove_absolute(path) + + +## Verify, stage, quiesce, then hand activation to an independent runner. Every check runs in this editor +## against the downloaded bytes; nothing outside the editor is executed. The +## live tree is touched only by the two renames inside `swap`, and only after +## the staged tree has been re-hashed against the signed manifest. +## Activation runs on the main thread: verification hashes the archive, +## staging extracts it, and the worker quiescence waits for threads. Each +## phase names itself in the dock and yields one frame first so the label +## repaints; without that the dock sat on "Downloading…" for seconds after +## the download had finished, looking frozen. +func install_downloaded_update(package: Dictionary) -> void: + await _present_install_phase("Verifying signed update…") + var manifest_bytes := FileAccess.get_file_as_bytes(str(package.get("manifest", ""))) + var signature := FileAccess.get_file_as_bytes(str(package.get("signature", ""))) + var verified: Dictionary = ReleaseVerifier.verify_manifest( + manifest_bytes, + signature, + { + "repository": str(package.get("repository", "")), + "channel": str(package.get("channel", "")), + "version": str(package.get("version", "")), + "current_version": ClientConfigurator.get_plugin_version(), + }, + UpdateManager.RELEASE_SIGNING_PUBLIC_KEY_PEM, + ) + var manifest: Dictionary = verified.get("manifest", {}) + var checked: Dictionary = verified + if bool(verified.get("ok", false)): + checked = ReleaseVerifier.verify_archive(str(package.get("archive", "")), manifest) + if not bool(checked.get("ok", false)): + _fail_update("Update verification failed", "signed update refused: %s" % str(checked.get("error", ""))) + return + await _present_install_phase("Staging the verified tree…") + var staged: Dictionary = UpdateInstaller.stage(str(package.get("archive", "")), manifest) + if not bool(staged.get("ok", false)): + _fail_update("Update staging failed", "update staging refused: %s" % str(staged.get("error", ""))) + return + if _update_manager != null: + _update_manager.discard_downloads() + await _present_install_phase("Waiting for client workers…") + if _client_jobs != null: + var jobs_quiesced: Dictionary = _client_jobs.quiesce( + Time.get_ticks_msec() + ClientConfigurator.PREWARM_TIMEOUT_MS + ) + if not bool(jobs_quiesced.get("ok", false)): + UpdateInstaller.discard_stage() + _fail_update("Update cancelled safely", "client workers refused update quiescence") + return + var script_quiesced := prepare_for_update_reload() + if not bool(script_quiesced.get("ok", false)): + UpdateInstaller.discard_stage() + _fail_update("Update cancelled safely", "command workers refused update quiescence") + if bool(script_quiesced.get("reload_required", false)): + _reload_plugin_after_failed_update() + return + _on_update_install_state_changed({ + "install_in_flight": true, + "status_text": "Activating verified update…", + "button_disabled": true, + }) + var to_version := str(manifest.get("version", "")) + var record := { + "from_version": ClientConfigurator.get_plugin_version(), + "to_version": to_version, + "manifest_sha256": ReleaseVerifier.sha256_bytes(manifest_bytes), + "expected_tree_sha256": str(staged.get("tree_sha256", "")), + "editor_nonce": Crypto.new().generate_random_bytes(16).hex_encode(), + "replace_owned_mismatches": false, + } + ## Compile an independent script with no resource path. Loading this as a + ## normal Script would let the filesystem scan replace our live runner. + var runner_script := GDScript.new() + runner_script.source_code = FileAccess.get_file_as_string( + "res://addons/godot_ai/utils/update_activation_runner.gd" + ) + if runner_script.source_code.is_empty() or runner_script.reload() != OK: + UpdateInstaller.discard_stage() + _fail_update("Update cancelled safely", "could not compile the independent activation runner") + _reload_plugin_after_failed_update() + return + var runner = runner_script.new() + get_tree().root.add_child(runner) + if not runner.start({"stage_root": str(staged.get("stage_root", "")), "record": record}): + var refusal_reason := str(runner.refusal_reason) + runner.queue_free() + UpdateInstaller.discard_stage() + _fail_update("Update cancelled safely", refusal_reason) + _reload_plugin_after_failed_update() + return + ## The runner now owns the lock. Teardown's cancellation signal must not + ## release it before the deferred disable/drain/swap sequence completes. + _update_swapped = true + ## Return: no frame of this plugin may be suspended across source replacement. + + + +## Name the activation phase in the dock and let it repaint before the +## phase's main-thread work begins. +func _present_install_phase(status_text: String) -> void: + _on_update_install_state_changed({ + "install_in_flight": true, + "status_text": status_text, + "button_disabled": true, + }) + var tree := get_tree() + if tree != null: + await tree.process_frame + + +func _fail_update(status_text: String, error: String) -> void: + if _update_manager != null: + _update_manager.discard_downloads() + push_error("MCP | %s" % error) + _on_update_install_state_changed({ + "install_in_flight": false, + "status_text": status_text, + "button_disabled": false, + }) + + +func _reload_plugin_after_failed_update() -> void: + ## The signed prepared tree was aborted before mutation. Reconstruct the + ## unchanged old composition if any later quiescence step had already + ## stopped vision or released handler references. The reload frees this + ## plugin, so it must not run on one of this plugin's own frames: defer + ## the static call itself, not a method of this instance. + PluginReload.reload_enabled_plugin.call_deferred() + + +func can_recover_incompatible_server() -> bool: + return _normal_start_released and _lifecycle.can_recover_incompatible_server() + + +func recover_incompatible_server(_user_initiated: bool = true, _stale_version: String = "") -> bool: + ## The Dock click is the sole source of replacement authority. The manager + ## binds, spends, and discards one authorization for this exact target. + if not _normal_start_released: + return false + return _lifecycle.request_replacement() + + +## Managed restart uses the exact owned grant. An unowned incompatible server +## can only reach replacement through the separate explicit Dock intent. +func force_restart_server() -> bool: + if not _normal_start_released: + return false + return _lifecycle.force_restart_server() + + +## Developer controls use the same lifecycle owner as ordinary startup. The +## plugin may restart only its exact process grant; a compatible external +## server is adopted for transport but remains the launcher's responsibility. +func restart_or_start_managed_server() -> bool: + if not _normal_start_released: + return false + if has_managed_server(): + _lifecycle.force_restart_server() + return true + var port := ClientConfigurator.http_port() + if PortResolver.is_port_in_use(port): + push_warning( + "MCP | refusing to restart the unowned server on port %d; stop it from its launcher" + % port + ) + return false + _lifecycle.start_server() + return true + + +func stop_managed_server() -> void: + if has_managed_server(): + _lifecycle.stop_server() + + +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 needs an owned grant or a currently replaceable blocked target. + return _normal_start_released and _lifecycle.can_restart_managed_server() + + +func _on_custom_tools_changed() -> void: + if _connection == null: + push_warning("MCP | connection isn't established") + return + var tool_list: Array[Dictionary] = [] + ## Send all definitions plus their state: the server hides disabled tools + ## from fresh tools/list responses but retains a callable tombstone so a + ## client using a cached promoted name receives CUSTOM_TOOL_DISABLED. + for spec in _custom_tool_registry.all(): + tool_list.append({ + "name": spec.name, + "description": spec.description, + "params_schema": spec.params_schema, + "source": spec.source, + "deferred": spec.deferred, + "timeout_ms": spec.timeout_ms, + "requires_writable": spec.requires_writable, + "undoable": spec.undoable, + "promoted": spec.promoted, + "enabled": _custom_tool_registry.is_tool_enabled(spec.name) + }) + _connection.send_event("custom_tools_changed", {"tools": tool_list}) + + +## On (re)connect, replay the current custom-tool catalog so tools +## registered before the initial connection or during a disconnect +## window reach the server. send_event silently drops while +## _connected is false (connection.gd::_send_json), so without this +## replay those tools only surface on the next registry mutation. +func _on_connection_state_changed(is_open: bool) -> void: + if is_open: + _lifecycle.transport_authenticated(_connection.server_version) + if _custom_tool_registry != null: + _on_custom_tools_changed() + else: + _lifecycle.transport_lost() diff --git a/addons/godot_ai/plugin.gd.uid b/addons/godot_ai/plugin.gd.uid index 33cf809..4c550dd 100644 --- a/addons/godot_ai/plugin.gd.uid +++ b/addons/godot_ai/plugin.gd.uid @@ -1 +1 @@ -uid://b5w7nw8pfnjww +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..85d9c23 --- /dev/null +++ b/addons/godot_ai/runtime/game_helper.gd @@ -0,0 +1,1352 @@ +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 +var _mcp_runtime_process_ticks: int = 0 +var _last_debug_status_reply: Dictionary = {} +## Rendering-freeze beacon for the Windows-minimize state (#794 smoke, 1b): +## the frames_drawn value last observed in _process, and when it last +## advanced. -1 until the first observed advance, so a booting or +## render-less game (frames_drawn stuck at 0) can never read as +## render-stalled and keeps the fresh-frame await path's error replies. +var _last_frames_drawn_seen: int = -1 +var _last_frames_advance_msec: int = -1 +## #859: test/diagnostic seam for the synchronous eval liveness reply. The +## debugger capture remains callable while the game loop is parked, so the +## reply reports whether the _process beacon is actually advancing rather +## than merely proving that the capture was registered once. +var _last_eval_liveness_reply: Dictionary = {} + + +func _ready() -> void: + ## Only run in the game process, not in the editor. Use is_editor_hint + ## — NOT OS.has_feature("editor"), which is a BUILD-config check + ## (TOOLS_ENABLED) and returns true in the game subprocess too because + ## the game is spawned with the same editor binary. is_editor_hint is + ## the runtime-context check: true only inside the editor GUI, false + ## in play-from-editor. The earlier has_feature check was causing us + ## to skip registration in the game and time out every capture. + if Engine.is_editor_hint(): + return + ## Keep ticking while the tree is paused: _process both ferries game logs + ## and timestamps main-loop liveness for the stalled-loop screenshot + ## fallback (#777). A paused game still iterates its loop and renders, and + ## must not be misread as frozen. + process_mode = Node.PROCESS_MODE_ALWAYS + ## register_message_capture is safe to call before the debugger + ## handshake completes; the capture sits until a message arrives. + EngineDebugger.register_message_capture(CAPTURE_PREFIX, _on_debug_message) + _registered = true + ## Capture print() / printerr() / push_error() / push_warning() and + ## ferry them to the editor in mcp:log_batch messages flushed from + ## _process. + _logger = GameLogger.new() + OS.add_logger(_logger) + _logger_attached = true + ## Routed to the editor's Output panel via Godot's remote-stdout + ## forwarder — handy when diagnosing why capture timed out. + print("[godot_ai game_helper] registered mcp capture (debugger active=%s, logger=%s)" + % [EngineDebugger.is_active(), _logger_attached]) + ## Boot beacon so the editor side can confirm the autoload ran even + ## if no screenshot was ever requested. + if EngineDebugger.is_active(): + EngineDebugger.send_message("mcp:hello", []) + + +func _process(_delta: float) -> void: + _mcp_runtime_process_ticks += 1 + ## #777: liveness beacon for _handle_take_screenshot's stalled-loop check. + ## Recorded before the early returns below so the signal stays truthful + ## even when the logger or debugger channel is unavailable. + _last_loop_tick_msec = Time.get_ticks_msec() + ## Rendering beacon: on Windows a minimized game keeps ticking _process + ## while presentation stops, so frames_drawn stagnation — not loop + ## silence — is the observable freeze signal there (#794 smoke, 1b). + var frames_now := Engine.get_frames_drawn() + if frames_now != _last_frames_drawn_seen: + _last_frames_drawn_seen = frames_now + _last_frames_advance_msec = _last_loop_tick_msec + ## Drain the logger queue on the main thread (Logger virtuals can fire + ## from any thread; EngineDebugger.send_message is only safe from main). + ## Send at most one FLUSH_BATCH_LIMIT-sized batch per frame so a runaway + ## print loop can't stall the game by shoving thousands of entries + ## through the debugger packet path in a single tick. Surplus stays in + ## `_pending_outbound` and bleeds out across subsequent frames. + if not _logger_attached or _logger == null: + return + if not EngineDebugger.is_active(): + return + if _pending_outbound.is_empty(): + if not _logger.has_pending(): + return + _pending_outbound = _logger.drain() + var batch := _pending_outbound.slice(0, FLUSH_BATCH_LIMIT) + _pending_outbound = _pending_outbound.slice(FLUSH_BATCH_LIMIT) + EngineDebugger.send_message("mcp:log_batch", [batch]) + + +func _exit_tree() -> void: + if _registered: + EngineDebugger.unregister_message_capture(CAPTURE_PREFIX) + _registered = false + if _logger_attached and _logger != null: + OS.remove_logger(_logger) + _logger_attached = false + _logger = null + + +## Dispatched for messages prefixed "mcp:" on the debugger channel. +## Godot passes the full message ("mcp:take_screenshot") to the capture +## callable; trim defensively so tests can still call the helper with either +## form. +func _on_debug_message(message: String, data: Array) -> bool: + var action := message.trim_prefix("mcp:") + match action: + "take_screenshot": + _handle_take_screenshot(data) + return true + "eval_liveness": + _reply_eval_liveness(data) + return true + "debug_status": + _reply_debug_status(data) + return true + "eval": + _handle_eval(data) + return true + "eval_check": + _handle_eval_check(data) + return true + "game_command": + _handle_game_command(data) + return true + return false + + +## #859: answer from the debugger capture without awaiting a game frame. The +## capture can still run while a backgrounded/frozen game's main loop cannot, +## so the _process beacon is the liveness signal that matters for game_eval. +func _reply_eval_liveness(data: Array) -> void: + var request_id: String = data[0] if data.size() > 0 else "" + var loop_live := not _main_loop_appears_stalled() + _last_eval_liveness_reply = {"request_id": request_id, "loop_live": loop_live} + if EngineDebugger.is_active(): + EngineDebugger.send_message("mcp:eval_liveness_response", [request_id, loop_live]) + + +func _debug_status_snapshot() -> Dictionary: + var inside_tree := is_inside_tree() + var tree := get_tree() if inside_tree else null + return { + "probe_version": 1, + "helper_found": true, + ## GameHelper is PROCESS_MODE_ALWAYS, so pause does not stop it; native + ## SceneTree suspension does. This exposes Godot's debugger-owned suspend + ## bit through Node::can_process() without conflating it with pause/time scale. + "suspended": inside_tree and not can_process(), + "loop_live": not _main_loop_appears_stalled(), + "loop_tick_msec": _last_loop_tick_msec, + "process_ticks": _mcp_runtime_process_ticks, + "tree_paused": tree.paused if tree != null else false, + "frames_drawn": Engine.get_frames_drawn(), + "process_frames": Engine.get_process_frames(), + "physics_frames": Engine.get_physics_frames(), + "time_scale": Engine.time_scale, + } + + +func _reply_debug_status(data: Array) -> void: + var request_id: String = data[0] if data.size() > 0 else "" + var state := _debug_status_snapshot() + _last_debug_status_reply = {"request_id": request_id, "state": state.duplicate(true)} + if EngineDebugger.is_active(): + EngineDebugger.send_message("mcp:debug_status_response", [request_id, state]) + + +func _handle_take_screenshot(data: Array) -> void: + var request_id: String = data[0] if data.size() > 0 else "" + var max_resolution: int = int(data[1]) if data.size() > 1 else 0 + + var tree := get_tree() + var viewport := tree.root if tree != null else null + if viewport == null: + _reply_error(request_id, "No game root viewport available") + return + + ## #777: this function runs inside the debugger message capture, which + ## stays live even when a backgrounded/minimized play-in-editor game has + ## frozen its main loop. In that state awaiting `process_frame` parks + ## this coroutine forever — no reply is ever sent, and the game side + ## cannot self-timeout because timers need the same frozen loop. Commit a + ## synchronous capture of the last rendered frame instead: stale, but a + ## real image, flagged as such in the reply. Only fall through to the + ## fresh-frame awaits when the loop is demonstrably alive. + if _should_capture_stale_sync( + _main_loop_appears_stalled(), + _rendering_appears_stalled(), + tree.current_scene != null, + Engine.get_frames_drawn() + ): + _capture_and_reply(request_id, viewport, max_resolution, Engine.get_frames_drawn()) + return + + ## Wait (bounded — see FIRST_FRAME_WAIT_SEC) until the main scene is in + ## the tree and at least one frame has been drawn after this request, so + ## the readback never precedes the first real present. Past the deadline, + ## fall through anyway: current_scene stays null under a custom main + ## loop, and frames_drawn never advances in a render-less game — both + ## are handled by the texture/image error replies below. + var deadline := Time.get_ticks_msec() + int(FIRST_FRAME_WAIT_SEC * 1000.0) + while tree.current_scene == null and Time.get_ticks_msec() < deadline: + await tree.process_frame + var frames_at_request := Engine.get_frames_drawn() + while Engine.get_frames_drawn() <= frames_at_request and Time.get_ticks_msec() < deadline: + await tree.process_frame + + _capture_and_reply(request_id, viewport, max_resolution, frames_at_request) + + +## #777: pure decision for the synchronous stale-frame path. Sync capture is +## only worth committing when awaiting can't produce a fresh frame — the main +## loop is frozen (macOS/suspend), or the loop still ticks but presentation +## is suppressed (Windows minimize, #794 smoke 1b) — AND the viewport +## plausibly holds a real frame: the main scene is in the tree and at least +## one frame was presented. Without those, the stale readback would be the +## boot clear-color framebuffer — worse than the honest timeout. +static func _should_capture_stale_sync( + loop_stalled: bool, render_stalled: bool, has_current_scene: bool, frames_drawn: int +) -> bool: + return (loop_stalled or render_stalled) and has_current_scene and frames_drawn > 0 + + +## #777: true when _process hasn't ticked within MAIN_LOOP_STALL_MSEC — +## i.e. the main loop is frozen (backgrounded window) or has never run. +func _main_loop_appears_stalled() -> bool: + ## The hello→probe round trip normally follows at least one _process tick. + ## Treat the never-ticked sentinel as stalled defensively rather than + ## dispatching an eval into a game whose main loop has not started. + if _last_loop_tick_msec < 0: + return true + return Time.get_ticks_msec() - _last_loop_tick_msec > MAIN_LOOP_STALL_MSEC + + +## True when frames_drawn has sat flat past RENDER_STALL_MSEC while _process +## kept ticking — Windows minimize suppresses presentation without freezing +## the loop, so the loop beacon alone misses it (#794 smoke, 1b). False until +## the first observed frame advance: a game that has never presented has no +## trustworthy frame to return, and must fall through to the await path's +## texture/image error replies instead. +func _rendering_appears_stalled() -> bool: + if _last_frames_advance_msec < 0: + return false + return Time.get_ticks_msec() - _last_frames_advance_msec > RENDER_STALL_MSEC + + +## Read back the viewport texture and reply — fully synchronous, so it is +## safe to call from the debugger capture while the main loop is frozen. +## `frames_at_request` is Engine.get_frames_drawn() at request receipt: if no +## further frame was drawn by capture time, the image predates the request +## and the reply is flagged stale. +func _capture_and_reply( + request_id: String, viewport: Viewport, max_resolution: int, frames_at_request: int +) -> void: + var texture := viewport.get_texture() + if texture == null: + _reply_error(request_id, "Root viewport has no texture (headless?)") + return + + var image := texture.get_image() + if image == null or image.is_empty(): + _reply_error(request_id, "Captured an empty image from game viewport") + return + + var encoded: Dictionary = ScreenshotEncode.downscale_and_encode(image, max_resolution) + var frames_drawn := Engine.get_frames_drawn() + var stale := frames_drawn <= frames_at_request + + _last_screenshot_reply = { + "kind": "response", + "request_id": request_id, + "frames_drawn": frames_drawn, + "stale": stale, + "width": encoded.width, + "height": encoded.height, + } + if EngineDebugger.is_active(): + ## Fields 7+8 are new in #777; older editors read the first six and + ## ignore the rest. + EngineDebugger.send_message("mcp:screenshot_response", [ + request_id, + encoded.base64, + encoded.width, + encoded.height, + encoded.original_width, + encoded.original_height, + frames_drawn, + stale, + ]) + + +## Testing seam: the last screenshot reply (response or error), recorded +## before hitting the EngineDebugger channel (inactive in the editor-side +## test harness). Mirrors _last_eval_reply. +var _last_screenshot_reply: Dictionary = {} + + +func _reply_error(request_id: String, message: String) -> void: + _last_screenshot_reply = {"kind": "error", "request_id": request_id, "message": message} + if EngineDebugger.is_active(): + EngineDebugger.send_message("mcp:screenshot_error", [request_id, message]) + + +## --- game_command: curated runtime inspection and input --- + +func _handle_game_command(data: Array) -> void: + var request_id: String = data[0] if data.size() > 0 else "" + var op: String = data[1] if data.size() > 1 else "" + var params_json: String = data[2] if data.size() > 2 else "{}" + + if request_id.is_empty(): + return + if op.is_empty(): + _reply_game_command_error(request_id, "No op provided") + return + + var json := JSON.new() + var parse_err := json.parse(params_json) + if parse_err != OK or not (json.data is Dictionary): + _reply_game_command_error(request_id, "Invalid params JSON") + return + + var result: Dictionary + match op: + "get_scene_tree": + result = _game_get_scene_tree(json.data) + "get_node_info": + result = _game_get_node_info(json.data) + "get_ui_elements": + result = _game_get_ui_elements(json.data) + "input_key": + result = _game_input_key(json.data) + "input_mouse": + result = _game_input_mouse(json.data) + "input_gamepad": + result = _game_input_gamepad(json.data) + "input_action": + result = _game_input_action(json.data) + "input_state": + result = _game_input_state(json.data) + "input_sequence": + ## Async: steps frames and replies itself (deferred), so bail out + ## before the synchronous send below — same shape as the eval and + ## screenshot capture paths. + _run_input_sequence(request_id, json.data) + return + _: + _reply_game_command_error(request_id, "Unknown game op: %s" % op) + return + + result["source"] = "game" + result["op"] = op + EngineDebugger.send_message("mcp:game_command_response", + [request_id, JSON.stringify(_variant_to_json(result))]) + + +func _reply_game_command_error(request_id: String, message: String) -> void: + EngineDebugger.send_message("mcp:game_command_error", [request_id, message]) + + +func _game_get_scene_tree(params: Dictionary) -> Dictionary: + var depth := maxi(0, int(params.get("depth", 10))) + var root := _resolve_runtime_node(str(params.get("root_path", ""))) + if root == null: + return {"root": "", "nodes": [], "total_count": 0, "not_found": params.get("root_path", "")} + + var nodes: Array[Dictionary] = [] + _collect_runtime_nodes(root, 0, depth, nodes) + return { + "root": _runtime_path(root), + "nodes": nodes, + "total_count": nodes.size(), + } + + +func _collect_runtime_nodes(node: Node, current_depth: int, max_depth: int, out: Array[Dictionary]) -> void: + out.append({ + "name": node.name, + "type": node.get_class(), + "path": _runtime_path(node), + "children_count": node.get_child_count(), + }) + if current_depth >= max_depth: + return + for child in node.get_children(): + if child is Node: + _collect_runtime_nodes(child, current_depth + 1, max_depth, out) + + +func _game_get_node_info(params: Dictionary) -> Dictionary: + var path := str(params.get("path", "")) + var node := _resolve_runtime_node(path) + if node == null: + return {"path": path, "found": false} + + var info := { + "path": _runtime_path(node), + "name": node.name, + "type": node.get_class(), + "children_count": node.get_child_count(), + "groups": node.get_groups(), + "found": true, + } + if bool(params.get("include_properties", true)): + info["properties"] = _runtime_node_properties(node) + return info + + +func _game_get_ui_elements(params: Dictionary) -> Dictionary: + var max_depth := maxi(0, int(params.get("max_depth", 10))) + var include_hidden := bool(params.get("include_hidden", false)) + var include_disabled := bool(params.get("include_disabled", true)) + var root_path := str(params.get("root_path", "")) + var root := _resolve_runtime_node(root_path) + if root == null: + return {"root": "", "elements": [], "total_count": 0, "not_found": root_path} + + var elements: Array[Dictionary] = [] + _collect_ui_elements(root, 0, max_depth, include_hidden, include_disabled, elements) + return { + "root": _runtime_path(root), + "elements": elements, + "total_count": elements.size(), + } + + +func _collect_ui_elements( + node: Node, + current_depth: int, + max_depth: int, + include_hidden: bool, + include_disabled: bool, + out: Array[Dictionary] +) -> void: + if node is Control: + var control := node as Control + var visible := _control_visible_in_tree(control) + var disabled := _control_disabled(control) + if (include_hidden or visible) and (include_disabled or not disabled): + out.append(_ui_element_info(control, visible, disabled)) + + if current_depth >= max_depth: + return + for child in node.get_children(): + if child is Node: + _collect_ui_elements( + child, + current_depth + 1, + max_depth, + include_hidden, + include_disabled, + out + ) + + +func _ui_element_info(control: Control, visible: bool, disabled: bool) -> Dictionary: + var info := { + "path": _runtime_path(control), + "name": control.name, + "type": control.get_class(), + "visible": visible, + "disabled": disabled, + "rect": _variant_to_json(control.get_rect()), + "global_rect": _variant_to_json(control.get_global_rect()), + } + if _object_has_property(control, "text"): + info["text"] = str(control.get("text")) + return info + + +func _control_disabled(control: Control) -> bool: + if _object_has_property(control, "disabled"): + return bool(control.get("disabled")) + return false + + +func _control_visible_in_tree(control: Control) -> bool: + if not control.visible: + return false + var parent := control.get_parent() + while parent != null: + if parent is CanvasItem and not (parent as CanvasItem).visible: + return false + parent = parent.get_parent() + if Engine.is_editor_hint(): + return true + return control.is_visible_in_tree() + + +static var _property_name_cache: Dictionary = {} + + +func _object_has_property(obj: Object, property_name: String) -> bool: + var key := _property_cache_key(obj) + if not _property_name_cache.has(key): + var names := {} + for prop in obj.get_property_list(): + names[str(prop.get("name", ""))] = true + _property_name_cache[key] = names + return (_property_name_cache[key] as Dictionary).has(property_name) + + +func _property_cache_key(obj: Object) -> String: + var script = obj.get_script() + if script == null: + return obj.get_class() + var script_id := str(script.get_instance_id()) + if not script.resource_path.is_empty(): + script_id = script.resource_path + return "%s:%s" % [obj.get_class(), script_id] + + +func _runtime_node_properties(node: Node) -> Dictionary: + var props := {} + for p in node.get_property_list(): + var name := str(p.get("name", "")) + var usage := int(p.get("usage", 0)) + if name.is_empty() or (usage & PROPERTY_USAGE_EDITOR) == 0: + continue + props[name] = _variant_to_json(node.get(name)) + return props + + +func _resolve_runtime_node(path: String) -> Node: + var scene_root := _current_scene_root() + if scene_root == null: + return null + if path.is_empty() or path == "/": + return scene_root + + if path.begins_with("/root/"): + return get_tree().root.get_node_or_null(path.trim_prefix("/root/")) + + var scene_path := path.trim_prefix("/") + if scene_path == str(scene_root.name): + return scene_root + var prefix := str(scene_root.name) + "/" + if scene_path.begins_with(prefix): + scene_path = scene_path.substr(prefix.length()) + return scene_root.get_node_or_null(scene_path) + + +func _runtime_path(node: Node) -> String: + var scene_root := _current_scene_root() + if scene_root == null: + return str(node.get_path()) + if node == scene_root: + return "/" + str(scene_root.name) + return "/" + str(scene_root.name) + "/" + str(scene_root.get_path_to(node)) + + +func _current_scene_root() -> Node: + var tree := get_tree() + if tree == null: + return null + var scene_root := tree.current_scene + if scene_root == null and Engine.is_editor_hint(): + # Look the editor singleton up by name rather than referencing the bare + # `EditorInterface` identifier: that identifier is compiled out of export + # templates, so the GDScript parser rejects it ("Identifier + # "EditorInterface" not declared in the current scope") in an exported + # build even though `Engine.is_editor_hint()` would never run it there. + # That parse failure stops this autoload from loading in every export. + var editor := Engine.get_singleton(&"EditorInterface") + if editor: + scene_root = editor.get_edited_scene_root() + return scene_root + + +func _game_input_key(params: Dictionary) -> Dictionary: + var key_name := str(params.get("key", "")) + var keycode := OS.find_keycode_from_string(key_name) + if keycode == KEY_NONE: + return {"sent": false, "error": "Unknown key: %s" % key_name} + var ev := InputEventKey.new() + ev.keycode = keycode + ev.physical_keycode = keycode + ev.pressed = bool(params.get("pressed", true)) + ev.echo = bool(params.get("echo", false)) + Input.parse_input_event(ev) + return {"sent": true, "key": key_name, "pressed": ev.pressed} + + +func _game_input_mouse(params: Dictionary) -> Dictionary: + var event := str(params.get("event", "button")) + var pos_result := _resolve_mouse_position(params.get("position")) + if pos_result.has("error"): + return {"sent": false, "event": event, "error": pos_result.error} + var pos: Vector2 = pos_result.position + match event: + "motion": + var motion := InputEventMouseMotion.new() + motion.position = pos + motion.global_position = pos + Input.parse_input_event(motion) + return {"sent": true, "event": "motion", "position": _variant_to_json(pos)} + "button": + var button_event := InputEventMouseButton.new() + button_event.position = pos + button_event.global_position = pos + button_event.button_index = _mouse_button_index(str(params.get("button", "left"))) + button_event.pressed = bool(params.get("pressed", true)) + Input.parse_input_event(button_event) + return { + "sent": true, + "event": "button", + "button": params.get("button", "left"), + "pressed": button_event.pressed, + "position": _variant_to_json(pos), + } + return {"sent": false, "error": "Invalid mouse event: %s" % event} + + +func _game_input_gamepad(params: Dictionary) -> Dictionary: + var device := int(params.get("device", 0)) + var control := str(params.get("control", "button")) + match control: + "button": + var button := InputEventJoypadButton.new() + button.device = device + button.button_index = int(params.get("index", 0)) + button.pressed = bool(params.get("pressed", true)) + Input.parse_input_event(button) + return {"sent": true, "control": "button", "device": device, "index": button.button_index, "pressed": button.pressed} + "axis": + var axis := InputEventJoypadMotion.new() + axis.device = device + axis.axis = int(params.get("index", 0)) + axis.axis_value = float(params.get("value", 0.0)) + Input.parse_input_event(axis) + return {"sent": true, "control": "axis", "device": device, "index": axis.axis, "value": axis.axis_value} + return {"sent": false, "error": "Invalid gamepad control: %s" % control} + + +func _game_input_action(params: Dictionary) -> Dictionary: + var action := str(params.get("action", "")) + if action.is_empty(): + return {"sent": false, "error": "Missing action"} + if not InputMap.has_action(action): + return {"sent": false, "action": action, "error": "Unknown action: %s" % action} + var pressed := bool(params.get("pressed", true)) + var strength := clampf(float(params.get("strength", 1.0)), 0.0, 1.0) + if pressed: + Input.action_press(action, strength) + else: + Input.action_release(action) + return { + "sent": true, + "action": action, + "pressed": pressed, + "strength": strength, + "delivery": "action_state", + } + + +func _game_input_state(params: Dictionary) -> Dictionary: + var actions: Array = params.get("actions", []) + if actions.is_empty(): + actions = InputMap.get_actions() + var states := {} + for action in actions: + var name := str(action) + states[name] = Input.is_action_pressed(name) + return {"actions": states} + + +## --- input_sequence: frame-timed action timeline (deferred) --- +## +## Per-step round-trips can't hit a target frame — network jitter lands each +## input on whatever frame its reply happens to arrive on, so a jump arc or a +## timed combo is unreproducible (#814). input_sequence takes the whole +## timeline in one call and drives it game-side, applying each step's action on +## its scheduled frame, then replies once (deferred). Frame count (not ms) is +## the timing basis: it's what reproduces identically across runs. + +## Hard caps mirrored by the server-side schema (see game handlers). The game +## side re-checks them so a malformed direct message can't park the coroutine +## on an unbounded await; the server rejects the same cases up front with a +## clearer error. +## +## The frame cap bounds the sequence in *frames*, which is a wall-clock time +## only at a given FPS: 600 frames is ~10s at 60fps but longer under load or on +## a throttled runner. It is not sized to the ~30s deferred budget +## (editor_handler.INPUT_SEQUENCE_TIMEOUT_SEC) — the two are independent +## safeguards. If a genuinely slow run exceeds the budget, the dispatcher +## returns a clean DEFERRED_TIMEOUT rather than hanging, so the cap can stay a +## simple frame count. +const MAX_SEQUENCE_STEPS := 256 +const MAX_SEQUENCE_FRAMES := 600 + +## Testing seam: the last input_sequence reply (response or error), recorded +## before the EngineDebugger channel (inactive in the editor-side test +## harness). Mirrors _last_screenshot_reply / _last_eval_reply. +var _last_game_command_reply: Dictionary = {} + +## Testing seam: overrides the per-frame wait in _run_input_sequence. Left +## invalid in production (real `process_frame` awaits). A test sets it to a +## synchronously-returning Callable so the multi-frame loop runs to completion +## in one call — the editor test runner invokes tests synchronously and never +## pumps `process_frame`, so a real frame-await would suspend and record zero +## assertions. Timing itself (one frame per step) is engine-guaranteed; this +## seam covers the scheduling/application/reply logic layered on top. +var _frame_waiter: Callable = Callable() + + +## Validate + normalize an input_sequence request. Pure (no engine state), so +## the ordering/cap/shape rules are unit-testable without a running game. +## Returns {"error": String} or {"steps": Array, "end_frame": int}. +func _plan_input_sequence(params: Dictionary) -> Dictionary: + var raw_steps: Variant = params.get("steps", null) + if not (raw_steps is Array): + return {"error": "steps must be an array"} + var steps_arr: Array = raw_steps + if steps_arr.is_empty(): + return {"error": "steps must not be empty"} + if steps_arr.size() > MAX_SEQUENCE_STEPS: + return {"error": "steps exceeds cap of %d (got %d)" % [MAX_SEQUENCE_STEPS, steps_arr.size()]} + + ## Validate field *kinds* rather than coercing them: the server already + ## rejects bad shapes, but this planner is also the backstop for a + ## malformed direct debugger message, so it must not silently turn + ## pressed="false" into true or at_frame="oops" into 0. _is_number accepts + ## int or float (JSON round-trips whole numbers as either) but not bool or + ## string, so this stays consistent with the server without tripping on + ## JSON's number typing. + var settle_raw: Variant = params.get("settle_frames", 0) + if not _is_number(settle_raw): + return {"error": "settle_frames must be a number"} + var settle_frames := int(settle_raw) + if settle_frames < 0: + return {"error": "settle_frames must be >= 0"} + + var normalized: Array = [] + var prev_frame := -1 + for i in steps_arr.size(): + var raw: Variant = steps_arr[i] + if not (raw is Dictionary): + return {"error": "steps[%d] must be an object" % i} + var step: Dictionary = raw + if not (step.get("action", "") is String) or str(step.get("action", "")).is_empty(): + return {"error": "steps[%d].action is required" % i} + var action: String = step["action"] + var at_frame_raw: Variant = step.get("at_frame", 0) + if not _is_number(at_frame_raw): + return {"error": "steps[%d].at_frame must be a number" % i} + var at_frame := int(at_frame_raw) + if at_frame < 0: + return {"error": "steps[%d].at_frame must be >= 0" % i} + if at_frame < prev_frame: + return {"error": "steps must be ordered by at_frame (steps[%d]=%d < previous %d)" % [i, at_frame, prev_frame]} + prev_frame = at_frame + var pressed_raw: Variant = step.get("pressed", true) + if not (pressed_raw is bool): + return {"error": "steps[%d].pressed must be a boolean" % i} + var strength_raw: Variant = step.get("strength", 1.0) + if not _is_number(strength_raw): + return {"error": "steps[%d].strength must be a number" % i} + normalized.append({ + "at_frame": at_frame, + "action": action, + "pressed": pressed_raw, + "strength": clampf(float(strength_raw), 0.0, 1.0), + }) + + var end_frame: int = int(normalized[-1]["at_frame"]) + settle_frames + if end_frame > MAX_SEQUENCE_FRAMES: + return {"error": "sequence spans %d frames, exceeds cap of %d" % [end_frame, MAX_SEQUENCE_FRAMES]} + return {"steps": normalized, "end_frame": end_frame} + + +## Async: apply each step's action on its scheduled frame, awaiting one +## process_frame per frame, then reply (deferred). Bails out before applying +## anything if the plan is invalid or any action is unknown to the running +## game's InputMap — a half-applied timeline leaves inputs in an undefined +## state, so it's all-or-nothing on the pre-checks. +func _run_input_sequence(request_id: String, params: Dictionary) -> void: + var plan := _plan_input_sequence(params) + if plan.has("error"): + _reply_input_sequence_error(request_id, plan["error"]) + return + + var steps: Array = plan["steps"] + var end_frame: int = plan["end_frame"] + + ## Resolve action names against the *game's* InputMap up front — the server + ## can't see it, so this is the first place unknown actions surface. + for step in steps: + if not InputMap.has_action(step["action"]): + _reply_input_sequence_error(request_id, "Unknown action: %s" % step["action"]) + return + + var tree := get_tree() + if tree == null: + _reply_input_sequence_error(request_id, "No SceneTree available for input sequence") + return + + var applied: Array = [] + var step_i := 0 + for f in range(0, end_frame + 1): + while step_i < steps.size() and int(steps[step_i]["at_frame"]) == f: + var step: Dictionary = steps[step_i] + _game_input_action(step) + applied.append({"at_frame": f, "action": step["action"], "pressed": step["pressed"]}) + step_i += 1 + if f < end_frame: + if _frame_waiter.is_valid(): + await _frame_waiter.call() + else: + await tree.process_frame + + _reply_input_sequence_ok(request_id, { + "completed": true, + "steps_applied": applied.size(), + "frames_elapsed": end_frame, + "applied": applied, + "actions_pressed_at_end": _actions_pressed_at_end(steps), + }) + + +## Distinct actions the sequence touched that are still held at the end, so the +## caller knows what it must release (a press with no matching release leaves +## the action stuck on across the next frames). +func _actions_pressed_at_end(steps: Array) -> Array: + var seen := {} + var pressed: Array = [] + for step in steps: + var action: String = step["action"] + if seen.has(action): + continue + seen[action] = true + if Input.is_action_pressed(action): + pressed.append(action) + return pressed + + +func _reply_input_sequence_ok(request_id: String, result: Dictionary) -> void: + result["source"] = "game" + result["op"] = "input_sequence" + _last_game_command_reply = {"kind": "response", "op": "input_sequence", "result": result} + if EngineDebugger.is_active(): + EngineDebugger.send_message("mcp:game_command_response", + [request_id, JSON.stringify(_variant_to_json(result))]) + + +func _reply_input_sequence_error(request_id: String, message: String) -> void: + _last_game_command_reply = {"kind": "error", "op": "input_sequence", "message": message} + if EngineDebugger.is_active(): + EngineDebugger.send_message("mcp:game_command_error", [request_id, message]) + + +## Resolve a mouse-position param. Absent (null, or an empty {}) falls back to +## the live cursor position — a deliberate default. A present but wrong-shaped +## value is rejected instead of silently substituting the cursor, which +## previously hid caller bugs (#635). Accepts a {x, y} dict or an [x, y] array; +## returns {position: Vector2} or {error: String}. +func _resolve_mouse_position(value: Variant) -> Dictionary: + var viewport := get_viewport() + var fallback := viewport.get_mouse_position() if viewport != null else Vector2.ZERO + if value == null: + return {"position": fallback} + if value is Dictionary: + var dict: Dictionary = value + if dict.is_empty(): + return {"position": fallback} + # A non-empty dict that carries neither coordinate is a caller mistake, + # not "use the default" — reject rather than silently substitute. + if not dict.has("x") and not dict.has("y"): + return {"error": "position object must have an 'x' and/or 'y' key (got keys %s)" % str(dict.keys())} + var x_val: Variant = dict.get("x", fallback.x) + var y_val: Variant = dict.get("y", fallback.y) + if not _is_number(x_val) or not _is_number(y_val): + return {"error": "position x/y must be numbers (got x=%s, y=%s)" % [type_string(typeof(x_val)), type_string(typeof(y_val))]} + return {"position": Vector2(float(x_val), float(y_val))} + if value is Array: + var arr: Array = value + if arr.size() != 2: + return {"error": "position array must be [x, y] (got %d elements)" % arr.size()} + if not _is_number(arr[0]) or not _is_number(arr[1]): + return {"error": "position array elements must be numbers (got [%s, %s])" % [type_string(typeof(arr[0])), type_string(typeof(arr[1]))]} + return {"position": Vector2(float(arr[0]), float(arr[1]))} + return {"error": "position must be a {x, y} object or [x, y] array (got %s)" % type_string(typeof(value))} + + +func _is_number(v: Variant) -> bool: + return typeof(v) == TYPE_INT or typeof(v) == TYPE_FLOAT + + +func _mouse_button_index(name: String) -> int: + match name: + "right": + return MOUSE_BUTTON_RIGHT + "middle": + return MOUSE_BUTTON_MIDDLE + "wheel_up": + return MOUSE_BUTTON_WHEEL_UP + "wheel_down": + return MOUSE_BUTTON_WHEEL_DOWN + return MOUSE_BUTTON_LEFT + + +## --- game_eval: execute arbitrary GDScript in the running game --- + +## Wall-clock ceiling for a single game_eval. Evaluated code that awaits +## something which never completes (a signal that never fires, a timer on a +## paused tree) would otherwise pin the request open until the dispatcher's +## 15s deferred budget / the server's 15s command timeout fires it as an +## opaque INTERNAL_ERROR — with the temp eval Node leaked into the tree. +## Bounding it here lets us free the node and reply with an actionable +## message instead. See hi-godot/godot-ai#487. +## +## TIMEOUT ORDERING — load-bearing across three files: this value MUST stay +## below the editor-side fallback timer in +## `debugger/mcp_debugger_plugin.gd::request_game_eval` (`timeout_sec`, +## default 10.0), which in turn stays below the dispatcher's `game_eval` +## budget in `dispatcher.gd` (15000 ms). So: game 8s < editor 10s < +## dispatcher 15s. Only this game-side guard emits the specific +## "Eval exceeded 8s" message (both it and the editor backstop now carry the +## EVAL_HUNG code, #518, but the editor's message can't name the cause). +## Raise this at/above the editor timer (or drop that timer below this) and +## the less specific editor message wins the race, silently losing the +## diagnostic this fix exists to provide. The cross-file contract is enforced +## by tests/unit/test_game_eval_timeout_ordering.py. +## +## NOTE: this catches a hung `await`, not a CPU-bound loop with no `await` — +## a tight `while true:` with no yield blocks the main thread, so nothing +## (including this poll) runs until it yields. That case is out of scope. +const EVAL_TIMEOUT_SEC := 8.0 + + +func _handle_eval(data: Array) -> void: + var request_id: String = data[0] if data.size() > 0 else "" + var code: String = data[1] if data.size() > 1 else "" + + if code.is_empty(): + _reply_eval_error(request_id, "No code provided") + return + + ## Wrap user code in an execute() coroutine (so it can `await` internally) + ## whose inner function is uniquely named per eval. A runtime error's + ## backtrace then carries `_mcp_run_`, letting us attribute it to + ## THIS eval — not an unrelated background game error, and not a sibling + ## overlapping eval. (#490) + _eval_token_counter += 1 + var token := str(_eval_token_counter) + var run_fn := "_mcp_run_%s" % token + var script_source := ( + "extends Node\n" + + "func execute():\n" + + "\treturn await %s()\n\n" % run_fn + + "func %s():\n" % run_fn + + _indent_eval_code(code) + ) + + ## Snapshot the logger's script-error seq BEFORE running so we only attribute + ## errors raised by this eval. In a debug build a parse error aborts reload() + ## and a runtime error aborts execute() — either way this function may never + ## reach its reply: the editor infers a compile error from the missing + ## mcp:eval_compiled beacon, and a runtime error is reported (via the + ## eval_check probe / the in-flight poll loop) once a logged error past this + ## baseline carries this eval's token. + var baseline: int = _logger.script_error_seq() if _logger != null else 0 + + var script: GDScript = GDScript.new() + script.source_code = script_source + ## #490: ack BEFORE reload(). A parse error aborts this function at reload() + ## without a return code in a debug build, so this is our only chance to tell + ## the editor "received + about to compile." The editor uses that to tell a + ## real parse error (acked, never compiled) apart from a message it simply + ## hasn't serviced yet (never acked); see mcp_debugger_plugin._on_eval_grace. + EngineDebugger.send_message("mcp:eval_ack", [request_id]) + ## reload() ABORTS this function on a parse error in a debug build (it does + ## not return a non-OK code there), so the lines below only run when the + ## source compiled. Keep reload() INLINE — moving it behind a timer/await + ## poisons subsequent evals (#490). The err branch still matters for the + ## editor process (handler unit tests), where reload() does return. + var err: int = script.reload() + if err != OK: + _reply_eval_error(request_id, + "Failed to compile GDScript (error %d). Check syntax." % err) + return + + ## Compiled OK — tell the editor so its grace timer doesn't flag a compile + ## error and so it begins probing for a runtime error. + EngineDebugger.send_message("mcp:eval_compiled", [request_id]) + + var temp_node := Node.new() + temp_node.set_script(script) + temp_node.process_mode = Node.PROCESS_MODE_ALWAYS + add_child(temp_node) + + if not temp_node.has_method("execute"): + temp_node.queue_free() + _reply_eval_error(request_id, "Internal error: eval wrapper is missing execute().") + return + + ## Register in-flight BEFORE running: a runtime error aborts execute() (and + ## may unwind this function) before we could record it afterward, and the + ## editor probe / poll loop need the entry to attribute and report the error. + _inflight_evals[request_id] = {"node": temp_node, "token": token, "baseline": baseline} + + ## Drive execute() as a fire-and-forget coroutine that records its outcome + ## into `holder`, then poll frames until it finishes or the deadline passes + ## (#488's hung-await guard). A plain `await temp_node.execute()` has no + ## escape hatch: if user code never returns, we never reach the reply/cleanup + ## below and the request hangs with the node leaked. + var holder := {"done": false, "value": null, "abandoned": false} + _drive_eval(temp_node, holder) + + var tree := get_tree() + var deadline_ms := int(EVAL_TIMEOUT_SEC * 1000.0) + var start_ms := Time.get_ticks_msec() + while not holder["done"] and (Time.get_ticks_msec() - start_ms) < deadline_ms: + ## #490 focused fast path: a runtime error aborts _drive_eval (holder + ## never completes), so check each frame whether THIS eval's token now + ## appears in a logged error and report it immediately. (Backgrounded, + ## this loop is frozen and the editor probe does the same job.) + if _try_report_eval_runtime_error(request_id): + holder["abandoned"] = true + return + await tree.process_frame + + if not holder["done"]: + ## Past the 8s deadline. Disambiguate a runtime error (its token is in a + ## logged error) from a genuine hung await before the generic timeout. + holder["abandoned"] = true + if _try_report_eval_runtime_error(request_id): + return + _inflight_evals.erase(request_id) + if is_instance_valid(temp_node): + remove_child(temp_node) + _reply_eval_error(request_id, + ("Eval exceeded %ds and was aborted — the code likely awaits " + + "something that never completes (a signal that never fires, a timer on " + + "a paused tree) or loops forever. Check logs_read(source='game').") + % int(EVAL_TIMEOUT_SEC), + ErrorCodes.EVAL_HUNG) + return + + ## Clean finish. + _inflight_evals.erase(request_id) + temp_node.queue_free() + _reply_eval_response(request_id, holder["value"]) + + +## Run the compiled eval node's execute() and stash the result. Kept +## separate from _handle_eval so the latter can race it against a deadline +## via frame polling. If the eval was abandoned (timed out) before this +## resumes, drop the result and free the now-detached node — _handle_eval +## has already replied. +## +## RESIDUAL LEAK (accepted): if the awaited thing *never* fires, this +## coroutine never resumes, so the `node` it holds is detached (via +## _handle_eval's remove_child) but never freed — one orphaned Node per such +## timeout, for the game-process lifetime. GDScript has no way to cancel a +## suspended coroutine, so this is the best achievable in-process. It is still +## strictly better than the pre-#487 behavior, where the node leaked *into* +## the live tree and the request hung to the 15s ceiling. +func _drive_eval(node: Node, holder: Dictionary) -> void: + var value = await node.execute() + if holder.get("abandoned", false): + if is_instance_valid(node): + node.queue_free() + return + holder["value"] = value + holder["done"] = true + + +## #518: cap on the serialized eval result. Godot's remote-debugger TCP peer +## silently discards any single message over ~8 MiB, so a bigger reply never +## reaches the editor and the request rides to the 10s backstop as a phantom +## "hang". (Results over the editor↔server WebSocket buffer cap of 4 MiB fail +## there with their own explicit error; this game-side cap only needs to stay +## under the debugger peer's drop threshold to keep the failure visible.) +const EVAL_RESULT_MAX_BYTES := 6 * 1024 * 1024 + +## Testing seam: the last eval reply, recorded before hitting the +## EngineDebugger channel (inactive in the editor-side test harness). +var _last_eval_reply: Dictionary = {} + + +## `code` (optional) rides as a third payload element so the editor can map +## the reply to a specific error code instead of the generic INTERNAL_ERROR; +## the editor allowlists the value (see mcp_debugger_plugin._on_eval_error). +func _reply_eval_error(request_id: String, message: String, code: String = "") -> void: + _last_eval_reply = {"kind": "error", "request_id": request_id, + "message": message, "code": code} + var payload := [request_id, message] + if not code.is_empty(): + payload.append(code) + if EngineDebugger.is_active(): + EngineDebugger.send_message("mcp:eval_error", payload) + + +func _reply_eval_response(request_id: String, value: Variant) -> void: + var serialized := JSON.stringify(_variant_to_json(value)) + var serialized_bytes := serialized.to_utf8_buffer().size() + if serialized_bytes > EVAL_RESULT_MAX_BYTES: + _reply_eval_error(request_id, + ("Eval result too large to return (%d bytes serialized, limit %d). " + + "Return a smaller slice instead — e.g. counts, node paths, or a " + + "truncated substring.") % [serialized_bytes, EVAL_RESULT_MAX_BYTES], + ErrorCodes.EVAL_RESULT_TOO_LARGE) + return + _last_eval_reply = {"kind": "response", "request_id": request_id} + if EngineDebugger.is_active(): + EngineDebugger.send_message("mcp:eval_response", [request_id, serialized]) + + +## #490: if a logged script error past THIS eval's baseline carries its unique +## wrapper-function token, a runtime error aborted it before it could reply — +## report it with the real text + line. Returns true if it reported. Called +## from the editor's eval_check probe (the reliable path when a backgrounded +## game's idle loop is frozen — the debugger capture callback still runs) and +## from _handle_eval's poll loop (the focused fast path). Token + baseline +## matching means an unrelated background error, or a sibling overlapping +## eval's error, can never fail this request. +func _try_report_eval_runtime_error(request_id: String) -> bool: + if _logger == null: + return false + var entry = _inflight_evals.get(request_id) + if entry == null: + return false + var text: String = _logger.find_script_error_since( + int(entry["baseline"]), "_mcp_run_%s" % str(entry["token"])) + if text.is_empty(): + return false + _inflight_evals.erase(request_id) + var node: Node = entry["node"] + if node != null and is_instance_valid(node): + node.queue_free() + if EngineDebugger.is_active(): + EngineDebugger.send_message("mcp:eval_runtime_error", [request_id, text]) + return true + + +## #490: answer an editor eval_check probe. The editor polls this once the +## eval has compiled but not yet replied. This runs in the debugger capture +## callback, which stays live even when the backgrounded game's _process is +## frozen — so it's the reliable channel for reporting a runtime error that +## aborted the eval. Report if one is detected for this request, else stay +## silent (the editor keeps polling until the real reply or the hang timeout). +func _handle_eval_check(data: Array) -> void: + var request_id: String = data[0] if data.size() > 0 else "" + if request_id.is_empty(): + return + _try_report_eval_runtime_error(request_id) + + +func _indent_eval_code(code: String) -> String: + var lines: PackedStringArray = code.split("\n") + var out := "" + for line in lines: + out += "\t" + line + "\n" + return out + + +## Serialize any Godot Variant to a JSON-safe dictionary/array/primitive. +## Ported from godot-mcp's mcp_interaction_server.gd. +func _variant_to_json(value: Variant) -> Variant: + if value == null: + return null + if value is bool or value is int or value is float or value is String: + return value + if value is Vector2: + return {"x": value.x, "y": value.y} + if value is Vector3: + return {"x": value.x, "y": value.y, "z": value.z} + if value is Vector4: + return {"x": value.x, "y": value.y, "z": value.z, "w": value.w} + if value is Vector2i: + return {"x": value.x, "y": value.y} + if value is Vector3i: + return {"x": value.x, "y": value.y, "z": value.z} + if value is Vector4i: + return {"x": value.x, "y": value.y, "z": value.z, "w": value.w} + if value is Color: + return {"r": value.r, "g": value.g, "b": value.b, "a": value.a} + if value is Quaternion: + return {"x": value.x, "y": value.y, "z": value.z, "w": value.w} + if value is Basis: + return { + "x": _variant_to_json(value.x), + "y": _variant_to_json(value.y), + "z": _variant_to_json(value.z), + } + if value is Transform3D: + return { + "basis": _variant_to_json(value.basis), + "origin": _variant_to_json(value.origin), + } + if value is Transform2D: + return { + "x": _variant_to_json(value.x), + "y": _variant_to_json(value.y), + "origin": _variant_to_json(value.origin), + } + if value is Rect2: + return { + "position": _variant_to_json(value.position), + "size": _variant_to_json(value.size), + } + if value is Rect2i: + return { + "position": _variant_to_json(value.position), + "size": _variant_to_json(value.size), + } + if value is AABB: + return { + "position": _variant_to_json(value.position), + "size": _variant_to_json(value.size), + } + if value is NodePath or value is StringName: + return str(value) + if value is Plane: + return { + "normal": _variant_to_json(value.normal), + "d": value.d, + } + if value is Projection: + return { + "x": _variant_to_json(value.x), + "y": _variant_to_json(value.y), + "z": _variant_to_json(value.z), + "w": _variant_to_json(value.w), + } + ## Packed arrays + if value is PackedByteArray: + var arr: Array = [] + for item in value: arr.append(item) + return arr + if value is PackedInt32Array or value is PackedInt64Array: + var arr: Array = [] + for item in value: arr.append(item) + return arr + if value is PackedFloat32Array or value is PackedFloat64Array: + var arr: Array = [] + for item in value: arr.append(item) + return arr + if value is PackedStringArray: + var arr: Array = [] + for item in value: arr.append(item) + return arr + if value is PackedVector2Array: + var arr: Array = [] + for item in value: arr.append({"x": item.x, "y": item.y}) + return arr + if value is PackedVector3Array: + var arr: Array = [] + for item in value: arr.append({"x": item.x, "y": item.y, "z": item.z}) + return arr + if value is PackedVector4Array: + var arr: Array = [] + for item in value: arr.append({"x": item.x, "y": item.y, "z": item.z, "w": item.w}) + return arr + if value is PackedColorArray: + var arr: Array = [] + for item in value: arr.append({"r": item.r, "g": item.g, "b": item.b, "a": item.a}) + return arr + ## Generic arrays and dictionaries — recurse + if value is Array: + var arr: Array = [] + for item in value: + arr.append(_variant_to_json(item)) + return arr + if value is Dictionary: + var dict: Dictionary = {} + for key in value.keys(): + dict[str(key)] = _variant_to_json(value[key]) + return dict + ## Fallback: string representation + return str(value) diff --git a/addons/godot_ai/runtime/game_helper.gd.uid b/addons/godot_ai/runtime/game_helper.gd.uid 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..0dfe414 --- /dev/null +++ b/addons/godot_ai/telemetry.gd @@ -0,0 +1,224 @@ +## 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", +] + +## Runtime opt-out sent over the authenticated WebSocket (#913). Twinned +## with `transport/websocket.py::TELEMETRY_OPT_OUT_EVENT`. Outside +## `_ALLOWED_EVENTS` on purpose: that list gates events the server records, +## and this one tells it to stop. +const OPT_OUT_EVENT := "telemetry_opt_out" + +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: + ## Before the flush: a server we adopted must hear our preference + ## before it records anything else on this session. + assert_opt_out() + _flush() + + +## Tell the connected server to stop sending telemetry, if this editor is +## opted out (#913). Spawn-time env injection only reaches a server the +## plugin started; this reaches one it adopted, over the same authenticated +## WebSocket every command uses. The server latches it — off until that +## process is replaced, never back on. +## +## Reads the setting live rather than the `_disabled` snapshot so the dock +## can call this the moment the user applies a change, before the plugin +## reload swaps this instance out. Returns whether anything was sent. +func assert_opt_out() -> bool: + if McpSettings.telemetry_enabled(): + return false + if _connection == null or not _connection.is_connected: + return false + return bool(_connection.send_event(OPT_OUT_EVENT, {})) + +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..e5e7adb --- /dev/null +++ b/addons/godot_ai/testing/test_suite.gd @@ -0,0 +1,302 @@ +@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 + + +## 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..d1e8b4a --- /dev/null +++ b/addons/godot_ai/tool_catalog.gd @@ -0,0 +1,110 @@ +@tool +class_name McpToolCatalog +extends RefCounted + +## Mirror of src/godot_ai/tools/domains.py — drives the dock's Tools tab +## so the UI can render checkboxes, tool counts, and tooltips without +## round-tripping to a running server. +## +## DO NOT EDIT by hand. tests/unit/test_tool_domains.py verifies this file +## against actual tool registration and fails CI when they drift; the +## failure message prints the up-to-date catalog body for paste-over. +## +## The four core tools are always registered and cannot be excluded — they +## render as a single grayed-out "Core" row in the UI. Each non-core domain +## now exposes one or two named verbs plus a single rolled-up +## `_manage` tool. + +const CORE_TOOLS := [ + "editor_state", + "node_get_properties", + "scene_get_hierarchy", + "session_activate", +] + +## Non-core tools that live in a NON-excludable domain (only `session` +## today), so they appear in no DOMAINS row yet are always registered. +## Counted alongside CORE_TOOLS so the dock's totals match the real +## server surface. +const ALWAYS_ON_TOOLS := [ + "session_manage", +] + +## Ordered list of user-toggleable domains. Each entry: +## id: matches the name passed to `--exclude-domains` +## label: human-friendly display (same as id for now, kept separate so +## a future renaming doesn't break the setting) +## count: number of NON-CORE tools in this domain +## tools: flat list of tool names registered by this domain (non-core only) +const DOMAINS := [ + {"id": "animation", "label": "animation", "count": 2, "tools": ["animation_create", "animation_manage"]}, + {"id": "api", "label": "api", "count": 1, "tools": ["api_manage"]}, + {"id": "audio", "label": "audio", "count": 1, "tools": ["audio_manage"]}, + {"id": "autoload", "label": "autoload", "count": 1, "tools": ["autoload_manage"]}, + {"id": "batch", "label": "batch", "count": 1, "tools": ["batch_execute"]}, + {"id": "camera", "label": "camera", "count": 1, "tools": ["camera_manage"]}, + {"id": "client", "label": "client", "count": 1, "tools": ["client_manage"]}, + {"id": "custom", "label": "custom", "count": 1, "tools": ["custom_manage"]}, + {"id": "editor", "label": "editor", "count": 4, "tools": ["editor_manage", "editor_reload_plugin", "editor_screenshot", "logs_read"]}, + {"id": "filesystem", "label": "filesystem", "count": 1, "tools": ["filesystem_manage"]}, + {"id": "game", "label": "game", "count": 1, "tools": ["game_manage"]}, + {"id": "input_map", "label": "input_map", "count": 1, "tools": ["input_map_manage"]}, + {"id": "material", "label": "material", "count": 1, "tools": ["material_manage"]}, + {"id": "node", "label": "node", "count": 4, "tools": ["node_create", "node_find", "node_manage", "node_set_property"]}, + {"id": "particle", "label": "particle", "count": 1, "tools": ["particle_manage"]}, + {"id": "project", "label": "project", "count": 2, "tools": ["project_manage", "project_run"]}, + {"id": "resource", "label": "resource", "count": 1, "tools": ["resource_manage"]}, + {"id": "scene", "label": "scene", "count": 3, "tools": ["scene_manage", "scene_open", "scene_save"]}, + {"id": "script", "label": "script", "count": 4, "tools": ["script_attach", "script_create", "script_manage", "script_patch"]}, + {"id": "signal", "label": "signal", "count": 1, "tools": ["signal_manage"]}, + {"id": "testing", "label": "testing", "count": 2, "tools": ["test_manage", "test_run"]}, + {"id": "theme", "label": "theme", "count": 1, "tools": ["theme_manage"]}, + {"id": "tilemap", "label": "tilemap", "count": 1, "tools": ["tilemap_manage"]}, + {"id": "tileset", "label": "tileset", "count": 1, "tools": ["tileset_manage"]}, + {"id": "gridmap", "label": "gridmap", "count": 1, "tools": ["gridmap_manage"]}, + {"id": "csg", "label": "csg", "count": 1, "tools": ["csg_manage"]}, + {"id": "ui", "label": "ui", "count": 1, "tools": ["ui_manage"]}, +] + + +## Whether `id` is a real, excludable domain in this plugin version. Used to +## drop stale names (e.g. a domain removed since the setting was written) so +## they never reach the server's `--exclude-domains`, whose `parse_exclude_list` +## hard-fails on unknown names. +static func is_excludable_domain(id: String) -> bool: + for d in DOMAINS: + if d["id"] == id: + return true + return false + + +## Total tool count when no domains are excluded. Used for the "Enabled: N / M" +## readout in the Tools tab without looping the catalog on every repaint. +static func total_tool_count() -> int: + var n := CORE_TOOLS.size() + ALWAYS_ON_TOOLS.size() + for d in DOMAINS: + n += int(d["count"]) + return n + + +## Tool count remaining after excluding the given set of domain ids. +static func enabled_tool_count(excluded: PackedStringArray) -> int: + var n := CORE_TOOLS.size() + ALWAYS_ON_TOOLS.size() + for d in DOMAINS: + if excluded.find(d["id"]) == -1: + n += int(d["count"]) + return n + + +## Canonical comma-separated string for a set of domain ids — sorted and +## deduplicated so two equivalent settings (entered in different orders) +## hash to the same EditorSetting value. Matches `excluded_domains()` in +## client_configurator.gd. +static func canonical(excluded: PackedStringArray) -> String: + var seen := PackedStringArray() + for e in excluded: + var t := e.strip_edges() + if not t.is_empty() and seen.find(t) == -1: + seen.append(t) + seen.sort() + return ",".join(seen) diff --git a/addons/godot_ai/tool_catalog.gd.uid b/addons/godot_ai/tool_catalog.gd.uid 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/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/client_job_owner.gd b/addons/godot_ai/utils/client_job_owner.gd new file mode 100644 index 0000000..58ef803 --- /dev/null +++ b/addons/godot_ai/utils/client_job_owner.gd @@ -0,0 +1,966 @@ +@tool +extends Node + +## Plugin-lifetime owner for every background client-configuration job. +## +## The Dock is replaceable UI. Threads cannot share that lifetime: a timed-out +## probe or package pre-warm can outlive the panel that requested it, and +## destroying a live Godot Thread corrupts the GDScript VM. This node therefore +## owns both worker pools, their cancellation, phases, and one action slot per +## client until its Thread has been joined. It publishes copied value snapshots +## and outcomes; it never retains the Dock, plugin, lifecycle, or update manager. + +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 DshStrategy := preload("res://addons/godot_ai/clients/_dsh_strategy.gd") +const CliStrategy := preload("res://addons/godot_ai/clients/_cli_strategy.gd") +const RefreshState := preload("res://addons/godot_ai/utils/mcp_client_refresh_state.gd") +const MutationLock := preload("res://addons/godot_ai/utils/client_mutation_lock.gd") + +const STATUS_COOLDOWN_MSEC := 15 * 1000 +const STATUS_TIMEOUT_MSEC := 30 * 1000 +## One scoped CLI configure may run three bounded cleanup commands, one write, +## discovery, and read-back verification before it reaches the optional package +## prewarm. Keep the owner watchdog above that real mutation budget. +const ACTION_TIMEOUT_MSEC := 75 * 1000 +const MCP_ACTION_DEFERRED_GRACE_MSEC := 5 * 1000 +const MAX_MCP_STATUS_WAITERS := 64 +const PHASE_PREWARM := "prewarm" +const MUTATION_UNPROVEN_META := &"_godot_ai_client_mutation_termination_unproven" +const POST_UPDATE_UNPROVEN_META := &"_godot_ai_post_update_termination_unproven" + +signal snapshot_changed(snapshot: Dictionary) +signal status_refresh_completed(results: Dictionary) +signal mcp_status_completed(request_ids: Array[String], payload: Dictionary) +signal mcp_action_completed(request_id: String, payload: Dictionary) +signal post_update_repin_completed(result: Dictionary) +signal action_completed(client_id: String, action: String, result: Dictionary, prewarm: Dictionary) +signal action_timed_out(client_id: String, action: String, detail: String) + +var _accepting_work := false +var _client_health_blocked := false +var _client_ids: Array[String] = [] + +var _refresh_thread: Thread +var _refresh_state := RefreshState.IDLE +var _refresh_pending := false +var _refresh_pending_force := false +var _refresh_completed_msec := 0 +var _refresh_started_msec := 0 +var _refresh_generation := 0 +## request_id -> first refresh generation allowed to answer it. An MCP request +## arriving behind a partial Dock refresh waits for the coalesced all-client +## retry instead of accepting the partial result. +var _mcp_status_waiters: Dictionary = {} + +var _action_threads: Dictionary = {} +var _action_started_msec: Dictionary = {} +var _action_names: Dictionary = {} +var _action_request_ids: Dictionary = {} +var _action_timeout_reported: Dictionary = {} +var _action_phase_mutex := Mutex.new() +var _action_phases: Dictionary = {} +var _action_cancel_mutex := Mutex.new() +var _action_cancelled: Dictionary = {} +## client_id -> action whose CLI termination could not be proven. Mirrored into +## process-lifetime Engine metadata for immediate reload safety; the CLI +## strategy's global lock directory is the durable cross-restart authority. +var _mutation_termination_unproven: Dictionary = {} +## A successful update enters one pre-activation migration job. It is owned and +## joined here like every other client worker, while ordinary Dock/MCP work +## remains closed and the server lifecycle stays dormant. +var _post_update_thread: Thread +var _post_update_generation := 0 +var _post_update_cancel_mutex := Mutex.new() +var _post_update_cancelled := false +var _post_update_termination_unproven := false +var _last_snapshot: Dictionary = {} + + +func _init() -> void: + var mutations: Variant = Engine.get_meta(MUTATION_UNPROVEN_META, {}) + if mutations is Dictionary: + _mutation_termination_unproven = (mutations as Dictionary).duplicate(true) + _post_update_termination_unproven = bool( + Engine.get_meta(POST_UPDATE_UNPROVEN_META, false) + ) + + +func _ready() -> void: + ## Work can start — and the owner can be activated — before this node is + ## ready. The composition root adds this child, wires it, and calls + ## `activate()` inside the plugin's `_enter_tree`; Godot defers a child's + ## `_ready` until that returns, so this callback runs LAST, after both the + ## post-update migration thread and the ordinary activation seam. + ## + ## Scripts that define `_process` are processing by default, so an inert + ## owner still has to turn it off here. What it must never do is turn it + ## off again once work has been admitted: `_process` is the only thing that + ## joins finished workers, so disabling it after `activate()` strands every + ## refresh and client action for the rest of the session — the sweep never + ## leaves RUNNING, Configure never leaves its last published phase. + set_process(_accepting_work or _has_work_in_flight()) + + +func _has_work_in_flight() -> bool: + return _post_update_thread != null or _refresh_thread != null or not _action_threads.is_empty() + + +## Construction is inert. The composition root calls activate only after its +## startup barrier has allowed normal client work. +func activate() -> void: + if _refresh_state == RefreshState.SHUTTING_DOWN or _post_update_thread != null: + return + _accepting_work = true + set_process(true) + _publish_snapshot() + _retry_deferred_refresh() + + +func set_client_health_blocked(blocked: bool) -> void: + _client_health_blocked = blocked + if not blocked: + _retry_deferred_refresh() + + +func snapshot() -> Dictionary: + var busy: Array[String] = [] + for client_id in _action_threads: + busy.append(String(client_id)) + busy.sort() + return { + "accepting_work": _accepting_work, + "refresh_state": _refresh_state, + "refresh_completed": _refresh_completed_msec > 0, + "post_update_repin_running": _post_update_thread != null, + "busy_actions": busy, + "action_names": _action_names.duplicate(true), + "action_phases": _read_action_phases(), + } + + +func _publish_snapshot() -> void: + var value := snapshot() + if value == _last_snapshot: + return + _last_snapshot = value.duplicate(true) + snapshot_changed.emit(value) + + +func _process(_delta: float) -> void: + _poll_post_update_repin() + _poll_refresh() + _poll_actions() + _check_refresh_timeout() + _check_action_timeouts() + _retry_deferred_refresh() + + +## Stop accepting work, cooperatively cancel action subprocesses, then realize +## every Thread before scripts can be overwritten. Godot exposes no timed join; +## deadline_msec is reported honestly rather than pretending the join can be +## interrupted without risking VM corruption. +func quiesce(deadline_msec: int = 0) -> Dictionary: + _accepting_work = false + _refresh_state = RefreshState.SHUTTING_DOWN + _publish_snapshot() + for client_id in _action_threads: + _set_action_cancelled(String(client_id), true) + _set_post_update_cancelled(true) + _refresh_generation += 1 + if _refresh_thread != null: + _refresh_thread.wait_to_finish() + _refresh_thread = null + if _post_update_thread != null: + _record_unproven_post_update_result(_post_update_thread.wait_to_finish()) + _post_update_thread = null + for thread in _action_threads.values(): + if thread != null: + _record_unproven_action_payload((thread as Thread).wait_to_finish()) + _action_threads.clear() + _action_started_msec.clear() + _action_names.clear() + _action_request_ids.clear() + _action_timeout_reported.clear() + _action_phase_mutex.lock() + _action_phases.clear() + _action_phase_mutex.unlock() + _action_cancel_mutex.lock() + _action_cancelled.clear() + _action_cancel_mutex.unlock() + _refresh_pending = false + _refresh_pending_force = false + _mcp_status_waiters.clear() + set_process(false) + _publish_snapshot() + var termination_unproven := ( + _post_update_termination_unproven + or not _mutation_termination_unproven.is_empty() + or MutationLock.is_locked() + ) + return { + "ok": not termination_unproven, + "deadline_exceeded": deadline_msec > 0 and Time.get_ticks_msec() > deadline_msec, + "termination_unproven": termination_unproven, + } + + +## Re-open the same owner after a pre-activation update failure. Successful +## activation tears down the plugin instead, so this is only a bail-out path. +func resume_after_quiesce() -> void: + if _refresh_state != RefreshState.SHUTTING_DOWN: + return + _refresh_state = RefreshState.IDLE + _set_post_update_cancelled(false) + activate() + + +## Start the hard post-update client barrier. The signed transaction outcome +## supplies both versions; a mismatch with the code actually loaded fails +## closed before touching client config or starting the server. +func begin_post_update_repin( + from_version: String, to_version: String, replace_owned_mismatches: bool = false +) -> Dictionary: + ## `from_version` is empty when the closed-editor installer put v4 into a + ## project that had no add-on: nothing was replaced, and owned client + ## entries still repin to the installed version on this first start. + var replaced := from_version.strip_edges() + var installed := to_version.strip_edges() + if installed.is_empty(): + return {"ok": false, "error": "Post-update target version is missing."} + if installed != ClientConfigurator.get_plugin_version(): + return {"ok": false, "error": "Post-update target does not match the loaded plugin."} + if MutationLock.is_locked(): + return {"ok": false, "error": MutationLock.recovery_message()} + if _post_update_termination_unproven or not _mutation_termination_unproven.is_empty(): + return {"ok": false, "error": MutationLock.recovery_message()} + if ( + _accepting_work + or _post_update_thread != null + or _refresh_thread != null + or not _action_threads.is_empty() + or _refresh_state == RefreshState.SHUTTING_DOWN + ): + return {"ok": false, "error": "Client work is not idle for post-update migration."} + ClientConfigurator.warm_env_snapshot() + _warm_worker_bytecode() + var probes: Array[Dictionary] = [] + for client_id in ClientConfigurator.client_ids(): + var probe := ClientConfigurator.client_status_probe_snapshot(client_id) + ## Manual-only descriptors remain visible in ordinary status UI, but M6 + ## cannot safely parse or rewrite them. In particular Zed owns JSONC and + ## may contain comments that the strict shared JSON parser must reject. + ## Keep manual remediation outside the hard startup barrier. + if not probe.is_empty() and bool(probe.get("automatic_config_edits", true)): + probes.append(probe) + var context := ClientConfigurator.capture_launch_context() + if context.is_empty(): + return {"ok": false, "error": "Post-update launch context is unavailable."} + _set_post_update_cancelled(false) + _post_update_generation += 1 + _post_update_thread = Thread.new() + var start_error := _post_update_thread.start( + Callable(self, "_run_post_update_repin").bind( + probes, + context, + replaced, + installed, + replace_owned_mismatches, + _post_update_generation, + ) + ) + if start_error != OK: + _post_update_thread = null + return {"ok": false, "error": "Could not start post-update client migration."} + set_process(true) + _publish_snapshot() + return {"ok": true} + + +func _run_post_update_repin( + probes: Array[Dictionary], + context: Dictionary, + from_version: String, + to_version: String, + replace_owned_mismatches: bool, + generation: int, +) -> Dictionary: + var prewarm := ClientConfigurator.prewarm_server_package_blocking( + to_version, + ClientConfigurator.PREWARM_TIMEOUT_MS, + Callable(self, "_is_post_update_cancelled"), + ) + var configured_ids: Array[String] = [] + var repinned_ids: Array[String] = [] + var foreign_ids: Array[String] = [] + var deferred: Array[Dictionary] = [] + if bool(prewarm.get("termination_failed", false)): + return _post_update_result( + false, + generation, + configured_ids, + repinned_ids, + "Post-update package pre-warm could not be proven stopped; stop relevant uvx processes or reboot before retrying.", + prewarm, + true, + ) + var server_url := ClientConfigurator.server_url_from(context) + var resolved_launch := ClientConfigurator.resolve_attach_launch(context) + for probe in probes: + if _is_post_update_cancelled(): + return _post_update_result(false, generation, configured_ids, repinned_ids, + "Post-update client migration was cancelled.", prewarm) + var client_id := String(probe.get("id", "")) + var details := ClientConfigurator.check_status_details_for_url_with_cli_path( + client_id, + server_url, + String(probe.get("cli_path", "")), + context, + resolved_launch, + ) + var status := int(details.get("status", Client.Status.ERROR)) + if status == Client.Status.NOT_CONFIGURED: + continue + if status == Client.Status.ERROR: + ## One client whose configuration cannot even be read must not + ## hold every other client and the server itself hostage: leave + ## it for the dock's Configure and say so. + deferred.append({ + "id": client_id, + "reason": "its configuration could not be read: %s" + % str(details.get("error_msg", "unknown error")), + }) + continue + if status == Client.Status.CONFIGURED: + configured_ids.append(client_id) + continue + if replace_owned_mismatches: + ## A major migration rewrites only entries that launch Godot AI. + ## An entry under our name that starts something else is the + ## user's own server: leave it, name it, let Configure replace it. + if not bool(details.get("owned", false)): + foreign_ids.append(client_id) + continue + elif not ClientConfigurator.entry_drift_is_version_pin_only( + client_id, from_version, context + ): + ## #890's write restriction stands: an entry that is not provably + ## what Configure wrote before the update is never rewritten here. + ## But refusing the write is not a reason to refuse the server + ## (#999): the entry is left untouched, named, and deferred to + ## the dock's Configure, and startup continues. + deferred.append({ + "id": client_id, + "reason": "its godot-ai entry differs from what Configure wrote before the update", + }) + continue + var configured := ClientConfigurator.configure(client_id, server_url, context) + if str(configured.get("status", "error")) != "ok": + return _post_update_result(false, generation, configured_ids, repinned_ids, + "%s repin failed: %s" % [client_id, str(configured.get("message", "unknown error"))], + prewarm, + bool(configured.get("termination_failed", false)), + client_id) + var verified := ClientConfigurator.check_status_details_for_url_with_cli_path( + client_id, + server_url, + String(probe.get("cli_path", "")), + context, + resolved_launch, + ) + if int(verified.get("status", Client.Status.ERROR)) != Client.Status.CONFIGURED: + return _post_update_result(false, generation, configured_ids, repinned_ids, + "%s repin could not be verified." % client_id, prewarm) + configured_ids.append(client_id) + repinned_ids.append(client_id) + configured_ids.sort() + repinned_ids.sort() + foreign_ids.sort() + deferred.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return str(a.id) < str(b.id)) + return _post_update_result( + true, generation, configured_ids, repinned_ids, "", prewarm, false, "", foreign_ids, deferred + ) + + +static func _post_update_result( + ok: bool, + generation: int, + configured_ids: Array[String], + repinned_ids: Array[String], + error: String, + prewarm: Dictionary, + termination_failed: bool = false, + unsafe_client_id: String = "", + foreign_ids: Array[String] = [], + deferred: Array[Dictionary] = [], +) -> Dictionary: + return { + "ok": ok, + "generation": generation, + "configured_ids": configured_ids.duplicate(), + "repinned_ids": repinned_ids.duplicate(), + "error": error, + "prewarm": prewarm.duplicate(true), + "termination_failed": termination_failed, + "unsafe_client_id": unsafe_client_id, + ## Entries under our name that launch something else; left unchanged. + "foreign_ids": foreign_ids.duplicate(), + ## `{id, reason}` for entries the migration left unchanged because + ## they were unreadable or drifted beyond the version pin (#999). + ## They are the dock's Configure job, not a startup barrier. + "deferred": deferred.duplicate(true), + } + + +func _poll_post_update_repin() -> void: + if _post_update_thread == null or _post_update_thread.is_alive(): + return + var payload: Variant = _post_update_thread.wait_to_finish() + _post_update_thread = null + if _refresh_state == RefreshState.SHUTTING_DOWN: + return + var result: Dictionary = ( + (payload as Dictionary).duplicate(true) + if payload is Dictionary + else {"ok": false, "error": "Post-update client migration returned no result."} + ) + _record_unproven_post_update_result(result) + if int(result.get("generation", -1)) != _post_update_generation: + return + post_update_repin_completed.emit(result) + _publish_snapshot() + + +func request_action(client_id: String, action: String) -> bool: + var started := _start_action(client_id, action, "") + if not bool(started.get("ok", false)) and bool(started.get("termination_failed", false)): + action_completed.emit( + client_id, + action, + { + "status": "error", + "message": str(started.get("error", "Client action is unavailable.")), + "termination_failed": true, + }, + {}, + ) + return bool(started.get("ok", false)) + + +## MCP mutations use the same one-slot owner as Dock actions. They preserve the +## tool's previous mutation-only behavior: package prewarm remains a Dock-only +## convenience. The returned timeout is the bounded out-of-band reply budget. +func request_mcp_action(request_id: String, client_id: String, action: String) -> Dictionary: + if request_id.is_empty(): + return {"ok": false, "error": "Client mutation requires a deferred request context."} + return _start_action(client_id, action, request_id) + + +func _start_action(client_id: String, action: String, request_id: String) -> Dictionary: + if not _accepting_work: + return {"ok": false, "error": "Client worker is unavailable."} + if action not in ["configure", "remove"]: + return {"ok": false, "error": "Unknown client action: %s" % action} + if _mutation_termination_unproven.has(client_id): + return { + "ok": false, + "error": MutationLock.recovery_message(), + "termination_failed": true, + } + if _action_threads.has(client_id): + return {"ok": false, "error": "A client action is already running for %s." % client_id} + if not ClientConfigurator.has_client(client_id): + return {"ok": false, "error": "Unknown client: %s" % client_id} + ClientConfigurator.warm_env_snapshot() + _set_action_cancelled(client_id, false) + _clear_action_phase(client_id) + var context := ClientConfigurator.capture_launch_context() + _action_names[client_id] = action + _action_request_ids[client_id] = request_id + _action_timeout_reported.erase(client_id) + _action_started_msec[client_id] = Time.get_ticks_msec() + var thread := Thread.new() + _action_threads[client_id] = thread + var error := thread.start( + Callable(self, "_run_action").bind( + client_id, + action, + ClientConfigurator.server_url_from(context), + context, + request_id.is_empty(), + ) + ) + if error != OK: + _action_threads.erase(client_id) + _action_started_msec.erase(client_id) + _action_names.erase(client_id) + _action_request_ids.erase(client_id) + return {"ok": false, "error": "Could not start client worker thread."} + ## Same invariant `begin_post_update_repin` states: a live Thread is only + ## ever joined from `_process`, so spawning one must guarantee the poll. + set_process(true) + _publish_snapshot() + return { + "ok": true, + "deferred_timeout_ms": _mcp_action_deferred_timeout_msec(action), + } + + +func _run_action( + client_id: String, + action: String, + server_url: String, + context: Dictionary, + prewarm_after_configure: bool, +) -> Dictionary: + var result: Dictionary + var prewarm: Dictionary = {} + if action == "remove": + result = ClientConfigurator.remove(client_id, server_url, context) + else: + result = ClientConfigurator.configure(client_id, server_url, context) + if result.get("status") == "ok" and prewarm_after_configure: + _set_action_phase(client_id, PHASE_PREWARM) + prewarm = ClientConfigurator.prewarm_attach_launch( + context, + ClientConfigurator.PREWARM_TIMEOUT_MS, + {}, + Callable(self, "_is_action_cancelled").bind(client_id), + ) + return { + "client_id": client_id, + "action": action, + "result": result, + "prewarm": prewarm, + } + + +func _poll_actions() -> void: + var phase_changed := false + for client_id in _action_threads.keys(): + var thread: Thread = _action_threads[client_id] + if thread == null: + continue + if thread.is_alive(): + phase_changed = phase_changed or not _read_action_phase(String(client_id)).is_empty() + continue + var payload: Variant = thread.wait_to_finish() + _action_threads[client_id] = null + var action := String(_action_names.get(client_id, "configure")) + var result: Dictionary = {"status": "error", "message": "worker returned no result"} + var prewarm: Dictionary = {} + if payload is Dictionary: + var data := payload as Dictionary + action = String(data.get("action", action)) + result = (data.get("result", {}) as Dictionary).duplicate(true) + prewarm = (data.get("prewarm", {}) as Dictionary).duplicate(true) + _finalize_action(String(client_id), action, result, prewarm) + if phase_changed: + _publish_snapshot() + + +func _finalize_action( + client_id: String, + action: String, + result: Dictionary, + prewarm: Dictionary, +) -> void: + ## Safety evidence is consumed before the sole slot is released. A completed + ## timed-out worker therefore cannot reopen a race window between is_alive() + ## flipping false and its termination_failed payload being persisted. + _record_unproven_action_result(client_id, action, result, prewarm) + var request_id := String(_action_request_ids.get(client_id, "")) + _action_threads.erase(client_id) + _action_started_msec.erase(client_id) + _action_names.erase(client_id) + _action_request_ids.erase(client_id) + _action_timeout_reported.erase(client_id) + _set_action_cancelled(client_id, false) + _clear_action_phase(client_id) + if _refresh_state != RefreshState.SHUTTING_DOWN: + action_completed.emit(client_id, action, result.duplicate(true), prewarm.duplicate(true)) + if not request_id.is_empty(): + mcp_action_completed.emit( + request_id, + _mcp_action_payload(client_id, result), + ) + _publish_snapshot() + + +static func _mcp_action_payload(client_id: String, result: Dictionary) -> Dictionary: + if str(result.get("status", "error")) == "error": + return McpErrorCodes.make( + McpErrorCodes.INTERNAL_ERROR, + str(result.get("message", "Configuration failed for '%s'" % client_id)), + ) + return {"data": result.duplicate(true)} + + +static func _mcp_action_deferred_timeout_msec(_action: String) -> int: + return ACTION_TIMEOUT_MSEC + MCP_ACTION_DEFERRED_GRACE_MSEC + + +func _record_unproven_action_payload(payload: Variant) -> void: + if not payload is Dictionary: + return + var data := payload as Dictionary + var result: Dictionary = {} + var prewarm: Dictionary = {} + if data.get("result", null) is Dictionary: + result = data.get("result", {}) as Dictionary + if data.get("prewarm", null) is Dictionary: + prewarm = data.get("prewarm", {}) as Dictionary + _record_unproven_action_result( + str(data.get("client_id", "")), + str(data.get("action", "configure")), + result, + prewarm, + ) + + +func _record_unproven_action_result( + client_id: String, + action: String, + result: Dictionary, + prewarm: Dictionary, +) -> void: + if client_id.is_empty(): + return + if ( + bool(result.get("termination_failed", false)) + or bool(prewarm.get("termination_failed", false)) + ): + _mark_mutation_termination_unproven(client_id, action) + + +func _record_unproven_post_update_result(payload: Variant) -> void: + if not payload is Dictionary: + return + var result := payload as Dictionary + if not bool(result.get("termination_failed", false)): + return + var client_id := str(result.get("unsafe_client_id", "")) + if client_id.is_empty(): + _post_update_termination_unproven = true + Engine.set_meta(POST_UPDATE_UNPROVEN_META, true) + else: + _mark_mutation_termination_unproven(client_id, "post_update_repin") + + +func _mark_mutation_termination_unproven(client_id: String, action: String) -> void: + _mutation_termination_unproven[client_id] = action + Engine.set_meta( + MUTATION_UNPROVEN_META, + _mutation_termination_unproven.duplicate(true), + ) + + +func _check_action_timeouts(now_msec: int = -1) -> void: + var now := Time.get_ticks_msec() if now_msec < 0 else now_msec + for client_id in _action_threads.keys(): + if bool(_action_timeout_reported.get(client_id, false)): + continue + var started := int(_action_started_msec.get(client_id, 0)) + if started > 0 and now - started >= _action_budget(String(client_id)): + _report_action_timeout(String(client_id), now - started) + + +func _action_budget(client_id: String) -> int: + if _read_action_phase(client_id) == PHASE_PREWARM: + return ACTION_TIMEOUT_MSEC + ClientConfigurator.PREWARM_TIMEOUT_MS + return ACTION_TIMEOUT_MSEC + + +func _report_action_timeout(client_id: String, elapsed_msec: int) -> void: + if not _action_threads.has(client_id): + return + var thread: Thread = _action_threads[client_id] + var alive := thread != null and thread.is_alive() + if alive: + _set_action_cancelled(client_id, true) + _action_timeout_reported[client_id] = true + var action := String(_action_names.get(client_id, "configure")) + var label := "Remove" if action == "remove" else "Configure" + var detail := ( + "%s is taking longer than expected and is being stopped; this row remains locked until its worker is joined." % label + if alive + else "%s did not report completion in time; waiting to inspect its result." % label + ) + print( + "MCP | client action timed out: client=%s action=%s elapsed_ms=%d worker_alive=%s" + % [client_id, action, elapsed_msec, str(alive)] + ) + action_timed_out.emit(client_id, action, detail) + _publish_snapshot() + + +func request_status_refresh(ids: Array[String], force := false) -> bool: + _client_ids = ids.duplicate() + if not _accepting_work or _client_health_blocked: + return false + if RefreshState.has_worker_alive(_refresh_state): + ## One refresh worker total. A timed-out probe remains visible and owns + ## the slot until its bounded subprocess calls return; repeated force + ## requests collapse into one retry instead of growing live Threads. + _refresh_pending = true + _refresh_pending_force = _refresh_pending_force or force + _publish_snapshot() + return false + if RefreshState.is_blocked_for_spawn(_refresh_state): + return false + if not force and _refresh_completed_msec > 0: + if Time.get_ticks_msec() - _refresh_completed_msec < STATUS_COOLDOWN_MSEC: + return false + if ids.is_empty(): + return false + if _filesystem_busy(): + _refresh_state = RefreshState.DEFERRED_FOR_FILESYSTEM + _refresh_pending_force = _refresh_pending_force or force + _publish_snapshot() + return false + if force: + ClientConfigurator.invalidate_cli_cache() + _warm_worker_bytecode() + var probes: Array[Dictionary] = [] + for client_id in ids: + var probe := ClientConfigurator.client_status_probe_snapshot(client_id) + if not probe.is_empty(): + probes.append(probe) + if probes.is_empty(): + _finish_refresh({}) + return false + var context := ClientConfigurator.capture_launch_context() + _refresh_state = RefreshState.RUNNING + _refresh_pending = false + _refresh_pending_force = false + _refresh_started_msec = Time.get_ticks_msec() + _refresh_generation += 1 + _refresh_thread = Thread.new() + var error := _refresh_thread.start( + Callable(self, "_run_status_refresh").bind( + probes, + ClientConfigurator.server_url_from(context), + context, + _refresh_generation, + ) + ) + if error != OK: + _refresh_thread = null + _refresh_state = RefreshState.IDLE + _publish_snapshot() + return false + ## See `_start_action`: the sweep leaves RUNNING only through `_poll_refresh`. + set_process(true) + _publish_snapshot() + return true + + +## Queue one deferred MCP response onto the same aggregate refresh worker the +## Dock uses. The dispatcher bounds its pending table; this local cap also +## protects direct/future callers. No connection or handler is retained. +func request_mcp_status(request_id: String) -> bool: + if ( + request_id.is_empty() + or not _accepting_work + or _client_health_blocked + or _refresh_state == RefreshState.SHUTTING_DOWN + or _mcp_status_waiters.has(request_id) + or _mcp_status_waiters.size() >= MAX_MCP_STATUS_WAITERS + ): + return false + var target_generation := _refresh_generation + 1 + _mcp_status_waiters[request_id] = target_generation + request_status_refresh(ClientConfigurator.client_ids(), true) + if ( + _refresh_generation >= target_generation + or _refresh_pending + or _refresh_state == RefreshState.DEFERRED_FOR_FILESYSTEM + ): + return true + _mcp_status_waiters.erase(request_id) + return false + + +func _run_status_refresh( + probes: Array[Dictionary], + server_url: String, + context: Dictionary, + generation: int, +) -> Dictionary: + var results: Dictionary = {} + var resolved_launch := ClientConfigurator.resolve_attach_launch(context) + for probe in 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", "")), + context, + resolved_launch, + ) + results[client_id] = { + "status": details.get("status", Client.Status.NOT_CONFIGURED), + "installed": bool(probe.get("installed", false)), + "error_msg": details.get("error_msg", ""), + } + return {"results": results, "generation": generation} + + +func _poll_refresh() -> void: + if _refresh_thread == null or _refresh_thread.is_alive(): + return + var payload: Variant = _refresh_thread.wait_to_finish() + _refresh_thread = null + if not (payload is Dictionary): + _finish_refresh({}, _refresh_generation, "Client status worker returned an invalid response.") + return + var data := payload as Dictionary + if int(data.get("generation", -1)) != _refresh_generation: + return + var results := (data.get("results", {}) as Dictionary).duplicate(true) + var timed_out := _refresh_state == RefreshState.RUNNING_TIMED_OUT + _finish_refresh( + {} if timed_out else results, + int(data.get("generation", _refresh_generation)), + "Client status probe timed out." if timed_out else "", + ) + + +func _finish_refresh( + results: Dictionary, completed_generation: int = -1, mcp_error: String = "" +) -> void: + _refresh_completed_msec = Time.get_ticks_msec() + if _refresh_state != RefreshState.SHUTTING_DOWN: + _refresh_state = RefreshState.IDLE + if not _client_health_blocked and _refresh_state != RefreshState.SHUTTING_DOWN: + status_refresh_completed.emit(results.duplicate(true)) + _complete_mcp_status_waiters(results, completed_generation, mcp_error) + _publish_snapshot() + if _refresh_pending: + var force := _refresh_pending_force + _refresh_pending = false + _refresh_pending_force = false + request_status_refresh(_client_ids, force) + + +func _complete_mcp_status_waiters( + results: Dictionary, completed_generation: int, error: String +) -> void: + if completed_generation < 0 or _mcp_status_waiters.is_empty(): + return + var completed: Array[String] = [] + for request_id in _mcp_status_waiters: + if int(_mcp_status_waiters[request_id]) <= completed_generation: + completed.append(String(request_id)) + for request_id in completed: + _mcp_status_waiters.erase(request_id) + if completed.is_empty(): + return + var payload := ( + McpErrorCodes.make(McpErrorCodes.INTERNAL_ERROR, error) + if not error.is_empty() + else ClientConfigurator.client_status_response(results) + ) + mcp_status_completed.emit(completed, payload.duplicate(true)) + +func _refresh_timed_out() -> bool: + return ( + RefreshState.has_worker_alive(_refresh_state) + and _refresh_started_msec > 0 + and Time.get_ticks_msec() - _refresh_started_msec >= STATUS_TIMEOUT_MSEC + ) + + +func _check_refresh_timeout() -> void: + if _refresh_state == RefreshState.RUNNING and _refresh_timed_out(): + _refresh_state = RefreshState.RUNNING_TIMED_OUT + _publish_snapshot() + + +func _retry_deferred_refresh() -> void: + if _refresh_state != RefreshState.DEFERRED_FOR_FILESYSTEM: + return + if not _accepting_work or _client_health_blocked or _filesystem_busy(): + return + var force := _refresh_pending_force + _refresh_state = RefreshState.IDLE + _refresh_pending_force = false + request_status_refresh(_client_ids, force) + + +func _filesystem_busy() -> bool: + var filesystem := EditorInterface.get_resource_filesystem() + return filesystem != null and filesystem.is_scanning() + + +func _warm_worker_bytecode() -> void: + var ids := ClientConfigurator.client_ids() + if ids.is_empty(): + return + var client := ClientRegistry.get_by_id(String(ids[0])) + if client != null: + JsonStrategy.verify_entry(client, {}, "") + DshStrategy.command_launch_error(client, {}) + TomlStrategy.format_body(PackedStringArray(), "") + CliStrategy.format_args(PackedStringArray(), "", "") + ClientConfigurator.warm_env_snapshot() + + +func _set_action_cancelled(client_id: String, cancelled: bool) -> void: + _action_cancel_mutex.lock() + if cancelled: + _action_cancelled[client_id] = true + else: + _action_cancelled.erase(client_id) + _action_cancel_mutex.unlock() + + +func _is_action_cancelled(client_id: String) -> bool: + _action_cancel_mutex.lock() + var cancelled := bool(_action_cancelled.get(client_id, false)) + _action_cancel_mutex.unlock() + return cancelled + + +func _set_post_update_cancelled(cancelled: bool) -> void: + _post_update_cancel_mutex.lock() + _post_update_cancelled = cancelled + _post_update_cancel_mutex.unlock() + + +func _is_post_update_cancelled() -> bool: + _post_update_cancel_mutex.lock() + var cancelled := _post_update_cancelled + _post_update_cancel_mutex.unlock() + return cancelled + + +func _set_action_phase(client_id: String, phase: String) -> void: + _action_phase_mutex.lock() + _action_phases[client_id] = phase + _action_phase_mutex.unlock() + + +func _read_action_phase(client_id: String) -> String: + _action_phase_mutex.lock() + var phase := String(_action_phases.get(client_id, "")) + _action_phase_mutex.unlock() + return phase + + +func _read_action_phases() -> Dictionary: + _action_phase_mutex.lock() + var phases := _action_phases.duplicate(true) + _action_phase_mutex.unlock() + return phases + + +func _clear_action_phase(client_id: String) -> void: + _action_phase_mutex.lock() + _action_phases.erase(client_id) + _action_phase_mutex.unlock() diff --git a/addons/godot_ai/utils/client_job_owner.gd.uid b/addons/godot_ai/utils/client_job_owner.gd.uid new file mode 100644 index 0000000..43da106 --- /dev/null +++ b/addons/godot_ai/utils/client_job_owner.gd.uid @@ -0,0 +1 @@ +uid://jo18d41wbdpx diff --git a/addons/godot_ai/utils/client_mutation_lock.gd b/addons/godot_ai/utils/client_mutation_lock.gd new file mode 100644 index 0000000..864f8cf --- /dev/null +++ b/addons/godot_ai/utils/client_mutation_lock.gd @@ -0,0 +1,131 @@ +@tool +class_name McpClientMutationLock +extends RefCounted + +## Durable, atomic authority for all automatic client mutations. +## +## A POSIX exact-PID kill cannot prove that a timed-out CLI left no descendant +## behind. The lock directory therefore survives plugin reload, editor restart, +## and crashes. Only a sequence whose every direct child terminated without an +## ambiguous kill removes it. An existing or malformed lock always fails closed. + +const ROOT_DIR_NAME := "godot-ai" +const LOCK_DIR_NAME := "client_mutation.lock" +const RECORD_NAME := "owner.json" +const SCHEMA := 1 +const _POSIX_PERMISSION_MASK := 0x1ff ## 0777 +const _OWNER_DIRECTORY_MODE := ( + FileAccess.UNIX_READ_OWNER + | FileAccess.UNIX_WRITE_OWNER + | FileAccess.UNIX_EXECUTE_OWNER +) + + +static func lock_path() -> String: + return _root_path().path_join(LOCK_DIR_NAME) + + +static func recovery_message() -> String: + return ( + "Automatic MCP client mutations are safety-locked. Stop relevant client processes " + + "or reboot, then explicitly remove '%s' before retrying; restarting Godot alone " + + "does not prove an old descendant stopped." + ) % lock_path() + + +static func is_locked() -> bool: + var path := lock_path() + return DirAccess.dir_exists_absolute(path) or FileAccess.file_exists(path) + + +## Atomically claim the one global mutation slot. Creation wins ownership; +## failure for any reason is indistinguishable from an existing unsafe owner. +static func acquire(client_id: String, operation: String) -> Dictionary: + var path := lock_path() + if not _prepare_private_root(): + return {"ok": false, "error": recovery_message(), "path": path} + if DirAccess.make_dir_absolute(path) != OK: + return {"ok": false, "error": recovery_message(), "path": path} + if not _has_private_mode(path): + ## The directory itself remains as the durable deny marker. Never + ## write ownership data into a location whose privacy is unproven. + return {"ok": false, "error": recovery_message(), "path": path} + var token := Crypto.new().generate_random_bytes(16).hex_encode() + var record := { + "schema": SCHEMA, + "token": token, + "pid": OS.get_process_id(), + "client_id": client_id, + "operation": operation, + "started_unix": int(Time.get_unix_time_from_system()), + } + var record_path := path.path_join(RECORD_NAME) + var file := FileAccess.open(record_path, FileAccess.WRITE) + if file == null: + return {"ok": false, "error": recovery_message(), "path": path} + file.store_string(JSON.stringify(record)) + file.flush() + var write_error := file.get_error() + file.close() + if write_error != OK: + return {"ok": false, "error": recovery_message(), "path": path} + return {"ok": true, "path": path, "record_path": record_path, "token": token} + + +## Release only the exact claim this process wrote. Every failure leaves either +## the record or its parent directory in place, which keeps later mutations +## denied rather than guessing that cleanup succeeded. +static func release(claim: Dictionary) -> bool: + var path := str(claim.get("path", "")) + var record_path := str(claim.get("record_path", "")) + var token := str(claim.get("token", "")) + if ( + path != lock_path() + or record_path != path.path_join(RECORD_NAME) + or token.is_empty() + ): + return false + var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(record_path)) + if ( + not parsed is Dictionary + or int(parsed.get("schema", 0)) != SCHEMA + or str(parsed.get("token", "")) != token + or int(parsed.get("pid", 0)) != OS.get_process_id() + ): + return false + if DirAccess.remove_absolute(record_path) != OK: + return false + return DirAccess.remove_absolute(path) == OK + + +static func _root_path() -> String: + ## Unlike user://, the OS config directory is shared by every Godot + ## project owned by this account. User-scope client config is shared too, + ## so a project-specific lock would leave the real cross-editor race open. + return OS.get_config_dir().path_join(ROOT_DIR_NAME) + + +static func _prepare_private_root() -> bool: + var root := _root_path() + if FileAccess.file_exists(root) or _is_link(root): + return false + if not DirAccess.dir_exists_absolute(root): + var created := DirAccess.make_dir_absolute(root) + ## Another editor may have won the benign root-creation race. Accept + ## that only after independently proving the resulting directory. + if created != OK and not DirAccess.dir_exists_absolute(root): + return false + return not _is_link(root) and _has_private_mode(root) + + +static func _has_private_mode(path: String) -> bool: + if OS.get_name() == "Windows": + return true + var permission_error := FileAccess.set_unix_permissions(path, _OWNER_DIRECTORY_MODE) + var actual_mode := FileAccess.get_unix_permissions(path) & _POSIX_PERMISSION_MASK + return permission_error == OK and actual_mode == _OWNER_DIRECTORY_MODE + + +static func _is_link(path: String) -> bool: + var parent := DirAccess.open(path.get_base_dir()) + return parent == null or parent.is_link(path) diff --git a/addons/godot_ai/utils/client_mutation_lock.gd.uid b/addons/godot_ai/utils/client_mutation_lock.gd.uid new file mode 100644 index 0000000..c6ef2a2 --- /dev/null +++ b/addons/godot_ai/utils/client_mutation_lock.gd.uid @@ -0,0 +1 @@ +uid://climutlockv4x1 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..9136144 --- /dev/null +++ b/addons/godot_ai/utils/error_codes.gd @@ -0,0 +1,164 @@ +@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 transport/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 TRANSPORT_OVERLOADED := "TRANSPORT_OVERLOADED" +const NEW_CLIENT_SESSION_REQUIRED := "NEW_CLIENT_SESSION_REQUIRED" +const ATTACH_LOCK_TIMEOUT := "ATTACH_LOCK_TIMEOUT" +const ATTACH_LOCK_ERROR := "ATTACH_LOCK_ERROR" +const ATTACH_RUNTIME_DIR_ERROR := "ATTACH_RUNTIME_DIR_ERROR" +const PORT_OCCUPIED := "PORT_OCCUPIED" +const BACKEND_START_FAILED := "BACKEND_START_FAILED" +const BACKEND_START_TIMEOUT := "BACKEND_START_TIMEOUT" +# game_eval failure codes (#490) — keep in sync with protocol/errors.py +const EVAL_COMPILE_ERROR := "EVAL_COMPILE_ERROR" +const EVAL_RUNTIME_ERROR := "EVAL_RUNTIME_ERROR" +## #518/#859: the play session is up (EditorInterface.is_playing_scene() is true, +## so editor_handler's EDITOR_NOT_READY "game is not running" gate already +## passed) but the game cannot service evals: the helper did not register within +## EVAL_READY_WAIT_SEC, its main-loop beacon is stale, or its debugger session +## closed mid-eval. Carved out of INTERNAL_ERROR so these fast, +## caller-actionable failures do not burn the opaque 10s eval backstop. +const EVAL_GAME_NOT_READY := "EVAL_GAME_NOT_READY" +## #518: the eval genuinely never finished inside the timeout ladder — the +## game-side 8s deadline aborted a hung await, or the editor-side 10s backstop +## fired because the game never replied at all (CPU-bound loop, frozen / +## backgrounded idle loop). Carved out of INTERNAL_ERROR — the last big +## still-unlabeled bucket from #487/#488 — so "your eval code never finished" +## stops reading as an internal fault in telemetry and agent-facing errors. +const EVAL_HUNG := "EVAL_HUNG" +## #518: the eval completed but its serialized result is too large for the +## debugger + WebSocket pipeline. Without this the reply is dropped silently +## (the debugger TCP peer discards messages over ~8 MiB) and the request rides +## to the 10s backstop as a phantom "hang". Failing fast game-side with the +## real byte count makes the failure actionable (return a smaller slice). +const EVAL_RESULT_TOO_LARGE := "EVAL_RESULT_TOO_LARGE" +## #777: a game-side request (currently editor_screenshot source="game") +## reached a live, registered game helper but no reply came back before the +## editor-side timer fired. Every editor gate already passed +## (is_playing_scene, helper hello) so this is a TOP-LEVEL code, not an +## EDITOR_NOT_READY sub-code: the game process itself failed to respond — +## backgrounded with a frozen main loop and nothing rendered to fall back +## on, main thread blocked, or the helper died mid-run. Carved out of +## INTERNAL_ERROR (the largest opaque timeout bucket fleet-wide) so the +## residual timeout is attributable and actionable. +const GAME_HELPER_TIMEOUT := "GAME_HELPER_TIMEOUT" +## audit-v2 #21 (issue #365): finer-grained codes carved out of the 471 +## INVALID_PARAMS sites so agents can distinguish recoverable input +## errors from structural ones. INVALID_PARAMS stays for genuinely +## catch-all input errors that don't fit any of the buckets below. +## +## - NODE_NOT_FOUND: scene-tree/autoload node lookup failed (path didn't +## resolve to a Node). +## - RESOURCE_NOT_FOUND: a `res://` path lookup failed (file/.tres/ +## .gdshader/.tscn etc. doesn't exist or couldn't load). Distinct from +## NODE_NOT_FOUND because the recovery path differs — agents need to +## know whether to fix a node path vs. create/import a resource. +## - PROPERTY_NOT_ON_CLASS: property/signal/method/uniform/slot lookup +## failed on a known instance (path resolved, but the requested +## member doesn't exist on that class). +## - VALUE_OUT_OF_RANGE: numeric/index bound violation OR enum value +## not in the allowed set. +## - WRONG_TYPE: input was a value (or a loaded resource) of the wrong +## type — the param was provided, but `typeof` or `is X` failed. +## - MISSING_REQUIRED_PARAM: required input field was absent or empty. +const NODE_NOT_FOUND := "NODE_NOT_FOUND" +const RESOURCE_NOT_FOUND := "RESOURCE_NOT_FOUND" +const PROPERTY_NOT_ON_CLASS := "PROPERTY_NOT_ON_CLASS" +const VALUE_OUT_OF_RANGE := "VALUE_OUT_OF_RANGE" +const WRONG_TYPE := "WRONG_TYPE" +const MISSING_REQUIRED_PARAM := "MISSING_REQUIRED_PARAM" +const CUSTOM_TOOL_NOT_UNDOABLE := "CUSTOM_TOOL_NOT_UNDOABLE" +const CUSTOM_TOOL_DISABLED := "CUSTOM_TOOL_DISABLED" + +## #651 stage 1: EDITOR_NOT_READY sub-codes. These travel in +## `error.data.sub_code`, NEVER as the top-level `error.code` — existing +## callers and dashboards key on EDITOR_NOT_READY, so the top-level code is +## frozen. Each sub-code names the concrete editor state at rejection time, +## limited to states EditorInterface/EditorFileSystem can report +## deterministically. States we cannot observe (script compilation, +## resource reload, modal dialogs) intentionally get NO sub-code: a bare +## EDITOR_NOT_READY stays the honest fallback rather than a guessed label. +## Keep in sync with protocol/errors.py::EditorNotReadySubCode — enforced +## by tests/unit/test_editor_not_ready_hint_contract.py. +const SUB_EDITOR_IMPORTING := "EDITOR_IMPORTING" +const SUB_EDITOR_PLAYING := "EDITOR_PLAYING" +const SUB_EDITOR_NO_SCENE := "EDITOR_NO_SCENE" +const SUB_EDITOR_GAME_NOT_RUNNING := "EDITOR_GAME_NOT_RUNNING" +const SUB_EDITOR_VIEWPORT_UNAVAILABLE := "EDITOR_VIEWPORT_UNAVAILABLE" +const SUB_EDITOR_VIEWPORT_NOT_3D := "EDITOR_VIEWPORT_NOT_3D" +const SUB_EDITOR_VIEWPORT_EMPTY := "EDITOR_VIEWPORT_EMPTY" +const SUB_EDITOR_UNAVAILABLE := "EDITOR_UNAVAILABLE" +## Emitted only by the exclusive-run transport servicing path: a command +## arrived while a synchronous test run holds the main thread, and was +## rejected (not buffered) so it can't replay stale after its server-side +## future expires. See connection.gd::service_transport_during_exclusive_run. +const SUB_EDITOR_TEST_RUNNING := "EDITOR_TEST_RUNNING" + +## Terminal code for a test run that hit its between-test abort ceiling +## before finishing. error.data carries the partial summary; full partial +## results stay retrievable via get_test_results. +const TEST_RUN_TIMEOUT := "TEST_RUN_TIMEOUT" + + +## Build a standard error response dictionary. +static func make(code: String, message: String) -> Dictionary: + return {"status": "error", "error": {"code": code, "message": message}} + + +## Build an EDITOR_NOT_READY error carrying the #651 stage-1 attribution +## payload: `data.sub_code` + `retryable` + `hint`. Mirrors the shape +## scene_path.gd::require_edited_scene established (editor_state/retryable/ +## hint). `hint` may be empty when `message` already IS the recovery hint — +## the server's GodotCommandError string-appends every data key, so +## duplicating the message into data would double the agent-visible text. +static func make_not_ready( + sub_code: String, message: String, retryable: bool, hint: String = "" +) -> Dictionary: + var err := make(EDITOR_NOT_READY, message) + var data := {"sub_code": sub_code, "retryable": retryable} + if not hint.is_empty(): + data["hint"] = hint + err["error"]["data"] = data + return err + + +## Return a NEW error dict with the original code and a prefixed message. +## Prefer this over mutating `err["error"]["message"]` in place — callers +## that want to add context ("Property '%s': …") shouldn't need to know +## the internal shape of the dict returned by `make`. Empty `prefix` +## returns `err` unchanged so callers don't need their own guard. +static func prefix_message(err: Dictionary, prefix: String) -> Dictionary: + if prefix.is_empty(): + return err + var inner: Dictionary = err.get("error", {}) + var code: String = inner.get("code", INTERNAL_ERROR) + var message: String = inner.get("message", "") + return make(code, "%s: %s" % [prefix, message]) diff --git a/addons/godot_ai/utils/error_codes.gd.uid b/addons/godot_ai/utils/error_codes.gd.uid 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..6100836 --- /dev/null +++ b/addons/godot_ai/utils/json_values.gd @@ -0,0 +1,108 @@ +@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 + + +## Parse a scalar float from the wire shapes agents send: float passthrough, +## int (JSON integers land as numbers, not strings), and strictly-numeric +## strings — some MCP clients stringify float arguments ("4.0"; #964). +## Anything else returns null so callers turn it into their own typed +## error; null input stays null (callers gate on the input's type first +## when null is a meaningful "clear" value). +static func parse_float(value: Variant) -> Variant: + if _is_number(value): + return float(value) + if value is String: + var text := value as String + if text.is_valid_float(): + return text.to_float() + 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..d3537c4 --- /dev/null +++ b/addons/godot_ai/utils/mcp_adoption_label.gd @@ -0,0 +1,8 @@ +@tool +class_name McpAdoptionLabel +extends RefCounted + +## Retired v4 lifecycle vocabulary kept only because this class_name and its +## constants were published. Runtime ownership now lives in the tagged episode. +const MANAGED := "managed" +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..7b88c34 --- /dev/null +++ b/addons/godot_ai/utils/mcp_client_refresh_state.gd @@ -0,0 +1,101 @@ +@tool +class_name McpClientRefreshState +extends RefCounted + +## State machine for the plugin-lifetime client's 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 job owner parks the request and retries on the next `_process` after +## the scan settles. A separate force bit preserves an explicit retry request. +const DEFERRED_FOR_FILESYSTEM := 1 +## Worker thread is alive and probing client status off-main. The +## Dock snapshots paint "(checking...)" in the clients summary and the owner 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)"; further requests coalesce into +## one retry after this sole worker exits. +const RUNNING_TIMED_OUT := 3 +## Plugin teardown / update activation is draining workers. New refresh +## requests are rejected outright until an explicitly failed handoff resumes +## the same owner. +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 the sole worker running. +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 +## job owner's deferred entrypoint and 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. + return to == IDLE or to == RUNNING_TIMED_OUT + RUNNING_TIMED_OUT: + ## The sole late-arriving worker drops back to IDLE. A coalesced + ## retry then starts through the ordinary IDLE -> RUNNING edge. + return to == IDLE + 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..f127015 --- /dev/null +++ b/addons/godot_ai/utils/mcp_server_state.gd @@ -0,0 +1,57 @@ +@tool +class_name McpServerState +extends RefCounted +## Stable presentation values consumed by the Dock and editor-status API. +## McpServerLifecycleManager's tagged episode is the only transition owner. +const UNINITIALIZED := 0 +const SPAWNING := 1 +# Slot 2 stays unused so existing status consumers keep stable values. +const READY := 3 +const INCOMPATIBLE := 4 +const CRASHED := 5 +const NO_COMMAND := 6 +const PORT_EXCLUDED := 7 +const FOREIGN_PORT := 8 +const STOPPING := 10 +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", + 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 skip interpreting client health (incompatible +## tool surface). This must NOT block Configure writes — those take an +## explicit url and the live plugin version (#916). 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 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..dc0ae16 --- /dev/null +++ b/addons/godot_ai/utils/mcp_startup_path.gd @@ -0,0 +1,15 @@ +@tool +class_name McpStartupPath +extends RefCounted + +## Retired v4 lifecycle vocabulary kept only because this class_name and its +## constants were published. Runtime startup state lives in one tagged episode. +const UNSET := "" +const GUARDED := "guarded" +const ADOPTED := "adopted" +const SPAWNED := "spawned" +const CRASHED := "crashed" +const RESERVED := "reserved" +const NO_COMMAND := "no_command" +const INCOMPATIBLE := "incompatible" +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..8cdc77c --- /dev/null +++ b/addons/godot_ai/utils/path_validator.gd @@ -0,0 +1,216 @@ +@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 + + +## True when `path` carries a codepoint that must never reach a filesystem API. +## +## Issue #889: the previous guard built its sentinel with `String.chr(0)`, and +## Godot emits `Unicode parsing error … Unexpected NUL character` while +## *constructing* that string. Every validated call — including reads that then +## failed with "File not found" — printed an alarming engine error that had +## nothing to do with the caller's path. Reading codepoints instead never +## materialises a NUL String, so the noise is gone. +## +## Both codepoints are rejected, for different reasons: +## * `0x0000` — a real embedded NUL can truncate a C string, so the path that +## is validated is not the path that gets opened. Current Godot cannot +## retain one in a String, making this a no-op today; it stays as +## defense-in-depth for any build or input path that can. +## * `0xFFFD` — what current Godot actually substitutes for a NUL or any other +## undecodable input. Once the engine has replaced the byte there is no way +## left to tell an attempted NUL from malformed UTF-8 from a literal +## replacement character, so the only safe reading at a security boundary is +## to refuse all three. A legitimate filename containing U+FFFD is rejected +## too; that false positive is deliberate and vanishingly rare. +static func _contains_invalid_path_codepoint(path: String) -> bool: + for i in path.length(): + var codepoint := path.unicode_at(i) + if codepoint == 0x0000 or codepoint == 0xFFFD: + return true + return false + + +## 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" + if _contains_invalid_path_codepoint(path): + return "Path must not contain null bytes or invalid Unicode" + 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" + if _contains_invalid_path_codepoint(path): + return "Path must not contain null bytes or invalid Unicode" + 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: + # Normalize once, then reuse for filename and segment checks. The writer + # (`FileAccess::fix_path` / `FileAccessWindows`) folds `\`, `//`, and `.` + # the same way, so `res://project.godot/.` and `res://override.cfg/.` must + # not slip past `get_file()` as a trailing `.` while still opening the + # protected file. + # + # - `res://.godot\uid_cache.bin` used to collapse to one fused segment + # and match neither this check nor the loaded-plugin one below, while the + # write itself still landed in `.godot/`. + # - a `.` segment shifts the *anchored* plugin-tree check below, so + # `res://./addons/godot_ai/plugin.gd` slipped past it even once the + # separators were normalized. + # + # `..` never reaches here -- `_confine_under` refuses it first -- so its + # folding is inert. Because the engine normalizes backslashes on Linux too, + # a POSIX file literally named `addons\godot_ai\x.gd` is not addressable + # through `res://` at all, so normalizing costs no legitimate write. + var normalized := path.replace("\\", "/").simplify_path() + var file_lower := normalized.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 := normalized.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/plugin_reload.gd b/addons/godot_ai/utils/plugin_reload.gd new file mode 100644 index 0000000..5577f51 --- /dev/null +++ b/addons/godot_ai/utils/plugin_reload.gd @@ -0,0 +1,125 @@ +@tool +extends RefCounted + +## Ordinary in-editor reload only. Transaction activation keeps its own +## quiescence, lock and readiness protocol; this is not a hot-update grant. +const PLUGIN_CFG := "res://addons/godot_ai/plugin.cfg" +const ScriptWork := preload("res://addons/godot_ai/utils/script_work.gd") +## A reload waits for the filesystem scan it asks for. An editor that is +## still importing at startup can take well over 5 s to answer on a slow +## host, and a reload abandoned then leaves the plugin unchanged while the +## caller keeps waiting; stay under the server's 90 s reconnect budget. +const SCAN_TIMEOUT_SECONDS := 60.0 +## The native timer's budget; tests shorten it to prove the timer itself +## (not its length) settles an unfinished scan under a paused time scale. +static var _scan_timeout_seconds: float = SCAN_TIMEOUT_SECONDS + +## One command-scoped native-signal handoff, not a suspended GDScript frame. +## Named callbacks survive the source reload caused by the scan itself. +static var _pending_scan: Dictionary = {} + + +static func is_reload_pending() -> bool: + return not _pending_scan.is_empty() + + +## Reserve before deferring the scan so the current dispatcher tick cannot +## start another handler which pumps the already-scheduled reload callback. +static func reserve_reload() -> int: + if is_reload_pending(): + return 0 + var work := ScriptWork.begin("reload_plugin") + _pending_scan = {"work": work} + return work + + +static func reload_after_scan(work: int = 0) -> void: + if work == 0: + work = reserve_reload() + if work == 0: + return + elif _pending_scan.get("work", 0) != work or bool(_pending_scan.get("toggling", false)): + return # A direct reload or cancellation consumed this deferred request. + _start_scan(EditorInterface.get_resource_filesystem(), null, work) + + +static func _start_scan(filesystem: Object, timer: Object, work: int) -> void: + if not _pending_scan.is_empty() and ( + _pending_scan.get("work", 0) != work or _pending_scan.has("filesystem") + or bool(_pending_scan.get("toggling", false)) + ): + if _pending_scan.get("work", 0) != work: + ScriptWork.finish(work) + push_error("MCP | a plugin reload is already waiting for its filesystem scan") + return + if timer == null: + # Editor deadlines must not inherit a project's pause or time scale. + timer = Engine.get_main_loop().create_timer(_scan_timeout_seconds, true, false, true) + var complete := _finish_scan.bind(work, false) + var timeout := _finish_scan.bind(work, true) + _pending_scan = {"work": work, "filesystem": filesystem, "timer": timer, + "complete": complete, "timeout": timeout} + filesystem.filesystem_changed.connect(complete, CONNECT_ONE_SHOT | CONNECT_DEFERRED) + timer.timeout.connect(timeout, CONNECT_ONE_SHOT) + filesystem.scan() + + +static func _take_scan() -> Dictionary: + var pending := _pending_scan + _pending_scan = {} + if pending.has("filesystem"): + if pending.filesystem.filesystem_changed.is_connected(pending.complete): + pending.filesystem.filesystem_changed.disconnect(pending.complete) + if pending.timer.timeout.is_connected(pending.timeout): + pending.timer.timeout.disconnect(pending.timeout) + return pending + + +static func _finish_scan(work: int, timed_out: bool) -> void: + # A queued callback from an earlier scan can never consume a later request. + if _pending_scan.get("work", 0) != work or not _pending_scan.has("filesystem"): + return + if timed_out: + var pending := _take_scan() + ScriptWork.finish(pending.work) + push_error( + "MCP | filesystem scan did not finish within %d s; plugin left unchanged, retry reload" + % int(_scan_timeout_seconds) + ) + else: + reload_enabled_plugin() + + +static func reload_enabled_plugin() -> Error: + # Keep the existing reservation through teardown: toggling the plugin can + # itself pump frames. Direct callers also gate dispatch during the toggle. + if not is_reload_pending(): + reserve_reload() + elif bool(_pending_scan.get("toggling", false)): + return ERR_BUSY + # Disconnect scan signals before a toggle pumps frames, but retain the + # work reservation so already-queued callbacks cannot start another toggle. + var pending := _take_scan() + _pending_scan = {"work": pending.work, "toggling": true} + var result := _toggle_enabled_plugin() + _take_scan() + ScriptWork.finish(pending.work) + return result + + +static func _toggle_enabled_plugin() -> Error: + if not EditorInterface.is_plugin_enabled(PLUGIN_CFG): + push_error("MCP | cannot reload a disabled plugin") + return ERR_UNAVAILABLE + EditorInterface.set_plugin_enabled(PLUGIN_CFG, false) + EditorInterface.set_plugin_enabled(PLUGIN_CFG, true) + if not EditorInterface.is_plugin_enabled(PLUGIN_CFG): + push_error("MCP | plugin could not be re-enabled after reload") + return FAILED + ## Plugin callbacks save autoload changes while the temporary disabled state + ## is still visible. Persist only after set_plugin_enabled has completed, or + ## the next editor process can reopen with this working plugin disabled. + var error := ProjectSettings.save() + if error != OK: + push_error("MCP | reloaded plugin enablement could not be saved: %s" % error_string(error)) + return error diff --git a/addons/godot_ai/utils/plugin_reload.gd.uid b/addons/godot_ai/utils/plugin_reload.gd.uid new file mode 100644 index 0000000..711b7a4 --- /dev/null +++ b/addons/godot_ai/utils/plugin_reload.gd.uid @@ -0,0 +1 @@ +uid://c287g7fv5n3en diff --git a/addons/godot_ai/utils/resource_io.gd b/addons/godot_ai/utils/resource_io.gd new file mode 100644 index 0000000..bde3a51 --- /dev/null +++ b/addons/godot_ai/utils/resource_io.gd @@ -0,0 +1,286 @@ +@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: + var ledger := preload("res://addons/godot_ai/utils/script_work.gd") + var work: int = ledger.begin("write_file") + await _settle_text_write(connection, request_id, path, data) + ledger.finish(work) + + +static func _settle_text_write( + 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/script_work.gd b/addons/godot_ai/utils/script_work.gd new file mode 100644 index 0000000..bb919a4 --- /dev/null +++ b/addons/godot_ai/utils/script_work.gd @@ -0,0 +1,29 @@ +@tool +extends RefCounted + +## A response timeout is not proof that its coroutine has returned. Track +## actual script execution across frame yields, independently of transport +## bookkeeping. This is process-wide deliberately: an older composition may +## still own a coroutine in the same scripts after an ordinary plugin reload. +## Only the main thread may use this ledger; OS workers have separate joins. +static var _next_id := 0 +static var _active: Dictionary = {} + + +static func begin(label: String) -> int: + _next_id += 1 + _active[_next_id] = label + return _next_id + + +static func finish(id: int) -> void: + _active.erase(id) + + +static func quiescence() -> Dictionary: + if not _active.is_empty(): + return { + "ok": false, + "error": "Wait for active editor work before updating: %s" % str(_active.values()), + } + return {"ok": true} diff --git a/addons/godot_ai/utils/script_work.gd.uid b/addons/godot_ai/utils/script_work.gd.uid new file mode 100644 index 0000000..a97dd01 --- /dev/null +++ b/addons/godot_ai/utils/script_work.gd.uid @@ -0,0 +1 @@ +uid://ghcyiy3v3nsk diff --git a/addons/godot_ai/utils/server_authority.gd b/addons/godot_ai/utils/server_authority.gd new file mode 100644 index 0000000..eec177f --- /dev/null +++ b/addons/godot_ai/utils/server_authority.gd @@ -0,0 +1,171 @@ +@tool +extends RefCounted + +const TransportCapability := preload("res://addons/godot_ai/utils/transport_capability.gd") + +## Capability and process-control values used by the lifecycle owner. None of +## these objects performs work or retains another owner. Keeping the three +## grants distinct makes it impossible to turn "I can talk to this server" +## into "I may kill or replace it" by accident. + + +class TransportAuthority: + extends RefCounted + + var _http_port: int + var _ws_port: int + var _server_instance_id: String + var _http_capability: String + var _ws_capability: String + + + func _init( + p_http_port: int, + p_ws_port: int, + p_server_instance_id: String, + p_http_capability: String, + p_ws_capability: String, + ) -> void: + _http_port = p_http_port + _ws_port = p_ws_port + _server_instance_id = p_server_instance_id + _http_capability = p_http_capability + _ws_capability = p_ws_capability + + + func is_valid() -> bool: + return ( + _http_port > 0 + and _http_port <= 65535 + and _ws_port > 0 + and _ws_port <= 65535 + and TransportCapability.is_lower_hex(_server_instance_id, 32) + and TransportCapability.is_http_capability(_http_capability) + and TransportCapability.is_lower_hex(_ws_capability, 64) + and _http_capability != _ws_capability + ) + + + func http_capability() -> String: + return _http_capability + + + func ws_capability() -> String: + return _ws_capability + + + func http_port() -> int: + return _http_port + + + func ws_port() -> int: + return _ws_port + + + func server_instance_id() -> String: + return _server_instance_id + + + ## Safe for UI/status fanout: secrets never leave the lifecycle/connection + ## boundary through a generic Dictionary snapshot. + func public_snapshot() -> Dictionary: + return { + "http_port": _http_port, + "ws_port": _ws_port, + "server_instance_id": _server_instance_id, + } + + +class OwnedProcessGrant: + extends RefCounted + + var _pid: int + var _process_fingerprint: String + var _issued_at_msec: int + + + func _init( + p_pid: int, + p_process_fingerprint: String, + p_issued_at_msec: int, + ) -> void: + _pid = p_pid + _process_fingerprint = p_process_fingerprint + _issued_at_msec = p_issued_at_msec + + + func is_valid() -> bool: + return ( + _pid > 1 + and _pid != OS.get_process_id() + and not _process_fingerprint.is_empty() + and _issued_at_msec > 0 + ) + + + func process_id() -> int: + return _pid + + + func matches(p_pid: int, p_fingerprint: String) -> bool: + return ( + is_valid() + and _pid == p_pid + and _process_fingerprint == p_fingerprint + ) + + +class ReplacementAuthorization: + extends RefCounted + + var _target_instance_id: String + var _target_version: String + var _target_port: int + var _expires_at_msec: int + var _spent := false + + + func _init( + p_target_instance_id: String, + p_target_version: String, + p_target_port: int, + p_expires_at_msec: int, + ) -> void: + _target_instance_id = p_target_instance_id + _target_version = p_target_version + _target_port = p_target_port + _expires_at_msec = p_expires_at_msec + + + func is_available(now_msec: int) -> bool: + return ( + not _spent + and now_msec <= _expires_at_msec + and _target_port > 0 + and not _target_instance_id.is_empty() + and not _target_version.is_empty() + ) + + + ## Spend is intentionally binding and destructive. A caller cannot probe + ## one target and then reuse the same click-authority against another. + func spend( + now_msec: int, + p_target_instance_id: String, + p_target_version: String, + p_target_port: int, + ) -> bool: + if not is_available(now_msec): + return false + if ( + _target_instance_id != p_target_instance_id + or _target_version != p_target_version + or _target_port != p_target_port + ): + return false + _spent = true + return true + + + func is_spent() -> bool: + return _spent diff --git a/addons/godot_ai/utils/server_authority.gd.uid b/addons/godot_ai/utils/server_authority.gd.uid new file mode 100644 index 0000000..7c17adf --- /dev/null +++ b/addons/godot_ai/utils/server_authority.gd.uid @@ -0,0 +1 @@ +uid://2gx74pk3lpq2 diff --git a/addons/godot_ai/utils/server_lifecycle.gd b/addons/godot_ai/utils/server_lifecycle.gd new file mode 100644 index 0000000..b4f1795 --- /dev/null +++ b/addons/godot_ai/utils/server_lifecycle.gd @@ -0,0 +1,1721 @@ +@tool +class_name McpServerLifecycleManager +extends RefCounted + +## One serialized lifecycle episode. Late effect results carry its ID. +signal snapshot_changed(snapshot: Dictionary) +signal transport_ready(ws_port: int, ws_capability: String) +signal transport_cleared(reason: String) +signal effect_requested(episode_id: int, effect: String, payload: Dictionary) +signal startup_finished(path: String) + +const Authority := preload("res://addons/godot_ai/utils/server_authority.gd") +const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd") +const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd") +const ServerState := preload("res://addons/godot_ai/utils/mcp_server_state.gd") +const ServerVersionCheck := preload("res://addons/godot_ai/utils/server_version_check.gd") +const TransportCapability := preload("res://addons/godot_ai/utils/transport_capability.gd") +const UvResolution := preload("res://addons/godot_ai/utils/uv_resolution_policy.gd") + +const DORMANT := "DORMANT" +const STARTING := "STARTING" +const READY := "READY" +const BLOCKED := "BLOCKED" +const RECOVERING := "RECOVERING" +## How long a server launched to replace an occupant may wait for the port. +## A Windows update took 25.262 s from the child's port-wait phase until the +## incumbent released the port. Keep time for those exact identity checks +## and termination before the child gives up. Python caps this at the same +## 60 s; the later capability proof has its own 180 s deadline. +const REPLACEMENT_WAIT_FOR_PORT_MS := 60_000 +## How long the replacement gives its launched server to reach that port +## wait before it kills the occupant. The launch may be uvx installing the +## version the update just brought in; until the server reports it is at its +## bind loop, the occupant keeps serving and the port never looks free to an +## attach bridge that would spawn a backend of its own into the gap. +const REPLACEMENT_LAUNCH_READY_TIMEOUT_MS := 120_000 +const STARTUP_PHASE_WAITING_FOR_PORT := "waiting_for_port" +const STOPPING := "STOPPING" + +const PROBE := "PROBE" +const LAUNCH := "LAUNCH" +const PROVE := "PROVE" +const REPLACE := "REPLACE" +const STOP := "STOP" + +const STATUS_PATH := "/godot-ai/status" +## Normal starts and recovery need the same settling window as post-update +## adoption. A healthy authenticated status response can take over a second. +const DEFAULT_PROBE_TIMEOUT_MS := 3000 +const STALE_PRE_V4_HINT := "stale_pre_v4_server" +const DEFAULT_PROVE_TIMEOUT_MS := 180_000 +const LAUNCH_FINGERPRINT_TIMEOUT_MS := 15_000 +const REPLACEMENT_TTL_MS := 15_000 +## An authenticated endpoint that drops (a late keepalive, a server restart) +## is re-probed automatically with this backoff, each attempt re-proving the +## server exactly as a start does; after the last one the block stays until +## the dock's Restart (#962). A server that stays ready this long earns a +## fresh budget, so a flapping one cannot loop forever. +const ENDPOINT_RECOVERY_DELAYS_SECONDS: Array[float] = [1.0, 2.0, 4.0, 8.0, 16.0] +const ENDPOINT_RECOVERY_STABLE_MS := 60_000 + +var _endpoint_recovery_attempts := 0 +## When the current READY began; a loss after a stable minute earns a fresh budget. +var _ready_since_msec := 0 +## The episode whose re-probe is scheduled; 0 when none is pending. +var _endpoint_recovery_pending_episode := 0 +const MAX_STATUS_BODY_BYTES := 8 * 1024 + +var _episode: Dictionary = _dormant_episode(0) +var _next_episode_id := 0 +var _plan: Dictionary = {} +var _transport +var _process_grant +var _replacement_authorization +var _effect: Dictionary = {} + +func configure(plan: Dictionary) -> void: + ## Capture caller-owned values once. Configuration is intentionally inert. + if not _plan.is_empty(): + return + _plan = plan.duplicate(true) + _plan["http_port"] = int(_plan.get("http_port", ClientConfigurator.DEFAULT_HTTP_PORT)) + _plan["ws_port"] = int(_plan.get("ws_port", ClientConfigurator.DEFAULT_WS_PORT)) + _plan["expected_version"] = str(_plan.get("expected_version", "")) + _plan["capability_path"] = str(_plan.get("capability_path", "")) + _plan["probe_timeout_ms"] = int(_plan.get("probe_timeout_ms", DEFAULT_PROBE_TIMEOUT_MS)) + _plan["prove_timeout_ms"] = int(_plan.get("prove_timeout_ms", DEFAULT_PROVE_TIMEOUT_MS)) + _plan["keep_alive"] = bool(_plan.get("keep_alive", false)) + _plan["automatic_effects"] = bool(_plan.get("automatic_effects", true)) + _plan["defer_effects"] = bool(_plan.get("defer_effects", true)) + +func start_server() -> void: + if _plan.is_empty(): + _block_without_effect("not_configured", "Server lifecycle is not configured.") + return + if str(_episode.get("state")) in [STARTING, RECOVERING, STOPPING]: + return + if _process_grant != null: + force_restart_server() + return + _begin_start_episode() + + +func _begin_start_episode(existing_id := 0, probe := true) -> void: + _cancel_effect() + _replacement_authorization = null + _transport = null + if existing_id <= 0: + _next_episode_id += 1 + existing_id = _next_episode_id + _episode = { + "id": existing_id, + "state": STARTING, + "phase": PROBE if probe else LAUNCH, + "reason": "", + "message": "Probing the configured server endpoint." if probe else "Launching the managed server.", + "ready_kind": "", + "effect": "", + "expected_version": str(_plan.get("expected_version", "")), + "actual_version": "", + "blocked_target": {}, + "launch": {}, + "after_stop": "", + } + _publish() + if not probe: + return + _request_effect(PROBE, { + "http_port": int(_plan.http_port), + "expected_version": str(_plan.expected_version), + "expected_ws_port": int(_plan.ws_port), + "timeout_ms": int(_plan.probe_timeout_ms), + "grant": _process_grant, + }) + + +func stop_server(force_inline := false) -> void: + ## Ordinary duplicate Stop clicks coalesce. Update/exit teardown passes + ## force_inline: it must join even an existing STOP worker before scripts can + ## be replaced, then re-check the same exact grant synchronously. + if str(_episode.get("state")) == STOPPING and not force_inline: + return + _adopt_cancelled_launch(_cancel_effect()) + _next_episode_id += 1 + var launch: Dictionary = _episode.get("launch", {}).duplicate(true) + _episode = _stopping_episode(_next_episode_id, launch, "") + _transport = null + _replacement_authorization = null + transport_cleared.emit("server stopping") + _publish() + if _process_grant == null: + _finish_stop() + return + _request_effect(STOP, _stop_payload(launch), force_inline) + + +func force_restart_server() -> bool: + if not has_managed_server(): + return false + _cancel_effect() + _next_episode_id += 1 + var launch: Dictionary = _episode.get("launch", {}).duplicate(true) + _episode = _stopping_episode(_next_episode_id, launch, "start") + _episode["message"] = "Restarting the managed server." + _transport = null + transport_cleared.emit("server restarting") + _publish() + _request_effect(STOP, _stop_payload(launch)) + return true + + +func authorize_replacement(now_msec := -1, ttl_msec := REPLACEMENT_TTL_MS) -> bool: + if str(_episode.get("state")) != BLOCKED: + return false + var target: Dictionary = _episode.get("blocked_target", {}) + if not bool(target.get("replaceable", false)): + return false + if now_msec < 0: + now_msec = Time.get_ticks_msec() + _replacement_authorization = Authority.ReplacementAuthorization.new( + str(target.get("instance_id", "")), + str(target.get("version", "")), + int(target.get("port", 0)), + now_msec + maxi(0, ttl_msec), + ) + return _replacement_authorization.is_available(now_msec) + + +func replace_authorized(now_msec := -1) -> bool: + if str(_episode.get("state")) != BLOCKED or _replacement_authorization == null: + return false + if now_msec < 0: + now_msec = Time.get_ticks_msec() + var target: Dictionary = _episode.get("blocked_target", {}).duplicate(true) + if not _replacement_authorization.spend( + now_msec, + str(target.get("instance_id", "")), + str(target.get("version", "")), + int(target.get("port", 0)), + ): + _replacement_authorization = null + return false + _replacement_authorization = null + _episode["state"] = RECOVERING + _episode["phase"] = "" + _episode["message"] = "Replacing the explicitly authorized server." + _episode["effect"] = "" + _publish() + ## The replacement launches our server first and kills the target only + ## then, so the port never looks free to a bridge polling to spawn its + ## own backend; the launched server waits for the port to free. + var launch_plan := _plan.duplicate(true) + launch_plan["wait_for_port_ms"] = REPLACEMENT_WAIT_FOR_PORT_MS + _request_effect(REPLACE, { + "target": target, + "timeout_ms": int(_plan.get("probe_timeout_ms", DEFAULT_PROBE_TIMEOUT_MS)), + "launch": launch_plan, + }) + return true + + +func request_replacement() -> bool: + var now := Time.get_ticks_msec() + return authorize_replacement(now) and replace_authorized(now) + + +func can_recover_incompatible_server() -> bool: + return ( + str(_episode.get("state")) == BLOCKED + and bool(_episode.get("blocked_target", {}).get("replaceable", false)) + ) + + +func prepare_for_update_reload() -> Dictionary: + stop_server(true) + var state := str(_episode.get("state", "")) + if state == DORMANT and _process_grant == null: + return {"ok": true} + return { + "ok": false, + "reason": str(_episode.get("reason", "stop_incomplete")), + "message": str(_episode.get( + "message", "The managed server did not stop before update activation." + )), + } + + +func teardown_for_editor_exit(live_snapshot: Dictionary = {}) -> void: + ## A launch effect can create the process immediately before teardown joins + ## it. Recover that exact PID+fingerprint result before deciding whether + ## there is an owned child to stop or intentionally detach. + _adopt_cancelled_launch(_cancel_effect()) + if _process_grant == null: + _detach_to_dormant("adopted server left running") + return + if bool(_plan.get("keep_alive", false)): + _detach_to_dormant("keep_server_on_exit") + return + var live := live_snapshot.duplicate(true) if not live_snapshot.is_empty() else _probe_current_transport() + if active_lease_count(live) > 0: + _detach_to_dormant("active attach lease") + return + stop_server(true) + + +func detach_server(reason := "server left running") -> void: + _detach_to_dormant(reason) + + +func _detach_to_dormant(reason: String) -> void: + _cancel_effect() + _process_grant = null + _transport = null + _replacement_authorization = null + _next_episode_id += 1 + _episode = _dormant_episode(_next_episode_id) + _episode["message"] = reason + transport_cleared.emit(reason) + _publish() + + +func transport_authenticated(version: String) -> void: + if str(_episode.get("state")) != READY: + return + var expected := str(_episode.get("expected_version", "")) + if _server_version_compatibility(version, expected).get("compatible", false): + _episode["actual_version"] = version + _publish() + return + var public_transport: Dictionary = _transport.public_snapshot() if _transport != null else {} + _block( + "incompatible", + "Server v%s does not match plugin v%s. Choose Replace to continue." + % [version, expected], + { + "instance_id": str(public_transport.get("server_instance_id", "")), + "version": version, + "port": int(public_transport.get("http_port", 0)), + "replaceable": not str(public_transport.get("server_instance_id", "")).is_empty(), + }, + ) + + +func transport_lost(reason := "Authenticated server endpoint was lost.") -> void: + if str(_episode.get("state")) != READY: + return + _transport = null + ## Stability is measured from the moment the server became READY, not + ## from when a recovery started, so a slow recovery cannot refresh its + ## own budget and a server that held for a minute does. + if _ready_since_msec != 0 and Time.get_ticks_msec() - _ready_since_msec >= ENDPOINT_RECOVERY_STABLE_MS: + _endpoint_recovery_attempts = 0 + _ready_since_msec = 0 + var limit := ENDPOINT_RECOVERY_DELAYS_SECONDS.size() + if _endpoint_recovery_attempts >= limit: + _block( + "endpoint_lost", + "%s Automatic re-probing gave up after %d attempts; click Restart." % [reason, limit], + ) + return + _endpoint_recovery_attempts += 1 + var delay := float(ENDPOINT_RECOVERY_DELAYS_SECONDS[_endpoint_recovery_attempts - 1]) + _block( + "endpoint_lost", + "%s Re-probing in %ds (attempt %d of %d)." % [ + reason, int(delay), _endpoint_recovery_attempts, limit, + ], + ) + print( + "MCP | server endpoint lost (%s); re-probing in %ds (attempt %d of %d)" + % [reason, int(delay), _endpoint_recovery_attempts, limit] + ) + _endpoint_recovery_pending_episode = int(_episode.get("id", 0)) + if bool(_plan.get("automatic_effects", true)): + _recover_lost_endpoint_after(delay, _endpoint_recovery_pending_episode) + + +## The scheduled re-probe. Only the episode that lost its endpoint, and only +## while its attempt is still pending, may start it: a dock Restart or a +## newer loss in between supersedes the timer. +func recover_lost_endpoint(episode_id: int) -> bool: + if episode_id <= 0 or episode_id != _endpoint_recovery_pending_episode: + return false + if episode_id != int(_episode.get("id", -1)): + _endpoint_recovery_pending_episode = 0 + return false + if str(_episode.get("state")) != BLOCKED or str(_episode.get("reason")) != "endpoint_lost": + _endpoint_recovery_pending_episode = 0 + return false + _endpoint_recovery_pending_episode = 0 + ## Losing a socket does not prove that the shared backend needs restarting. + ## The probe retains ownership only after checking the exact listener again. + _begin_start_episode() + return str(_episode.get("state")) != BLOCKED + + +func _recover_lost_endpoint_after(delay_seconds: float, episode_id: int) -> void: + var tree := Engine.get_main_loop() + if tree is SceneTree: + await (tree as SceneTree).create_timer(delay_seconds).timeout + if not is_instance_valid(self): + return + recover_lost_endpoint(episode_id) + + +func complete_effect(episode_id: int, effect: String, result: Dictionary) -> bool: + if episode_id != int(_episode.get("id", -1)): + return false + if effect != str(_episode.get("effect", "")): + return false + _episode["effect"] = "" + match effect: + PROBE: + _complete_probe(result) + LAUNCH: + _complete_launch(result) + PROVE: + _complete_prove(result) + REPLACE: + _complete_replace(result) + STOP: + _complete_stop(result) + _: + return false + return true + + +func _complete_probe(result: Dictionary) -> void: + match str(result.get("outcome", "blocked")): + "compatible": + var transport = result.get("transport") + if transport == null or not transport.is_valid(): + _block("invalid_transport", "The server returned invalid transport authority.") + return + var disposition := str(result.get("owned_disposition", "")) + if _process_grant != null and disposition == "owned": + _ready("owned", transport, str(result.get("version", ""))) + elif _process_grant == null or disposition in ["gone", "replaced"]: + _process_grant = null + _ready("adopted", transport, str(result.get("version", ""))) + else: + _block("process_authority_mismatch", "Managed process identity could not be proven during recovery. Click Restart to retry.") + "free": + if _process_grant != null: + if str(result.get("owned_disposition", "")) not in ["gone", "replaced"]: + _block("process_authority_mismatch", "The managed process is still running without a proven endpoint. Click Restart to retry.") + return + _process_grant = null + _episode["phase"] = LAUNCH + _episode["message"] = "Launching the managed server." + _publish() + var launch_plan := _plan.duplicate(true) + launch_plan["baseline_instance_id"] = str(result.get("baseline_instance_id", "")) + _request_effect(LAUNCH, launch_plan) + _: + _block( + str(result.get("reason", "occupied")), + str(result.get("message", "The configured endpoint is unavailable.")), + result.get("target", {}), + ) + + +func _complete_launch(result: Dictionary) -> void: + if not bool(result.get("ok", false)): + ## The process usually died before its identity could be captured + ## because it refused to start; it says why in its startup report. + _block( + str(result.get("reason", "launch_failed")), + str(result.get("message", "Server launch failed.")) + + startup_report_summary(str(_plan.get("startup_report", ""))), + ) + return + var pid := int(result.get("pid", 0)) + var fingerprint := str(result.get("fingerprint", "")) + var launch := { + "pid": pid, + "fingerprint": fingerprint, + "http_capability": str(result.get("http_capability", "")), + "ws_capability": str(result.get("ws_capability", "")), + "baseline_instance_id": str(result.get("baseline_instance_id", "")), + } + _process_grant = _owned_process_grant(pid, fingerprint) + if not _process_grant.is_valid(): + _process_grant = null + _block("launch_unproven", "The launched process identity could not be proven.") + return + _episode["launch"] = launch + _episode["phase"] = PROVE + _episode["prove_deadline_msec"] = ( + Time.get_ticks_msec() + int(_plan.get("prove_timeout_ms", DEFAULT_PROVE_TIMEOUT_MS)) + ) + _episode["message"] = "Waiting for the managed server capability record." + _publish() + _request_effect(PROVE, _prove_payload()) + + +func _complete_prove(result: Dictionary) -> void: + if bool(result.get("pending", false)): + _episode["proof_pending_reason"] = str(result.get("reason", "unknown")) + if Time.get_ticks_msec() >= int(_episode.get("prove_deadline_msec", 0)): + _block( + "proof_timeout", + "The managed server proof timed out at %s." + % str(_episode.get("proof_pending_reason", "unknown")) + + startup_report_summary(str(_plan.get("startup_report", ""))), + ) + return + _retry_effect_after(PROVE, _prove_payload(), 0.15) + return + if not bool(result.get("ok", false)): + var message := str(result.get("message", "Managed server proof failed.")) + var pending_reason := str(_episode.get("proof_pending_reason", "")) + if not pending_reason.is_empty(): + message += " Last pending proof: %s." % pending_reason + _block(str(result.get("reason", "proof_failed")), message) + return + var launch: Dictionary = _episode.get("launch", {}) + var pid := int(result.get("pid", 0)) + var fingerprint := str(result.get("fingerprint", "")) + var exact_grant = _owned_process_grant(pid, fingerprint) + if not exact_grant.is_valid(): + _block("process_proof_failed", "The server process identity changed before proof completed.") + return + _process_grant = exact_grant + launch["pid"] = pid + launch["fingerprint"] = fingerprint + _episode["launch"] = launch + _ready("owned", result.get("transport"), str(result.get("version", ""))) + + +func _complete_replace(result: Dictionary) -> void: + if not bool(result.get("ok", false)): + _block(str(result.get("reason", "replacement_failed")), str(result.get("message", "Authorized replacement failed."))) + return + _process_grant = null + _transport = null + var launch: Dictionary = result.get("launch", {}) + if launch.is_empty(): + _begin_start_episode(int(_episode.get("id", 0))) + return + ## Our server was launched before the target was killed and is taking + ## the port now; continue exactly as a fresh launch would, at its proof. + _begin_start_episode(int(_episode.get("id", 0)), false) + _complete_launch(launch) + + +func _complete_stop(result: Dictionary) -> void: + if not bool(result.get("ok", false)): + _block(str(result.get("reason", "stop_refused")), str(result.get("message", "Managed stop authority no longer matches the process."))) + return + _finish_stop() + + +func _finish_stop() -> void: + var continue_with_start := str(_episode.get("after_stop", "")) == "start" + var stopped_pid := get_server_pid() + _process_grant = null + _transport = null + _replacement_authorization = null + var pid_file := str(_plan.get("pid_file", "")) + if not pid_file.is_empty() and FileAccess.file_exists(pid_file): + DirAccess.remove_absolute(pid_file) + var completed_id := int(_episode.get("id", 0)) + _episode = _dormant_episode(completed_id) + _publish() + if stopped_pid > 1: + print("MCP | stopped server (PID %d)" % stopped_pid) + if continue_with_start: + _begin_start_episode() + + +func _ready(kind: String, transport, version: String) -> void: + if transport == null or not transport.is_valid(): + _block("invalid_transport", "The server returned invalid transport authority.") + return + _transport = transport + _ready_since_msec = maxi(1, Time.get_ticks_msec()) + _episode["state"] = READY + _episode["phase"] = "" + _episode["ready_kind"] = kind + _episode["actual_version"] = version + _episode["reason"] = "" + _episode["message"] = "Server ready (%s)." % kind + _episode["blocked_target"] = {} + _episode["launch"] = {} + _publish() + if kind == "owned": + print("MCP | started server (PID %d)" % get_server_pid()) + elif kind == "adopted": + print("MCP | adopted external server") + transport_ready.emit(transport.ws_port(), transport.ws_capability()) + startup_finished.emit(kind) + + +func _block(reason: String, message: String, target: Dictionary = {}) -> void: + _transport = null + _replacement_authorization = null + _episode["state"] = BLOCKED + _episode["phase"] = "" + _episode["reason"] = reason + _episode["message"] = message + _episode["blocked_target"] = target.duplicate(true) + _episode["effect"] = "" + _publish() + transport_cleared.emit(message) + startup_finished.emit("blocked:%s" % reason) + + +func _block_without_effect(reason: String, message: String) -> void: + _next_episode_id += 1 + _episode = _dormant_episode(_next_episode_id) + _episode["state"] = BLOCKED + _episode["reason"] = reason + _episode["message"] = message + _publish() + + +func _request_effect(kind: String, payload: Dictionary, force_inline := false) -> void: + if not _effect.is_empty(): + _block("effect_overlap", "Lifecycle attempted overlapping effects.") + return + _episode["effect"] = kind + _publish() + var episode_id := int(_episode.id) + var copied := payload.duplicate(true) + if not bool(_plan.get("automatic_effects", true)): + effect_requested.emit(episode_id, kind, copied) + return + if force_inline or not bool(_plan.get("defer_effects", true)) or not (Engine.get_main_loop() is SceneTree): + complete_effect(episode_id, kind, _execute_effect(kind, copied)) + return + var thread := Thread.new() + if thread.start(_execute_effect.bind(kind, copied)) != OK: + complete_effect(episode_id, kind, {"ok": false, "reason": "worker_start_failed"}) + return + _effect = {"thread": thread, "episode_id": episode_id, "kind": kind} + _await_effect(thread, episode_id, kind) + + +func _await_effect(thread: Thread, episode_id: int, kind: String) -> void: + var tree := Engine.get_main_loop() + while thread.is_alive() and tree is SceneTree: + await (tree as SceneTree).process_frame + if _effect.get("thread") != thread: + return + var result: Variant = thread.wait_to_finish() + _effect.clear() + if result is Dictionary: + complete_effect(episode_id, kind, result) + else: + complete_effect(episode_id, kind, {"ok": false, "reason": "invalid_effect_result"}) + + +func _cancel_effect() -> Dictionary: + if _effect.is_empty(): + return {} + var kind := str(_effect.get("kind", "")) + var thread: Thread = _effect.get("thread") + _effect.clear() + if thread == null: + return {"kind": kind, "result": {"ok": false, "reason": "missing_effect_thread"}} + var result: Variant = thread.wait_to_finish() + return { + "kind": kind, + "result": ( + result + if result is Dictionary + else {"ok": false, "reason": "invalid_effect_result"} + ), + } + + +## Preserve process authority when cancellation joins a launch that already +## spawned. The caller may then stop by exact fingerprint; it never kills by a +## bare PID and never treats a failed/unproven launch as owned. +func _adopt_cancelled_launch(cancelled: Dictionary) -> bool: + if str(cancelled.get("kind", "")) != LAUNCH: + return false + var result: Dictionary = cancelled.get("result", {}) + if not bool(result.get("ok", false)): + return false + var grant = _owned_process_grant( + int(result.get("pid", 0)), str(result.get("fingerprint", "")) + ) + if not grant.is_valid(): + return false + _process_grant = grant + _episode["launch"] = { + "pid": int(result.get("pid", 0)), + "fingerprint": str(result.get("fingerprint", "")), + "http_capability": str(result.get("http_capability", "")), + "ws_capability": str(result.get("ws_capability", "")), + "baseline_instance_id": str(result.get("baseline_instance_id", "")), + } + return true + + +static func _owned_process_grant(pid: int, fingerprint: String): + return Authority.OwnedProcessGrant.new( + pid, fingerprint, maxi(1, Time.get_ticks_msec()) + ) + + +func _retry_effect_after(kind: String, payload: Dictionary, delay_seconds: float) -> void: + var episode_id := int(_episode.get("id", 0)) + var tree := Engine.get_main_loop() + if tree is SceneTree: + await (tree as SceneTree).create_timer(delay_seconds).timeout + if episode_id == int(_episode.get("id", -1)) and str(_episode.get("state")) == STARTING: + _request_effect(kind, payload) + + +func _execute_effect(kind: String, payload: Dictionary) -> Dictionary: + match kind: + PROBE: + return _effect_probe(payload) + LAUNCH: + return _effect_launch(payload) + PROVE: + return _effect_prove(payload) + REPLACE: + return _effect_replace(payload) + STOP: + return _effect_stop(payload) + return {"ok": false, "reason": "unknown_effect"} + + +func _effect_probe(payload: Dictionary) -> Dictionary: + var port := int(payload.http_port) + var expected_version := str(payload.expected_version) + var expected_ws_port := int(payload.expected_ws_port) + var grant = payload.get("grant") + var disposition := "gone" + if grant != null: + var pid := int(grant.process_id()) + var alive := PortResolver.pid_alive(pid) + disposition = _owned_process_disposition( + grant, alive, PortResolver.process_fingerprint(pid) if alive else "", + ) + if disposition == "unproven": + return _blocked_probe_result("process_authority_mismatch", port, {}, false, + "managed process identity could not be proven; click Restart to retry") + var capability := _read_capability(port) + var live := _probe_with_capability(port, capability, int(payload.timeout_ms)) + if _authenticated_status_matches_record(live, capability): + var version := str(live.get("version", "")) + var ws_port := int(live.get("ws_port", 0)) + if _server_status_compatibility(version, expected_version, ws_port, expected_ws_port).get("compatible", false): + if disposition == "owned" and ( + not PortResolver.find_all_pids_on_port(port).has(int(grant.process_id())) + or not grant.matches(int(grant.process_id()), PortResolver.process_fingerprint(int(grant.process_id()))) + ): + return _blocked_probe_result("process_authority_mismatch", port, live, false, + "the authenticated endpoint does not match the managed process; click Restart to retry") + return { + "outcome": "compatible", + "owned_disposition": disposition, + "version": version, + "transport": _transport_from(port, ws_port, live, capability), + } + return _blocked_probe_result("incompatible", port, live, true) + if PortResolver.is_port_in_use(port): + var detail := _record_probe_failure_detail(capability, live) + var blocked := _blocked_probe_result("occupied", port, live, false, detail) + var pre_v4 := _untrusted_pre_v4_occupant_version(port, int(payload.timeout_ms)) + if not pre_v4.is_empty(): + blocked["message"] = stale_pre_v4_message(port, pre_v4) + blocked["target"]["hint"] = STALE_PRE_V4_HINT + return blocked + ## The server binds both ports before it publishes anything. A held + ## WebSocket port (a server moved off the HTTP port, or another editor) + ## would kill the launch at preflight; say so now and name the setting. + if expected_ws_port > 0 and PortResolver.is_port_in_use(expected_ws_port): + return ws_port_blocked_result(expected_ws_port) + return { + "outcome": "free", + "owned_disposition": disposition, + "baseline_instance_id": str(capability.get("instance_nonce", "")), + } + + +## Why an existing capability record did not authenticate the occupant, so a +## "held by another process" report can be acted on. Empty when there was no +## record to try, or when the probe succeeded and the mismatch is elsewhere. +static func _record_probe_failure_detail(capability: Dictionary, live: Dictionary) -> String: + if str(capability.get("http", "")).is_empty(): + return "" + var error := str(live.get("error", "")).strip_edges() + if error.is_empty(): + if str(live.get("name", "")) != "godot-ai": + return "a godot-ai record for this port exists, but the listener did not answer as godot-ai" + return "a godot-ai record for this port exists, but it belongs to a different server instance" + return "a godot-ai record for this port exists, but its status probe failed: %s" % error + + +static func ws_port_blocked_result(ws_port: int) -> Dictionary: + return { + "outcome": "blocked", + "reason": "ws_occupied", + "message": ( + "WebSocket port %d is already in use by another process. " + + "Set `godot_ai/ws_port` in Editor Settings to a free port " + + "(the dock's port picker moves both ports), then reconfigure your AI clients." + ) % ws_port, + "target": {"instance_id": "", "version": "", "port": ws_port, "replaceable": false}, + } + + +func _effect_launch(payload: Dictionary) -> Dictionary: + var port := int(payload.http_port) + if bool(payload.get("http_port_reserved", false)): + return {"ok": false, "reason": "port_reserved", "message": "Port %d is reserved by Windows." % port} + var server_command: Array = payload.get("server_command", []) + if server_command.is_empty(): + return {"ok": false, "reason": "no_command", "message": "No godot-ai server command was found."} + var listener_problem := PortResolver.listener_tools_problem() + if not listener_problem.is_empty(): + return {"ok": false, "reason": "listener_tools_missing", "message": listener_problem} + ## Do not spawn into a directory the server cannot publish from (#988): + ## the failure would only surface as a proof timeout three minutes later. + var directory_problem := TransportCapability.directory_write_problem(port) + if not directory_problem.is_empty(): + return {"ok": false, "reason": "capability_dir_unwritable", "message": directory_problem} + var args: Array[String] = [] + args.assign(server_command.slice(1)) + args.append_array(_server_flags(payload)) + var environment := { + "GODOT_AI_PLUGIN_SPAWNED": "1", + } + if int(payload.get("wait_for_port_ms", 0)) > 0: + environment["GODOT_AI_WAIT_FOR_PORT_MS"] = str(int(payload.get("wait_for_port_ms", 0))) + if OS.get_name() != "Windows" and not bool(payload.get("keep_alive", false)): + environment["GODOT_AI_OWNER_PID"] = str(OS.get_process_id()) + if bool(payload.get("keep_alive", false)): + environment["GODOT_AI_NO_IDLE_EXIT"] = "1" + if bool(payload.get("disable_telemetry", false)): + environment["GODOT_AI_DISABLE_TELEMETRY"] = "true" + var worktree_src := str(payload.get("worktree_src", "")) + if not worktree_src.is_empty(): + var old_pythonpath := str(payload.get("ambient_pythonpath", "")) + var separator := ";" if OS.get_name() == "Windows" else ":" + environment["PYTHONPATH"] = ( + worktree_src if old_pythonpath.is_empty() else worktree_src + separator + old_pythonpath + ) + var pid_file := str(payload.get("pid_file", "")) + if not pid_file.is_empty() and FileAccess.file_exists(pid_file): + DirAccess.remove_absolute(pid_file) + var startup_report := str(payload.get("startup_report", "")) + if not startup_report.is_empty() and FileAccess.file_exists(startup_report): + DirAccess.remove_absolute(startup_report) + ## A report this launch could not clear would be read as this + ## launch's: a stale "waiting_for_port" phase would let a replacement + ## kill its occupant before the new server holds the port. + if FileAccess.file_exists(startup_report): + return { + "ok": false, + "reason": "stale_startup_report", + "message": "A previous server's startup report could not be removed: %s" % startup_report, + } + ## Names this launch in what the server reports, so a phase read from the + ## report is never another launch's. + var launch_id := "%d-%d-%d" % [OS.get_process_id(), Time.get_ticks_usec(), randi()] + environment["GODOT_AI_LAUNCH_ID"] = launch_id + var spawned := spawn_capability_process(str(server_command[0]), args, environment) + var pid := int(spawned.pid) + if pid <= 1: + return {"ok": false, "reason": "launch_failed", "message": "The server process could not be launched."} + ## OS.create_process can return before POSIX exec replaces the child image. + ## Capture only after the command is branded and its exact fingerprint is + ## stable across two reads; otherwise a legitimate exec looks like PID reuse. + var capture_started := Time.get_ticks_msec() + var attempts: Array = [] + var exact_grant := PortResolver.capture_process_kill_grant(pid, true, attempts) + var fingerprint_deadline := capture_started + LAUNCH_FINGERPRINT_TIMEOUT_MS + while exact_grant.is_empty() and Time.get_ticks_msec() < fingerprint_deadline: + OS.delay_msec(100) + exact_grant = PortResolver.capture_process_kill_grant(pid, true, attempts) + var fingerprint := str(exact_grant.get("fingerprint", "")) + return { + "ok": not fingerprint.is_empty(), + "reason": "launch_unproven" if fingerprint.is_empty() else "", + "message": ( + _launch_unproven_message(pid, attempts, Time.get_ticks_msec() - capture_started) + if fingerprint.is_empty() + else "" + ), + "pid": pid, + "fingerprint": fingerprint, + "http_capability": spawned.http, + "ws_capability": spawned.websocket, + "baseline_instance_id": str(payload.get("baseline_instance_id", "")), + "launch_id": launch_id, + } + + +## Name which identity check failed so a Windows report can be acted on +## (#988, #1012 both surfaced as this bare sentence). Diagnostic only: the +## values are read once more after the capture budget and never grant +## authority. +func _launch_unproven_message(pid: int, attempts: Array, elapsed_ms: int) -> String: + var snapshot: Variant = _capture_process_snapshot(pid) + var alive := PortResolver.pid_alive(pid, snapshot) + var commandline := PortResolver.process_commandline(pid, snapshot) if alive else "" + if commandline.length() > 200: + commandline = commandline.substr(0, 199) + "…" + ## Summarise the per-attempt refusals as "reason×count" in first-seen order. + var counts := {} + var order: Array[String] = [] + for entry in attempts: + var key := str(entry) + if not counts.has(key): + counts[key] = 0 + order.append(key) + counts[key] = int(counts[key]) + 1 + var summary: Array[String] = [] + for key in order: + summary.append("%s×%d" % [key, int(counts[key])]) + return ( + "The launched process identity could not be captured in %d attempts over %.1f s " + + "(pid %d, now alive=%s, refusals: %s%s)." + ) % [ + attempts.size(), + elapsed_ms / 1000.0, + pid, + "unknown" if PortResolver.capture_failed(snapshot) else ("yes" if alive else "no"), + ", ".join(summary) if not summary.is_empty() else "none recorded", + "" if commandline.is_empty() else "; command: " + commandline, + ] + + +func _capture_process_snapshot(pid: int) -> Variant: + return PortResolver.capture_process_snapshot(pid) + + +func _effect_prove(payload: Dictionary) -> Dictionary: + var launch_grant = payload.get("grant") + var launch_pid := int(launch_grant.process_id()) if launch_grant != null else -1 + ## The pid file is an untrusted hint. Reuse its ancestor snapshot only + ## when it includes the launcher; the original PID read and all final + ## capability, listener, identity, and lineage checks still follow. + var hinted_pid := -1 + var first_snapshot: Variant = null + var launch_snapshot: Variant = null + if OS.get_name() == "Windows" and launch_pid > 1: + hinted_pid = PortResolver.read_pid_file(str(payload.get("pid_file", ""))) + if hinted_pid > 1: + first_snapshot = _capture_process_snapshot(hinted_pid) + if PortResolver.process_descends_from(hinted_pid, launch_pid, first_snapshot): + launch_snapshot = first_snapshot + if launch_snapshot == null: + launch_snapshot = _capture_process_snapshot(launch_pid) + first_snapshot = null + if PortResolver.capture_failed(launch_snapshot): + return {"pending": true, "reason": "identity_unavailable"} + var launch_alive := PortResolver.pid_alive(launch_pid, launch_snapshot) + var launch_fingerprint := ( + PortResolver.process_fingerprint(launch_pid, launch_snapshot) + if launch_alive + else "" + ) + var launch_disposition := _owned_process_disposition( + launch_grant, + launch_alive, + launch_fingerprint, + ) + if launch_disposition != "owned": + return { + "ok": false, + "reason": "launch_%s" % launch_disposition, + "message": ( + "The launched server process exited or changed identity before publishing capabilities." + + startup_report_summary(str(payload.get("startup_report", ""))) + ), + } + var port := int(payload.http_port) + var capability := _read_capability(port) + if capability.is_empty() or str(capability.get("instance_nonce", "")) == str(payload.get("baseline_instance_id", "")): + return {"pending": true, "reason": "capability_record"} + if ( + str(capability.get("http", "")) != str(payload.get("http_capability", "")) + or str(capability.get("websocket", "")) != str(payload.get("ws_capability", "")) + ): + return {"pending": true, "reason": "capability_pair"} + var live := _probe_with_capability(port, capability, int(payload.timeout_ms)) + if not _authenticated_status_matches_record(live, capability): + return {"pending": true, "reason": "authenticated_status"} + var version := str(live.get("version", "")) + var ws_port := int(live.get("ws_port", 0)) + if not _server_status_compatibility( + version, str(payload.expected_version), ws_port, int(payload.expected_ws_port) + ).get("compatible", false): + return {"ok": false, "reason": "incompatible", "message": "The launched server does not match this plugin."} + var pid := PortResolver.read_pid_file(str(payload.get("pid_file", ""))) + if pid <= 1: + return {"pending": true, "reason": "pid_file"} + if not PortResolver.find_all_pids_on_port(port).has(pid): + return {"pending": true, "reason": "listener_pid"} + if first_snapshot == null or pid != hinted_pid: + first_snapshot = ( + launch_snapshot if pid == launch_pid else _capture_process_snapshot(pid) + ) + if PortResolver.capture_failed(first_snapshot): + return {"pending": true, "reason": "identity_unavailable"} + if not PortResolver.pid_cmdline_is_godot_ai(pid, first_snapshot): + return {"pending": true, "reason": "process_brand"} + if not PortResolver.process_descends_from(pid, launch_pid, first_snapshot): + return {"pending": true, "reason": "launch_lineage"} + var fingerprint := PortResolver.process_fingerprint(pid, first_snapshot) + if fingerprint.is_empty(): + return {"pending": true, "reason": "process_fingerprint"} + ## Close every capture window before minting process authority. The same + ## launch-lineage PID must still own the listener, the private capability + ## record must still name the authenticated instance, and the process + ## fingerprint must remain unchanged after the final probe. + var final_capability := _read_capability(port) + var final_live := _probe_with_capability(port, final_capability, int(payload.timeout_ms)) + var final_snapshot: Variant = _capture_process_snapshot(pid) + if PortResolver.capture_failed(final_snapshot): + return {"pending": true, "reason": "identity_unavailable"} + if ( + PortResolver.read_pid_file(str(payload.get("pid_file", ""))) != pid + or not PortResolver.find_all_pids_on_port(port).has(pid) + or not PortResolver.process_descends_from(pid, launch_pid, final_snapshot) + or not PortResolver.pid_cmdline_is_godot_ai(pid, final_snapshot) + or PortResolver.process_fingerprint(pid, final_snapshot) != fingerprint + or final_capability != capability + or not _authenticated_status_matches_record(final_live, final_capability) + or str(final_live.get("instance_id", "")) != str(live.get("instance_id", "")) + ): + return {"pending": true, "reason": "final_capture_window"} + var final_version := str(final_live.get("version", "")) + var final_ws_port := int(final_live.get("ws_port", 0)) + if not _server_status_compatibility( + final_version, + str(payload.expected_version), + final_ws_port, + int(payload.expected_ws_port), + ).get("compatible", false): + return { + "ok": false, + "reason": "incompatible", + "message": "The launched server changed compatibility before authority was granted.", + } + return { + "ok": true, + "pid": pid, + "fingerprint": fingerprint, + "version": final_version, + "transport": _transport_from(port, final_ws_port, final_live, final_capability), + } + + +func _effect_replace(payload: Dictionary) -> Dictionary: + var target: Dictionary = payload.get("target", {}) + var port := int(target.get("port", 0)) + var capability := _read_capability(port) + var live := _probe_with_capability(port, capability, int(payload.timeout_ms)) + if not _replacement_target_matches(target, live, capability): + return {"ok": false, "reason": "replacement_target_changed", "message": "The authorized server changed before replacement."} + var candidates: Array[int] = [] + for pid in PortResolver.find_all_pids_on_port(port): + if PortResolver.pid_cmdline_is_godot_ai(pid): + candidates.append(pid) + if candidates.size() != 1: + return {"ok": false, "reason": "replacement_unproven", "message": "The authorized server process could not be proven."} + var pid := candidates[0] + var fingerprint := PortResolver.process_fingerprint(pid) + if fingerprint.is_empty(): + return {"ok": false, "reason": "replacement_unproven", "message": "The authorized server process could not be fingerprinted."} + # Reprobe, then bind the kill to the captured PID + start fingerprint. + live = _probe_with_capability(port, capability, int(payload.timeout_ms)) + if ( + not _replacement_target_matches(target, live, capability) + or not PortResolver.find_all_pids_on_port(port).has(pid) + or PortResolver.process_fingerprint(pid) != fingerprint + ): + return {"ok": false, "reason": "replacement_target_changed", "message": "The authorized server changed before replacement."} + ## Launch our server before the kill so the port passes straight from + ## the target to it (the server waits for the port); a bridge polling + ## for a free port to spawn its own backend never sees one. The launch + ## baseline is the record the target published, which the proof must + ## see replaced. + var launch_plan: Dictionary = payload.get("launch", {}) + var launch := {} + if not launch_plan.is_empty(): + launch_plan = launch_plan.duplicate(true) + launch_plan["baseline_instance_id"] = str(capability.get("instance_nonce", "")) + launch = _effect_launch(launch_plan) + if not bool(launch.get("ok", false)): + return { + "ok": false, + "reason": str(launch.get("reason", "launch_failed")), + "message": str(launch.get("message", "Server launch failed.")), + } + ## Kill only once the launched server is at its bind loop; a launch + ## that spends seconds getting there (uvx installing the new version) + ## would otherwise leave the port free for a bridge to spawn into. + var ready := _wait_for_launch_port_wait( + str(launch_plan.get("startup_report", "")), + str(launch.get("launch_id", "")), + int(launch.pid), + str(launch.fingerprint), + ) + if not bool(ready.get("ok", false)): + PortResolver.kill_exact_processes( + [{"pid": int(launch.pid), "fingerprint": str(launch.fingerprint)}], true + ) + return {"ok": false, "reason": str(ready.reason), "message": str(ready.message)} + if ( + not PortResolver.find_all_pids_on_port(port).has(pid) + or PortResolver.process_fingerprint(pid) != fingerprint + ): + PortResolver.kill_exact_processes( + [{"pid": int(launch.pid), "fingerprint": str(launch.fingerprint)}], true + ) + return {"ok": false, "reason": "replacement_target_changed", "message": "The authorized server changed before replacement."} + var killed := PortResolver.kill_exact_processes( + [{"pid": pid, "fingerprint": fingerprint}], + true, + ) + if not killed.has(pid): + if not launch.is_empty(): + PortResolver.kill_exact_processes( + [{"pid": int(launch.pid), "fingerprint": str(launch.fingerprint)}], true + ) + return {"ok": false, "reason": "replacement_failed"} + if launch.is_empty(): + PortResolver.wait_for_port_free(port, 5.0) + return {"ok": not PortResolver.is_port_in_use(port), "reason": ""} + return {"ok": true, "reason": "", "launch": launch} + + +## Poll the launched server's startup report until it says the port wait is +## running, the process is gone, or the budget is spent. No report path +## (a plan without one) keeps the old ordering: kill straight away. +func _wait_for_launch_port_wait( + startup_report: String, launch_id: String, pid: int, fingerprint: String +) -> Dictionary: + if startup_report.is_empty(): + return {"ok": true} + var deadline := Time.get_ticks_msec() + REPLACEMENT_LAUNCH_READY_TIMEOUT_MS + while true: + var reached := launch_reached_port_wait(startup_report, launch_id) + ## The phase counts only from a process still there to hold the + ## port: one that exited after writing it must not cost the occupant. + if PortResolver.process_fingerprint(pid) != fingerprint: + return { + "ok": false, + "reason": "replacement_launch_exited", + "message": ( + "The replacement server exited before it could wait for the port." + + startup_report_summary(startup_report) + ), + } + if reached: + return {"ok": true} + if Time.get_ticks_msec() >= deadline: + return { + "ok": false, + "reason": "replacement_launch_stalled", + "message": "The replacement server did not reach its port wait within %d s." % int(REPLACEMENT_LAUNCH_READY_TIMEOUT_MS / 1000), + } + OS.delay_msec(100) + return {"ok": true} + + +## Whether the startup report records the server of `launch_id` at its port +## wait (the phase it writes before its first bind attempt when the plugin +## asked it to wait for the port). A failure written later replaces the +## phase; a report naming another launch, or none, is never this launch's. +static func launch_reached_port_wait(startup_report: String, launch_id: String) -> bool: + if startup_report.is_empty() or launch_id.is_empty() or not FileAccess.file_exists(startup_report): + return false + var file := FileAccess.open(startup_report, FileAccess.READ) + if file == null: + return false + var raw := file.get_as_text().strip_edges() + file.close() + if raw.is_empty() or raw.length() > 8192: + return false + var parsed: Variant = JSON.parse_string(raw) + if not (parsed is Dictionary): + return false + return ( + str(parsed.get("phase", "")) == STARTUP_PHASE_WAITING_FOR_PORT + and str(parsed.get("launch_id", "")) == launch_id + ) + + +func _effect_stop(payload: Dictionary) -> Dictionary: + var grant = payload.get("grant") + if grant == null: + return {"ok": true} + var pid := int(grant.process_id()) + var port := int(payload.get("http_port", 0)) + var alive := PortResolver.pid_alive(pid) + var fingerprint := PortResolver.process_fingerprint(pid) if alive else "" + var disposition := _owned_process_disposition(grant, alive, fingerprint) + if disposition == "unproven": + return {"ok": false, "reason": "process_authority_mismatch", "message": "Managed process identity no longer matches its stop grant."} + var grants: Array[Dictionary] = [] + var server_grant := _launched_server_kill_grant(payload, pid) + if not server_grant.is_empty(): + grants.append(server_grant) + if disposition == "owned": + grants.append({"pid": pid, "fingerprint": fingerprint}) + PortResolver.kill_exact_processes(grants, true) + if port > 0 and not grants.is_empty(): + PortResolver.wait_for_port_free(port, 3.0) + var targets_gone := true + for exact in grants: + if PortResolver.process_fingerprint(int(exact.pid)) == str(exact.fingerprint): + targets_gone = false + var port_free := port <= 0 or not PortResolver.is_port_in_use(port) + ## Killing something of ours must also free its listener. When there was + ## nothing of ours left to kill, whoever holds the port now is not ours to + ## prove stopped; the next start's probe deals with that occupant. + var stopped := targets_gone and (grants.is_empty() or port_free) + return { + "ok": stopped, + "already_gone": grants.is_empty() and port_free, + "reason": "" if stopped else "process_stop_failed", + "message": "" if stopped else "A launched server process or listener could not be proven stopped.", + } + + +func _launched_server_kill_grant(payload: Dictionary, launch_pid: int) -> Dictionary: + var launch: Dictionary = payload.get("launch", {}) + if launch.is_empty(): + return {} + var server_pid := PortResolver.read_pid_file(str(payload.get("pid_file", ""))) + var port := int(payload.get("http_port", 0)) + if ( + server_pid <= 1 + or server_pid == launch_pid + or not PortResolver.process_descends_from(server_pid, launch_pid) + or not PortResolver.find_all_pids_on_port(port).has(server_pid) + ): + return {} + var capability := _read_capability(port) + if ( + str(capability.get("http", "")) != str(launch.get("http_capability", "")) + or str(capability.get("websocket", "")) != str(launch.get("ws_capability", "")) + ): + return {} + var live := _probe_with_capability( + port, capability, int(payload.get("timeout_ms", DEFAULT_PROBE_TIMEOUT_MS)) + ) + if not _authenticated_status_matches_record(live, capability): + return {} + var exact := PortResolver.capture_process_kill_grant(server_pid, true) + if exact.is_empty(): + return {} + if ( + PortResolver.read_pid_file(str(payload.get("pid_file", ""))) != server_pid + or not PortResolver.process_descends_from(server_pid, launch_pid) + or not PortResolver.find_all_pids_on_port(port).has(server_pid) + or PortResolver.process_fingerprint(server_pid) != str(exact.fingerprint) + ): + return {} + return exact + + +static func _owned_process_disposition(grant, alive: bool, fingerprint: String) -> String: + if grant == null: + return "gone" + if not alive: + return "gone" + var pid := int(grant.process_id()) + if fingerprint.is_empty(): + return "unproven" + if not grant.matches(pid, fingerprint): + return "replaced" + return "owned" + + +func _prove_payload() -> Dictionary: + var launch: Dictionary = _episode.get("launch", {}) + return { + "http_port": int(_plan.http_port), + "expected_ws_port": int(_plan.ws_port), + "expected_version": str(_plan.expected_version), + "timeout_ms": int(_plan.probe_timeout_ms), + "pid_file": str(_plan.get("pid_file", "")), + "startup_report": str(_plan.get("startup_report", "")), + "http_capability": str(launch.get("http_capability", "")), + "ws_capability": str(launch.get("ws_capability", "")), + "baseline_instance_id": str(launch.get("baseline_instance_id", "")), + "grant": _process_grant, + } + + +func _stop_payload(launch: Dictionary) -> Dictionary: + return { + "grant": _process_grant, + "http_port": int(_plan.get("http_port", 0)), + "pid_file": str(_plan.get("pid_file", "")), + "timeout_ms": int(_plan.get("probe_timeout_ms", DEFAULT_PROBE_TIMEOUT_MS)), + "launch": launch.duplicate(true), + } + + +func _probe_current_transport() -> Dictionary: + if _transport == null: + return {} + return _probe_with_capability( + _transport.http_port(), + {"http": _transport.http_capability()}, + int(_plan.get("probe_timeout_ms", DEFAULT_PROBE_TIMEOUT_MS)), + ) + + +func _read_capability(port: int) -> Dictionary: + return TransportCapability.read_for_http_port( + port, str(_plan.get("capability_path", "")) + ) + + +static func probe_live_server_status( + port: int, timeout_ms := DEFAULT_PROBE_TIMEOUT_MS, captured_path := "" +) -> Dictionary: + var capability := TransportCapability.read_for_http_port(port, captured_path) + return _probe_with_capability(port, capability, timeout_ms) + + +static func _probe_with_capability(port: int, capability: Dictionary, timeout_ms: int) -> Dictionary: + var result := {"reachable": false, "name": "", "version": "", "ws_port": 0, "instance_id": "", "status_code": 0, "error": ""} + var http_capability := str(capability.get("http", "")) + if http_capability.is_empty(): + result.error = "missing_capability" + return result + ## Windows can otherwise spend the whole connect deadline on an unbound + ## loopback port. This is only an unreachable probe; callers still check + ## occupancy and prove any later listener before using it. + if OS.get_name() == "Windows" and PortResolver.can_bind_local_port(port): + result.error = "port_unbound" + return result + var client := HTTPClient.new() + var error := client.connect_to_host("127.0.0.1", port) + if error != OK: + result.error = "connect_%d" % error + return result + var deadline := Time.get_ticks_msec() + maxi(1, timeout_ms) + while client.get_status() in [HTTPClient.STATUS_RESOLVING, 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 + error = client.request(HTTPClient.METHOD_GET, STATUS_PATH, [ + "Accept: application/json", "Authorization: Bearer %s" % http_capability, + ]) + if error != OK: + result.error = "request_%d" % error + 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 body.size() + chunk.size() > MAX_STATUS_BODY_BYTES: + result.error = "response_too_large" + return result + 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) + result.status_code = client.get_response_code() + if int(result.status_code) != 200: + result.error = "http_%d" % int(result.status_code) + return result + var parsed: Variant = 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 + + +static func project_status_payload(parsed: Dictionary) -> Dictionary: + var projected := { + "reachable": true, + "name": str(parsed.get("name", "")), + "version": str(parsed.get("server_version", parsed.get("version", ""))), + "ws_port": int(parsed.get("ws_port", 0)), + "instance_id": str(parsed.get("instance_id", "")), + "package_path": str(parsed.get("package_path", "")), + } + var leases: Variant = parsed.get("active_lease_count") + if leases is int or leases is float: + var numeric := float(leases) + if is_finite(numeric) and numeric == floor(numeric): + projected.active_lease_count = maxi(0, int(numeric)) + var telemetry: Variant = parsed.get("telemetry_enabled") + if telemetry is bool: + projected.telemetry_enabled = telemetry + return projected + + +static func _authenticated_status_matches_record(live: Dictionary, capability: Dictionary) -> bool: + return ( + bool(live.get("reachable", false)) + and str(live.get("name", "")) == "godot-ai" + and not str(capability.get("instance_nonce", "")).is_empty() + and str(live.get("instance_id", "")) == str(capability.get("instance_nonce", "")) + ) + + +static func _replacement_target_matches( + target: Dictionary, live: Dictionary, capability: Dictionary +) -> bool: + return ( + _authenticated_status_matches_record(live, capability) + and str(live.get("instance_id", "")) == str(target.get("instance_id", "")) + and str(live.get("version", "")) == str(target.get("version", "")) + ) + + +static func _transport_from(port: int, ws_port: int, live: Dictionary, capability: Dictionary): + return Authority.TransportAuthority.new( + port, + ws_port, + str(live.get("instance_id", "")), + str(capability.get("http", "")), + str(capability.get("websocket", "")), + ) + + +static func _blocked_probe_result( + reason: String, port: int, live: Dictionary, allow_replacement := false, detail := "" +) -> Dictionary: + var instance_id := str(live.get("instance_id", "")) + var version := str(live.get("version", "")) + var replaceable := ( + allow_replacement + and str(live.get("name", "")) == "godot-ai" + and not instance_id.is_empty() + and not version.is_empty() + ) + var message := "Port %d is occupied by another process." % port + if not detail.is_empty(): + message = "Port %d is occupied by another process (%s)." % [port, detail] + if replaceable: + message = "Port %d is occupied by godot-ai v%s; choose Replace to continue." % [port, version] + return { + "outcome": "blocked", + "reason": reason, + "message": message, + "target": {"instance_id": instance_id, "version": version, "port": port, "replaceable": replaceable}, + } + + +static func generate_capability_pair() -> Dictionary: + var http := _random_hex(32) + var websocket := _random_hex(32) + while websocket == http: + websocket = _random_hex(32) + return {"http": http, "websocket": websocket} + + +static func spawn_capability_process( + command: String, args: Array[String], extra_environment: Dictionary = {} +) -> Dictionary: + var pair := generate_capability_pair() + var environment := extra_environment.duplicate(true) + environment["GODOT_AI_HTTP_CAPABILITY"] = pair.http + environment["GODOT_AI_WS_TOKEN"] = pair.websocket + return { + "pid": _create_process_with_environment(command, args, environment), + "http": pair.http, + "websocket": pair.websocket, + } + + +static func _random_hex(byte_count: int) -> String: + return Crypto.new().generate_random_bytes(byte_count).hex_encode() + + +static func _create_process_with_environment(command: String, args: Array[String], environment: Dictionary) -> int: + var previous := {} + PortResolver.lock_process_spawn() + var full_command: Array = [command] + full_command.append_array(args) + var saved_uv_environment := ( + UvResolution.isolate_environment() + if UvResolution.is_production_command(full_command) + else {} + ) + for key in environment: + previous[key] = {"present": OS.has_environment(key), "value": OS.get_environment(key)} + OS.set_environment(key, str(environment[key])) + var pid := OS.create_process(command, args) + for key in previous: + if bool(previous[key].present): + OS.set_environment(key, str(previous[key].value)) + else: + OS.unset_environment(key) + UvResolution.restore_environment(saved_uv_environment) + PortResolver.unlock_process_spawn() + return pid + + +static func _server_flags(plan: Dictionary) -> Array[String]: + var flags: Array[String] = [ + "--transport", "streamable-http", + "--port", str(plan.http_port), + "--ws-port", str(plan.ws_port), + "--pid-file", str(plan.get("pid_file", "")), + ] + var startup_report := str(plan.get("startup_report", "")) + if not startup_report.is_empty(): + flags.append_array(["--startup-report", startup_report]) + var excluded := str(plan.get("excluded_domains", "")) + if not excluded.is_empty(): + flags.append_array(["--exclude-domains", excluded]) + var allow_hosts := str(plan.get("allow_hosts", "")) + if not allow_hosts.is_empty(): + flags.append_array(["--allow-host", allow_hosts]) + return flags + + +## " Server reported: : " from the launch's startup +## report, or "" when there is none. Bounded and single-line: the report is a +## file the spawned server wrote, so it is quoted, never interpreted. +static func startup_report_summary(path: String, max_chars := 600) -> String: + if path.is_empty() or not FileAccess.file_exists(path): + return "" + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + return "" + var raw := file.get_as_text().strip_edges() + file.close() + if raw.is_empty() or raw.length() > 8192: + return "" + var parsed: Variant = JSON.parse_string(raw) + if not (parsed is Dictionary): + return "" + var error := str(parsed.get("error", "")).strip_edges() + var message := str(parsed.get("message", "")).strip_edges() + var hint := str(parsed.get("hint", "")).strip_edges() + if error.is_empty() and message.is_empty(): + return "" + var text := " Server reported: " + if not error.is_empty(): + text += error + (": " if not message.is_empty() else "") + text += message + if not hint.is_empty(): + text += " " + hint + text = text.replace("\n", " ").replace("\r", " ") + if text.length() > max_chars: + text = text.substr(0, max_chars - 1) + "…" + return text +## Untrusted, bounded, tokenless read of `/godot-ai/status`, used ONLY to +## word the BLOCKED message when a pre-v4 godot-ai server holds the port. A +## v3 server never wrote a v4 capability record, so the authenticated probe +## cannot even name it, and the dock could only say "another process". This +## peek is not a fallback: its result never enters the probe outcome, never +## becomes a transport, and never contributes to replacement or kill +## authority; `_blocked_probe_result` keeps `replaceable` false. It answers +## one question, "is that a Godot AI 3.x server?", so the user is told to +## quit the AI client whose bridge keeps it alive instead of hunting for a +## foreign process. docs/server-lifecycle.md states the exception. +static func _untrusted_pre_v4_occupant_version(port: int, timeout_ms: int) -> String: + var client := HTTPClient.new() + if client.connect_to_host("127.0.0.1", port) != OK: + return "" + var deadline := Time.get_ticks_msec() + maxi(1, timeout_ms) + while client.get_status() in [HTTPClient.STATUS_RESOLVING, HTTPClient.STATUS_CONNECTING]: + client.poll() + if Time.get_ticks_msec() >= deadline: + return "" + OS.delay_msec(10) + if client.get_status() != HTTPClient.STATUS_CONNECTED: + return "" + if client.request(HTTPClient.METHOD_GET, STATUS_PATH, ["Accept: application/json"]) != OK: + return "" + 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 body.size() + chunk.size() > MAX_STATUS_BODY_BYTES: + return "" + body.append_array(chunk) + elif status == HTTPClient.STATUS_CONNECTED: + break + else: + return "" + if Time.get_ticks_msec() >= deadline: + return "" + OS.delay_msec(10) + if client.get_response_code() != 200: + return "" + var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) + return pre_v4_version_from_status(parsed) + + +## "3.2.4" for a payload that claims to be a godot-ai server below v4, else "". +## Pure and bounded so the contract is unit-testable without a socket. +static func pre_v4_version_from_status(parsed: Variant) -> String: + if not (parsed is Dictionary): + return "" + if str(parsed.get("name", "")) != "godot-ai": + return "" + var version := str(parsed.get("server_version", "")).strip_edges() + if version.length() > 32 or not version.begins_with("3."): + return "" + ## Every dot-separated component must be digits and non-empty: "3.", + ## "3..2" and "3.2." are not versions and must not earn the stale-server + ## wording or the slow post-update retry. + for part in version.split("."): + if part.is_empty(): + return "" + for index in range(part.length()): + var code := part.unicode_at(index) + if code < 48 or code > 57: + return "" + return version + + +static func stale_pre_v4_message(port: int, version: String) -> String: + return ( + "Port %d is held by a Godot AI %s server that an AI client attached before the update " + + "keeps alive. Quit and relaunch that client (restarting its MCP server is not enough); " + + "the old server exits on its own within about three minutes, and the editor retries " + + "meanwhile. Otherwise click Restart Server afterwards." + ) % [port, version] + + +static func active_lease_count(live: Dictionary) -> int: + if str(live.get("name", "")) != "godot-ai": + return 0 + return maxi(0, int(live.get("active_lease_count", 0))) + + +static func _server_version_compatibility(actual_version: String, expected_version: String) -> Dictionary: + return ServerVersionCheck.evaluate(actual_version, expected_version) + + +static func _server_status_compatibility(actual_version: String, expected_version: String, actual_ws_port: int, expected_ws_port: int) -> Dictionary: + var version := _server_version_compatibility(actual_version, expected_version) + if not bool(version.compatible): + return version + if actual_ws_port != expected_ws_port: + return {"compatible": false, "reason": "ws_port_mismatch"} + return {"compatible": true, "reason": ""} + + +func get_status_dict() -> Dictionary: + var state := str(_episode.get("state", DORMANT)) + var reason := str(_episode.get("reason", "")) + return { + "episode_id": int(_episode.get("id", 0)), + "episode_state": state, + "phase": str(_episode.get("phase", "")), + "ready_kind": str(_episode.get("ready_kind", "")), + "state": _dock_state(state, reason), + "expected_version": str(_episode.get("expected_version", _plan.get("expected_version", ""))), + "actual_version": str(_episode.get("actual_version", "")), + "actual_name": "godot-ai" if state == READY else "", + "message": str(_episode.get("message", "")), + "connection_blocked": state != READY, + "can_recover_incompatible": can_recover_incompatible_server(), + "conflict_port": int(_episode.get("blocked_target", {}).get("port", 0)), + "conflict_version": str(_episode.get("blocked_target", {}).get("version", "")), + ## Wording-only hint from the untrusted pre-v4 peek; never authority. + "blocked_hint": str(_episode.get("blocked_target", {}).get("hint", "")), + "keep_alive": bool(_plan.get("keep_alive", false)), + "recovery_attempt": _endpoint_recovery_attempts, + "recovery_limit": ENDPOINT_RECOVERY_DELAYS_SECONDS.size(), + } + + +func get_server_pid() -> int: + return int(_process_grant.process_id()) if _process_grant != null else -1 + + +func is_connection_blocked() -> bool: + return str(_episode.get("state")) != READY + + +func has_managed_server() -> bool: + return _process_grant != null and _process_grant.is_valid() + + +func can_restart_managed_server() -> bool: + return has_managed_server() + + +func episode_snapshot() -> Dictionary: + var snapshot := _episode.duplicate(true) + var launch: Dictionary = snapshot.get("launch", {}) + launch.erase("http_capability") + launch.erase("ws_capability") + snapshot["launch"] = launch + return snapshot + + +func authority_snapshot() -> Dictionary: + return { + "transport": _transport.public_snapshot() if _transport != null else {}, + "owned_pid": get_server_pid(), + "replacement_available": _replacement_authorization != null, + } + + +func _publish() -> void: + snapshot_changed.emit(get_status_dict().duplicate(true)) + + +static func _dormant_episode(episode_id: int) -> Dictionary: + return { + "id": episode_id, "state": DORMANT, "phase": "", "reason": "", + "message": "Server lifecycle is dormant.", "ready_kind": "", "effect": "", + "expected_version": "", "actual_version": "", "blocked_target": {}, + "launch": {}, "after_stop": "", + } + + +static func _stopping_episode(episode_id: int, launch: Dictionary, after_stop: String) -> Dictionary: + return { + "id": episode_id, "state": STOPPING, "phase": "", "reason": "", + "message": "Stopping the managed server.", "ready_kind": "", "effect": "", + "expected_version": "", "actual_version": "", "blocked_target": {}, + "launch": launch, "after_stop": after_stop, + } + + +static func _dock_state(state: String, reason: String) -> int: + match state: + DORMANT: + return ServerState.STOPPED + STARTING: + return ServerState.SPAWNING + READY: + return ServerState.READY + RECOVERING, STOPPING: + return ServerState.STOPPING + BLOCKED: + match reason: + "incompatible": return ServerState.INCOMPATIBLE + "no_command": return ServerState.NO_COMMAND + "port_reserved": return ServerState.PORT_EXCLUDED + "occupied", "replacement_unproven": return ServerState.FOREIGN_PORT + _: return ServerState.CRASHED + return ServerState.UNINITIALIZED 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..b6f2172 --- /dev/null +++ b/addons/godot_ai/utils/server_version_check.gd @@ -0,0 +1,64 @@ +@tool +class_name McpServerVersionCheck +extends RefCounted + +## Published v4 compatibility value. Lifecycle owns the authenticated +## handshake and episode transition; this class intentionally owns no timer, +## connection, or manager reference. + +## First release whose attached bridge follows servers of the same major. +const FIRST_BRIDGE_TOLERANT_VERSION := "4.0.4" + + +## Whether clients attached at `from_version` can reconnect after updating +## to `to_version` without restarting their bridge (#1024). +static func attached_bridges_follow(from_version: String, to_version: String) -> bool: + var from_tuple := version_tuple(from_version) + var to_tuple := version_tuple(to_version) + if from_tuple.is_empty() or to_tuple.is_empty(): + return false + if int(from_tuple[0]) != int(to_tuple[0]): + return false + return compare(from_tuple, version_tuple(FIRST_BRIDGE_TOLERANT_VERSION)) >= 0 + + +## Leading numeric `major.minor.patch` of a version as `[major, minor, patch]`, +## or `[]` when it does not start that way (a dev build, a malformed pin). +## A suffix after the patch (`4.0.3+local.1`, `4.1.0-rc1`) is ignored; a +## dangling separator (`4.0.3+`) is not a version. +static func version_tuple(version: String) -> Array: + var regex := RegEx.create_from_string("^(\\d+)\\.(\\d+)\\.(\\d+)(?:$|[.+-][0-9A-Za-z][0-9A-Za-z.+-]*$)") + var found := regex.search(version.strip_edges()) + if found == null: + return [] + return [int(found.get_string(1)), int(found.get_string(2)), int(found.get_string(3))] + + +static func compare(a: Array, b: Array) -> int: + for index in range(3): + if int(a[index]) != int(b[index]): + return -1 if int(a[index]) < int(b[index]) else 1 + return 0 + + +## `candidate` is a parseable version older than `reference` within the same +## major version. Unparseable, equal, newer, or another major all read false: +## a caller may only replace what it can prove is older than itself. +static func is_older_same_major(candidate: String, reference: String) -> bool: + var candidate_tuple := version_tuple(candidate) + var reference_tuple := version_tuple(reference) + if candidate_tuple.is_empty() or reference_tuple.is_empty(): + return false + if int(candidate_tuple[0]) != int(reference_tuple[0]): + return false + return compare(candidate_tuple, reference_tuple) < 0 + + +static func evaluate(actual_version: String, expected_version: String) -> Dictionary: + if actual_version.is_empty(): + return {"compatible": false, "reason": "missing_version"} + var compatible := actual_version == expected_version + return { + "compatible": compatible, + "reason": "" if compatible else "version_mismatch", + } 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..9ce2c68 --- /dev/null +++ b/addons/godot_ai/utils/settings.gd @@ -0,0 +1,135 @@ +@tool +class_name McpSettings +extends RefCounted + +## Shared EditorSettings key constants for the godot_ai/* namespace. +## +## Centralised here so lightweight files (e.g. telemetry.gd) can reference +## settings keys without pulling in the full client_configurator.gd dep tree. +## All keys must keep their raw string values stable across releases because +## they are persisted in the user's editor_settings-4.tres. + +const SETTING_HTTP_PORT := "godot_ai/http_port" +## Comma-separated list of tool domains excluded from the server at spawn time. +const SETTING_EXCLUDED_DOMAINS := "godot_ai/excluded_domains" +const SETTING_TELEMETRY_ENABLED := "godot_ai/telemetry_enabled" +## Comma-separated CIDRs / bare IPs passed to the server as `--allow-host` +## at spawn time (#507, server core #421). Empty means loopback-only. +const SETTING_ALLOW_HOSTS := "godot_ai/allow_remote_hosts" +## Whether MCP log lines echo to the Godot console (dock "Log" toggle). +## The dock's ring-buffer log panel keeps recording regardless. +const SETTING_MCP_LOGGING := "godot_ai/mcp_logging" +## Which scope CLI-configured clients register the server in. Claude Code's +## `--scope user` writes the global block of ~/.claude.json, which that client +## loads in EVERY workspace — so the server gets spawned in unrelated, +## non-Godot projects. `project` writes /.mcp.json instead, so the +## entry travels with the project. Defaults to `user` to preserve the +## historical behaviour for existing installs. +const SETTING_CLIENT_SCOPE := "godot_ai/mcp_client_scope" + +## Scopes accepted for SETTING_CLIENT_SCOPE. These are the values the Claude +## Code CLI's `--scope` flag takes; an unrecognised setting falls back to +## DEFAULT_CLIENT_SCOPE rather than passing junk to the CLI. +const CLIENT_SCOPES := ["user", "project", "local"] +const DEFAULT_CLIENT_SCOPE := "user" + + +## Returns true if the string value is truthy +## ("1", "true", "yes", "on", case-insensitive, whitespace-trimmed). +static func truthy(value: String) -> bool: + return value.strip_edges().to_lower() in ["1", "true", "yes", "on"] + + +## Returns true if the named environment variable is set to a truthy value. +static func env_truthy(var_name: String) -> bool: + return truthy(OS.get_environment(var_name)) + + +## #691: EditorInterface / EditorSettings are not thread-safe, and the dock +## runs Configure / Remove on a worker Thread (mcp_dock.gd +## `_run_client_action_worker` -> McpClientConfigurator -> McpCliStrategy +## `_format_args` -> client_scope()). Same contract as +## McpClientConfigurator._editor_setting_lookup: the main thread does a live +## read and refreshes the snapshot; worker threads read the snapshot only, so +## a never-warmed value reads as null (unset) rather than touching +## EditorInterface off-thread. Warmed on the main thread by +## McpClientConfigurator.warm_env_snapshot() before each worker dispatch. +## +## Kept here rather than reusing the configurator's `_editor_setting_lookup` +## so utils/settings.gd stays free of the client_configurator.gd dep tree — +## see this file's header. +static var _client_scope_snapshot: Variant = null +static var _client_scope_mutex := Mutex.new() + + +static func _client_scope_setting() -> Variant: + if OS.get_thread_caller_id() == OS.get_main_thread_id(): + var live: Variant = null + if Engine.is_editor_hint(): + var es := EditorInterface.get_editor_settings() + if es != null and es.has_setting(SETTING_CLIENT_SCOPE): + live = es.get_setting(SETTING_CLIENT_SCOPE) + _client_scope_mutex.lock() + _client_scope_snapshot = live + _client_scope_mutex.unlock() + return live + _client_scope_mutex.lock() + var cached: Variant = _client_scope_snapshot + _client_scope_mutex.unlock() + return cached + + +## Main-thread pre-warm of the scope snapshot so the dock's Configure/Remove +## worker resolves `{scope}` from a value captured while EditorSettings access +## was safe. Idempotent; called from warm_env_snapshot(). +static func warm_client_scope() -> void: + _client_scope_setting() + + +## Returns the scope CLI clients should register the MCP server in, as +## substituted into `{scope}` in a descriptor's cli_register_template / +## cli_unregister_template. Safe to call from either thread (see +## _client_scope_setting). Unrecognised values fall back to +## DEFAULT_CLIENT_SCOPE so a hand-edited editor_settings-4.tres can never make +## the plugin shell out with a bad flag. +static func client_scope() -> String: + var value: Variant = _client_scope_setting() + ## String(null) is a hard error (#850) — an unset/never-warmed key must + ## short-circuit before the cast. + if value == null: + return DEFAULT_CLIENT_SCOPE + var raw := String(value).strip_edges().to_lower() + if raw in CLIENT_SCOPES: + return raw + return DEFAULT_CLIENT_SCOPE + + +## Returns true if telemetry should be active, checking in priority order: +## 1. GODOT_AI_DISABLE_TELEMETRY / DISABLE_TELEMETRY env vars +## 2. The godot_ai/telemetry_enabled EditorSetting written by the dock UI +## Defaults to true when neither source has set a preference. +static func telemetry_enabled() -> bool: + if env_truthy("GODOT_AI_DISABLE_TELEMETRY") or env_truthy("DISABLE_TELEMETRY"): + return false + var es := EditorInterface.get_editor_settings() + if es != null and es.has_setting(SETTING_TELEMETRY_ENABLED): + return bool(es.get_setting(SETTING_TELEMETRY_ENABLED)) + return true + + +## Returns whether MCP log lines should echo to the Godot console. Read at +## plugin startup (to apply the persisted choice to the log buffer and +## dispatcher) and by the dock's LogViewer toggle for its initial state. +## Defaults to true when the user has never touched the toggle. +static func mcp_logging_enabled() -> bool: + var es := EditorInterface.get_editor_settings() + if es != null and es.has_setting(SETTING_MCP_LOGGING): + return bool(es.get_setting(SETTING_MCP_LOGGING)) + return true + + +## Persist the dock "Log" toggle so the choice survives editor restarts (#626). +static func set_mcp_logging_enabled(enabled: bool) -> void: + var es := EditorInterface.get_editor_settings() + if es != null: + es.set_setting(SETTING_MCP_LOGGING, enabled) diff --git a/addons/godot_ai/utils/settings.gd.uid b/addons/godot_ai/utils/settings.gd.uid 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/transport_capability.gd b/addons/godot_ai/utils/transport_capability.gd new file mode 100644 index 0000000..2f9f7b6 --- /dev/null +++ b/addons/godot_ai/utils/transport_capability.gd @@ -0,0 +1,305 @@ +@tool +class_name McpTransportCapability +extends RefCounted + +## Reads Python's atomic, private capability record as one indivisible value. +## This is a static boundary, not another state owner. + +const CAPABILITY_DIR_ENV := "GODOT_AI_CAPABILITY_DIR" +const RECORD_VERSION := 1 +const MAX_RECORD_BYTES := 1024 +const _POSIX_PERMISSION_MASK := 0x1ff ## 0777 +const _GROUP_OTHER_PERMISSION_MASK := 0x3f ## 0077 +const _GROUP_OTHER_WRITE_MASK := 0x12 ## 0022 +const _MAX_LINK_HOPS := 8 +const _SYSTEM_TEMP_ROOTS: Array[String] = ["/tmp", "/private/tmp", "/var/tmp"] +const _KEYS: Array[String] = [ + "version", "http", "websocket", "instance_nonce", +] + + +static func read_for_http_port(http_port: int, captured_path := "") -> Dictionary: + var path := str(captured_path) + if not path.is_empty() and path.get_file() != "http-%d.json" % http_port: + return {} + return _read_path(path if not path.is_empty() else path_for_http_port(http_port)) + + +static func _read_path(path: String) -> Dictionary: + if path.is_empty() or not path.is_absolute_path(): + return {} + var resolved := _resolve_trusted_path(path) + if resolved.is_empty(): + return {} + var directory := resolved.get_base_dir() + if ( + not _safe_posix_ancestors(directory) + or not _private_path(directory, true) + or not _private_path(resolved, false) + ): + return {} + var file := FileAccess.open(resolved, FileAccess.READ) + if file == null: + return {} + var length := file.get_length() + if length < 1 or length > MAX_RECORD_BYTES + 1: + return {} + var bytes := file.get_buffer(length) + if bytes.size() != length: + return {} + if bytes[bytes.size() - 1] == 10: + bytes.resize(bytes.size() - 1) + if bytes.is_empty() or bytes.size() > MAX_RECORD_BYTES: + return {} + for byte in bytes: + if byte > 127: + return {} + var raw := bytes.get_string_from_ascii() + ## Godot resolves duplicate JSON keys. Tokens need no escapes, so reject + ## escaping and require each literal key exactly once before parsing. + if raw.contains("\\"): + return {} + for key in _KEYS: + if raw.count("\"%s\"" % key) != 1: + return {} + var parsed: Variant = JSON.parse_string(raw) + if not (parsed is Dictionary) or parsed.size() != _KEYS.size(): + return {} + for key in _KEYS: + if not parsed.has(key): + return {} + var version: Variant = parsed["version"] + if not (version is int or version is float) or float(version) != RECORD_VERSION: + return {} + if ( + not (parsed["http"] is String) + or not (parsed["websocket"] is String) + or not (parsed["instance_nonce"] is String) + ): + return {} + var http: String = parsed["http"] + var websocket: String = parsed["websocket"] + var nonce: String = parsed["instance_nonce"] + if not is_http_capability(http) or not is_lower_hex(websocket, 64): + return {} + if not is_lower_hex(nonce, 32) or http == websocket: + return {} + return { + "http": http, + "websocket": websocket, + "instance_nonce": nonce.to_lower(), + } + + +static func _private_path(path: String, directory: bool) -> bool: + if directory: + if DirAccess.open(path) == null: + return false + elif not FileAccess.file_exists(path): + return false + if OS.get_name() == "Windows": + ## Godot's detectable link/reparse surface was checked by _path_has_link. + ## v4 deliberately makes no Windows DACL secrecy/integrity claim. + return true + ## Godot exposes POSIX mode bits but not the owning UID. This proves only + ## that group/other access is closed; it is not an owner-identity proof. + var mode := FileAccess.get_unix_permissions(path) & _POSIX_PERMISSION_MASK + return mode != 0 and (mode & _GROUP_OTHER_PERMISSION_MASK) == 0 + + +## Walk `path` from its root and return it with every accepted link component +## replaced by its target, or "" when the path must not be trusted. A link is +## followed only when the directory holding it is closed to group and other +## writes, so only that directory's owner or root could have placed it; ostree +## distributions need this for `/home -> /var/home` (#993). Godot exposes no +## owning UID, so this is the strongest proof available here; Python, which +## publishes the record, additionally requires such a link to be root-owned. +## The record file itself is never followed, and a chain longer than +## `_MAX_LINK_HOPS` fails closed. Windows keeps rejecting every detectable +## link or reparse point. +static func _resolve_trusted_path(path: String) -> String: + if OS.get_name() == "Windows": + return "" if _path_has_link(path) else path + var remaining := path.simplify_path().split("/", false) + var current := "/" + var hops := 0 + while not remaining.is_empty(): + var part := remaining[0] + remaining.remove_at(0) + var candidate := current.path_join(part) + var parent := DirAccess.open(current) + if parent == null: + return "" + if parent.is_link(candidate): + if ( + remaining.is_empty() + or hops >= _MAX_LINK_HOPS + or not _closed_to_group_and_other(current) + ): + return "" + hops += 1 + var target := parent.read_link(candidate) + if target.is_empty(): + return "" + if not target.is_absolute_path(): + target = current.path_join(target) + var target_parts := target.simplify_path().split("/", false) + target_parts.append_array(remaining) + remaining = target_parts + current = "/" + continue + current = candidate + return current + + +static func _closed_to_group_and_other(directory: String) -> bool: + var mode := FileAccess.get_unix_permissions(directory) & _POSIX_PERMISSION_MASK + return mode != 0 and (mode & _GROUP_OTHER_WRITE_MASK) == 0 + + +static func _path_has_link(path: String) -> bool: + var current := path + while not current.is_empty(): + var parent := current.get_base_dir() + if parent.is_empty() or parent == current: + return false + var directory := DirAccess.open(parent) + if directory == null or directory.is_link(current): + return true + current = parent + return false + + +static func _safe_posix_ancestors(path: String) -> bool: + if OS.get_name() == "Windows": + return true + var current := path.simplify_path() + while not current.is_empty(): + if DirAccess.open(current) != null: + var permissions := FileAccess.get_unix_permissions(current) + if not _safe_posix_ancestor_mode(current, permissions): + return false + var parent := current.get_base_dir() + if parent.is_empty() or parent == current: + break + current = parent + return true + + +static func _safe_posix_ancestor_mode(path: String, permissions: int) -> bool: + var mode := permissions & _POSIX_PERMISSION_MASK + if mode == 0: + return false + if (mode & _GROUP_OTHER_WRITE_MASK) == 0: + return true + ## Godot exposes no UID. Accept the conventional system temp boundary only + ## when its sticky bit is present; Python additionally proves root ownership + ## before it publishes a record below the same canonical roots. + return ( + path.simplify_path() in _SYSTEM_TEMP_ROOTS + and (permissions & FileAccess.UNIX_RESTRICTED_DELETE) != 0 + ) + + +static func is_http_capability(value: String) -> bool: + var bytes := value.to_ascii_buffer() + if bytes.size() < 32 or bytes.size() > 128: + return false + for byte in bytes: + if not ( + (byte >= 48 and byte <= 57) + or (byte >= 65 and byte <= 90) + or (byte >= 97 and byte <= 122) + or byte in [43, 45, 46, 47, 61, 95, 126] + ): + return false + return true + + +static func is_hex(value: String, length: int) -> bool: + if value.length() != length: + return false + for index in range(length): + var code := value.unicode_at(index) + if not ( + (code >= 48 and code <= 57) + or (code >= 65 and code <= 70) + or (code >= 97 and code <= 102) + ): + return false + return true + + +static func is_lower_hex(value: String, length: int) -> bool: + return is_hex(value, length) and value == value.to_lower() + + +## Windows only: "" when this account can create and write the capability +## directory, else a repair message. Python creates the directory with +## ``mode=0o700`` on POSIX, but on Windows that mode yields a DACL of SYSTEM, +## Administrators and OWNER RIGHTS alone; a directory first created by an +## elevated process is then owned by Administrators and unusable from the +## user's own unelevated editor, server and bridge (#988). Creating it here +## first inherits the per-user %LOCALAPPDATA% DACL, and probing it before the +## spawn turns a silent "proof timed out at capability_record" into a message +## that names the directory and the fix. +static func directory_write_problem(http_port: int) -> String: + if OS.get_name() != "Windows": + return "" + var record := path_for_http_port(http_port) + if record.is_empty(): + return "" + return directory_write_problem_for(record.get_base_dir()) + + +static func directory_write_problem_for(directory: String) -> String: + if DirAccess.open(directory) == null: + var created := DirAccess.make_dir_recursive_absolute(directory) + if created != OK: + return windows_repair_hint(directory, error_string(created)) + var probe := directory.path_join(".access-probe-%d-%s" % [ + OS.get_process_id(), Crypto.new().generate_random_bytes(4).hex_encode(), + ]) + var file := FileAccess.open(probe, FileAccess.WRITE) + if file == null: + return windows_repair_hint(directory, error_string(FileAccess.get_open_error())) + file.close() + DirAccess.remove_absolute(probe) + return "" + + +static func windows_repair_hint(directory: String, detail: String) -> String: + return ( + "Godot AI cannot use the directory %s (%s): this Windows account cannot " + + "access it. This can happen when an elevated (Run as administrator) " + + "process created it. Check this directory's permissions and grant your " + + "Windows account access, then reopen Godot and your AI clients without " + + "Run as administrator." + ) % [directory, detail] + + +static func path_for_http_port(http_port: int) -> String: + if http_port < 1 or http_port > 65535: + return "" + var override := OS.get_environment(CAPABILITY_DIR_ENV).strip_edges() + if OS.get_name() == "Windows" and not override.is_empty(): + return "" + var directory := override + if directory.is_empty(): + if OS.get_name() == "Windows": + directory = OS.get_environment("LOCALAPPDATA").strip_edges() + if directory.is_empty(): + return "" + directory = directory.path_join("godot-ai/capabilities") + elif OS.get_name() == "macOS": + directory = OS.get_environment("HOME").path_join( + "Library/Application Support/godot-ai/capabilities" + ) + else: + directory = OS.get_environment("XDG_CONFIG_HOME").strip_edges() + if directory.is_empty(): + directory = OS.get_environment("HOME").path_join(".config") + directory = directory.path_join("godot-ai/capabilities") + if directory.is_empty() or not directory.is_absolute_path(): + return "" + return directory.simplify_path().path_join("http-%d.json" % http_port) diff --git a/addons/godot_ai/utils/transport_capability.gd.uid b/addons/godot_ai/utils/transport_capability.gd.uid new file mode 100644 index 0000000..9f165f0 --- /dev/null +++ b/addons/godot_ai/utils/transport_capability.gd.uid @@ -0,0 +1 @@ +uid://b7j8n63he8ljy diff --git a/addons/godot_ai/utils/update_manager.gd b/addons/godot_ai/utils/update_manager.gd new file mode 100644 index 0000000..4f6fabb --- /dev/null +++ b/addons/godot_ai/utils/update_manager.gd @@ -0,0 +1,533 @@ +@tool +class_name McpUpdateManager +extends Node + +## v4-only release discovery and download preparation. Python authenticates +## and extracts the three canonical assets before this manager asks the root to +## quiesce or disable anything; the transaction actor alone mutates live code. + +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 REPOSITORY := "hi-godot/godot-ai" +const ASSET_NAME := "godot-ai-v4-plugin.zip" +const MANIFEST_NAME := "godot-ai-v4-plugin.manifest.json" +const SIGNATURE_NAME := "godot-ai-v4-plugin.manifest.sig" +const LEGACY_ASSET_NAME := "godot-ai-plugin.zip" +const LEGACY_CHECKSUM_NAME := "godot-ai-plugin.zip.sha256" +const LEGACY_SIGNATURE_NAME := "godot-ai-plugin.zip.sha256.sig" +const MAX_RELEASE_METADATA_BYTES := 1024 * 1024 +const MAX_ARCHIVE_SIZE_BYTES := 64 * 1024 * 1024 +const MAX_MANIFEST_SIZE_BYTES := 1024 * 1024 +const MAX_REDIRECTS := 5 +const QUALIFICATION_SWITCH_ENV := "GODOT_AI_QUALIFICATION_RELEASE" +const QUALIFICATION_URL_ENV := "GODOT_AI_QUALIFICATION_RELEASE_URL" +const QUALIFICATION_ASSET_PREFIX_ENV := "GODOT_AI_QUALIFICATION_ASSET_PREFIX" +const QUALIFICATION_TOKEN_ENV := "GODOT_AI_QUALIFICATION_TOKEN" +const _ASSET_LIMITS := { + ASSET_NAME: MAX_ARCHIVE_SIZE_BYTES, + MANIFEST_NAME: MAX_MANIFEST_SIZE_BYTES, + SIGNATURE_NAME: 512, +} +## Stable releases also carry one signed, temporary v3 migration capsule. +## v4 never downloads it, but requires the exact six-name release envelope so +## an unexpected extra executable asset remains a fail-closed condition. +const _RELEASE_ASSET_LIMITS := { + ASSET_NAME: MAX_ARCHIVE_SIZE_BYTES, + MANIFEST_NAME: MAX_MANIFEST_SIZE_BYTES, + SIGNATURE_NAME: 512, + LEGACY_ASSET_NAME: 66 * 1024 * 1024, + LEGACY_CHECKSUM_NAME: 1024, + LEGACY_SIGNATURE_NAME: 512, +} +const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd") +const TransportCapability := preload("res://addons/godot_ai/utils/transport_capability.gd") + +## Kept byte-for-byte aligned with src/godot_ai/release_verify.py and the +## standalone verifier. Python performs the mandatory strict verification; +## embedding it here also lets release packaging fail on trust-anchor drift. +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----- +""" + +const _TRUSTED_PATHS := { + "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-", +} + +signal update_check_completed(result: Dictionary) +signal install_state_changed(state: Dictionary) +signal activation_requested(package: Dictionary) + +var _check_request: HTTPRequest +var _asset_request: HTTPRequest +var _release: Dictionary = {} +var _queue: Array[String] = [] +var _active_asset := "" +var _redirect_count := 0 +var _qualification: Dictionary = {} +var _download_root := "" + + +func check_for_updates() -> void: + _release.clear() + _qualification = _qualification_from_environment() + if bool(_qualification.get("invalid", false)): + _qualification.clear() + push_error("MCP | invalid private qualification release capability") + return + if ClientConfigurator.is_dev_checkout(): + return + if _check_request != null: + _check_request.queue_free() + _check_request = HTTPRequest.new() + _check_request.max_redirects = 0 + _check_request.body_size_limit = MAX_RELEASE_METADATA_BYTES + _check_request.request_completed.connect(_on_check_completed) + add_child(_check_request) + var url := str(_qualification.get("release_url", RELEASES_URL)) + if _check_request.request(url, _request_headers(url, _qualification, true)) != OK: + _check_request.queue_free() + _check_request = null + + +func start_install(preflight: Dictionary) -> void: + if is_install_in_flight(): + install_state_changed.emit({ + "status_text": "Update already in progress", + "button_disabled": true, + }) + return + if _release.is_empty(): + OS.shell_open(RELEASES_PAGE) + return + if not bool(preflight.get("ok", false)): + install_state_changed.emit({ + "install_in_flight": false, + "status_text": "Update blocked — resolve recovery state", + "button_disabled": false, + }) + return + var directory := str(preflight.get("download_root", "")) + if ( + directory.is_empty() + or not directory.is_absolute_path() + or not _directory_is_empty(directory) + ): + install_state_changed.emit({ + "install_in_flight": false, + "status_text": "Update blocked — private download directory unavailable", + "button_disabled": false, + }) + return + _download_root = directory + _queue.assign([ASSET_NAME, MANIFEST_NAME, SIGNATURE_NAME]) + install_state_changed.emit({ + "install_in_flight": true, + "status_text": "Downloading…", + "button_disabled": true, + }) + _download_next() + + +func is_install_in_flight() -> bool: + return _asset_request != null or not _download_root.is_empty() + + +func has_install_candidate() -> bool: + return not is_install_in_flight() and not _release.is_empty() + + +static func _directory_is_empty(path: String) -> bool: + var directory := DirAccess.open(path) + if directory == null: + return false + directory.list_dir_begin() + var first := directory.get_next() + directory.list_dir_end() + return first.is_empty() + + +func cancel_install() -> void: + if _asset_request != null: + _asset_request.cancel_request() + _asset_request.queue_free() + _asset_request = null + _queue.clear() + _qualification.clear() + discard_downloads() + + +func _exit_tree() -> void: + cancel_install() + + +## Download roots are exclusively allocated by the Python transaction actor. +## This manager never creates, reuses, or recursively deletes a predictable +## user:// namespace; it removes only the three exact files it requested and +## then the now-empty transaction directory. +func discard_downloads() -> void: + if _download_root.is_empty(): + return + for name in [ASSET_NAME, MANIFEST_NAME, SIGNATURE_NAME]: + DirAccess.remove_absolute(_download_root.path_join(name)) + DirAccess.remove_absolute(_download_root) + _download_root = "" + + +func _download_path(name: String) -> String: + if _download_root.is_empty() or not _ASSET_LIMITS.has(name): + return "" + return _download_root.path_join(name) + + +static func parse_releases_response( + result: int, + response_code: int, + body: PackedByteArray, + local_version: String = "", + qualification: Dictionary = {}, +) -> Dictionary: + var empty := { + "has_update": false, + "version": "", + "tag": "", + "channel": "", + "label_text": "", + "urls": {}, + "sizes": {}, + } + if ( + result != HTTPRequest.RESULT_SUCCESS + or response_code != 200 + or body.size() > MAX_RELEASE_METADATA_BYTES + ): + return empty + var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) + if not parsed is Dictionary: + return empty + var tag := str(parsed.get("tag_name", "")) + var version := tag.trim_prefix("v") + var local := local_version if not local_version.is_empty() else ClientConfigurator.get_plugin_version() + if not tag.begins_with("v4.") or not _is_newer(version, local): + return empty + var assets: Variant = parsed.get("assets", []) + if not assets is Array or assets.size() != _RELEASE_ASSET_LIMITS.size(): + return empty + var urls := {} + var sizes := {} + for value in assets: + if not value is Dictionary: + return empty + var name := str(value.get("name", "")) + if not _RELEASE_ASSET_LIMITS.has(name) or sizes.has(name): + return empty + var size := int(value.get("size", -1)) + if size <= 0 or size > int(_RELEASE_ASSET_LIMITS[name]): + return empty + if name in [SIGNATURE_NAME, LEGACY_SIGNATURE_NAME] and size != 512: + return empty + var url := str(value.get("browser_download_url", "")) + if not _is_trusted_download_url(url, qualification): + return empty + sizes[name] = size + if _ASSET_LIMITS.has(name): + urls[name] = url + if urls.size() != 3: + return empty + return { + "has_update": true, + "version": version, + "tag": tag, + "channel": "stable", + "label_text": "Update available: %s" % tag, + "urls": urls, + "sizes": sizes, + } + + +static func _is_newer(remote: String, local: String) -> bool: + var remote_parts := _version_parts(remote) + var local_parts := _version_parts(local) + if remote_parts.is_empty() or local_parts.is_empty(): + return false + for index in 3: + if remote_parts[index] != local_parts[index]: + return remote_parts[index] > local_parts[index] + return false + + +static func _version_parts(version: String) -> Array[int]: + var expression := RegEx.new() + if expression.compile("^4\\.(\\d+)\\.(\\d+)$") != OK: + return [] + var found := expression.search(version) + if found == null: + return [] + return [4, int(found.get_string(1)), int(found.get_string(2))] + + +static func _https_url_parts(url: String) -> Dictionary: + const SCHEME := "https://" + if not url.begins_with(SCHEME) or url.contains("\\") or url.contains("#"): + return {} + for index in url.length(): + var code: int = url.unicode_at(index) + if code <= 0x20 or code == 0x7f: + return {} + var rest := url.substr(SCHEME.length()) + var slash := rest.find("/") + if slash < 0: + return {} + var authority := rest.substr(0, slash) + var path := rest.substr(slash) + if authority.is_empty() or authority.contains("@") or authority.count(":") > 1: + return {} + var host := authority + var colon := authority.find(":") + if colon >= 0: + if authority.substr(colon + 1) != "443": + return {} + host = authority.substr(0, colon) + if ( + host.is_empty() + or host != host.to_lower() + or host.begins_with(".") + or host.ends_with(".") + or host.contains("..") + ): + return {} + for index in host.length(): + var code := host.unicode_at(index) + if not (code >= 97 and code <= 122) and not (code >= 48 and code <= 57) and code not in [45, 46]: + return {} + var query := path.find("?") + if query >= 0: + path = path.substr(0, query) + var lower := path.to_lower() + for needle in ["/../", "/..", "%2e", "%2f", "%5c"]: + if lower.contains(needle): + return {} + return {"host": host, "origin": SCHEME + host, "path": path} + + +static func _is_trusted_download_url(url: String, qualification: Dictionary = {}) -> bool: + var parts := _https_url_parts(url) + if parts.is_empty(): + return false + ## Current GitHub release assets use a slash-delimited repository ID; + ## retain the older CDN namespace below for existing release URLs. + if ( + parts.host == "release-assets.githubusercontent.com" + and str(parts.path).begins_with("/github-production-release-asset/1208239711/") + ): + return true + if ( + str(parts.origin) == str(qualification.get("asset_origin", "")) + and str(parts.path).begins_with(str(qualification.get("asset_path", ""))) + and not str(qualification.get("asset_path", "")).is_empty() + ): + return true + return _TRUSTED_PATHS.has(parts.host) and str(parts.path).begins_with( + str(_TRUSTED_PATHS[parts.host]) + ) + + +static func _qualification_config( + enabled: String, release_url: String, asset_prefix: String, token: String +) -> Dictionary: + if enabled.is_empty() and release_url.is_empty() and asset_prefix.is_empty() and token.is_empty(): + return {} + var release := _https_url_parts(release_url) + var assets := _https_url_parts(asset_prefix) + if ( + enabled != "1" + or release.is_empty() + or assets.is_empty() + or not asset_prefix.ends_with("/") + or asset_prefix.contains("?") + or release.origin != assets.origin + or not TransportCapability.is_http_capability(token) + ): + return {"invalid": true} + return { + "asset_origin": str(assets.origin), + "asset_path": str(assets.path), + "release_url": release_url, + "token": token, + } + + +static func _qualification_from_environment() -> Dictionary: + return _qualification_config( + OS.get_environment(QUALIFICATION_SWITCH_ENV), + OS.get_environment(QUALIFICATION_URL_ENV), + OS.get_environment(QUALIFICATION_ASSET_PREFIX_ENV), + OS.get_environment(QUALIFICATION_TOKEN_ENV), + ) + + +static func _request_headers( + url: String, qualification: Dictionary, metadata: bool = false +) -> PackedStringArray: + var headers := PackedStringArray(["Accept: application/vnd.github+json"] if metadata else []) + var authorized := url == str(qualification.get("release_url", "")) + if not authorized: + var parts := _https_url_parts(url) + authorized = ( + not parts.is_empty() + and parts.origin == qualification.get("asset_origin", "") + and str(parts.path).begins_with(str(qualification.get("asset_path", ""))) + and not str(qualification.get("asset_path", "")).is_empty() + ) + if authorized: + headers.append("Authorization: Bearer " + str(qualification.token)) + return headers + + +static func _redirect_url(headers: PackedStringArray) -> String: + var location := "" + for header in headers: + var colon := header.find(":") + if colon < 0 or header.substr(0, colon).strip_edges().to_lower() != "location": + continue + if not location.is_empty(): + return "" + location = header.substr(colon + 1).strip_edges() + return location + + +func _on_check_completed( + result: int, + response_code: int, + _headers: PackedStringArray, + body: PackedByteArray +) -> void: + if _check_request != null: + _check_request.queue_free() + _check_request = null + var release := parse_releases_response(result, response_code, body, "", _qualification) + if not bool(release.get("has_update", false)): + _release.clear() + _qualification.clear() + return + _release = release.duplicate(true) + update_check_completed.emit(release.duplicate(true)) + + +func _download_next() -> void: + if _queue.is_empty(): + _finish_downloads() + return + _active_asset = _queue.pop_front() + _redirect_count = 0 + _request_active_asset(str((_release.get("urls", {}) as Dictionary).get(_active_asset, ""))) + + +func _request_active_asset(url: String) -> void: + _asset_request = HTTPRequest.new() + ## Follow redirects ourselves so every hop remains inside the pinned GitHub + ## release/CDN namespace; HTTPRequest's automatic mode exposes no hop hook. + _asset_request.max_redirects = 0 + _asset_request.body_size_limit = int((_release.get("sizes", {}) as Dictionary).get(_active_asset, 0)) + _asset_request.download_file = _download_path(_active_asset) + _asset_request.request_completed.connect(_on_asset_completed) + add_child(_asset_request) + if _asset_request.request(url, _request_headers(url, _qualification)) != OK: + _fail_download("request failed") + + +func _on_asset_completed( + result: int, + response_code: int, + headers: PackedStringArray, + _body: PackedByteArray +) -> void: + if _asset_request != null: + _asset_request.queue_free() + _asset_request = null + ## With max_redirects = 0 Godot returns REDIRECT_LIMIT_REACHED on the + ## first redirect, including its status and Location. Validate that hop + ## ourselves; other transport failures must still fail closed. + if ( + result in [HTTPRequest.RESULT_SUCCESS, HTTPRequest.RESULT_REDIRECT_LIMIT_REACHED] + and response_code in [301, 302, 303, 307, 308] + ): + var redirect := _redirect_url(headers) + DirAccess.remove_absolute(_download_path(_active_asset)) + if ( + _redirect_count >= MAX_REDIRECTS + or not _is_trusted_download_url(redirect, _qualification) + ): + _fail_download("untrusted or excessive redirect") + return + _redirect_count += 1 + _request_active_asset(redirect) + return + if result != HTTPRequest.RESULT_SUCCESS or response_code != 200: + _fail_download("download failed (%d)" % response_code) + return + var path := _download_path(_active_asset) + var file := FileAccess.open(path, FileAccess.READ) + var expected := int((_release.get("sizes", {}) as Dictionary).get(_active_asset, -1)) + if file == null: + _fail_download("downloaded asset size differs from release metadata") + return + var actual_size := file.get_length() + file.close() + if actual_size != expected: + _fail_download("downloaded asset size differs from release metadata") + return + _download_next() + + +func _finish_downloads() -> void: + var directory := _download_root + var manifest_path := directory.path_join(MANIFEST_NAME) + var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(manifest_path)) + if not parsed is Dictionary or str(parsed.get("source_commit", "")).is_empty(): + _fail_download("manifest is unreadable") + return + activation_requested.emit({ + "archive": directory.path_join(ASSET_NAME), + "manifest": manifest_path, + "signature": directory.path_join(SIGNATURE_NAME), + "repository": REPOSITORY, + "channel": str(_release.channel), + "tag": str(_release.tag), + "version": str(_release.version), + "source": str(parsed.source_commit), + "download_root": directory, + }) + _qualification.clear() + + +func _fail_download(reason: String) -> void: + if _asset_request != null: + _asset_request.queue_free() + _asset_request = null + _queue.clear() + _qualification.clear() + discard_downloads() + push_error("MCP | v4 update preparation failed: %s" % reason) + ## The click-time lock and any quiesced client work are the plugin's to + ## release, and it only does so when it hears the install is over. + install_state_changed.emit({ + "install_in_flight": false, + "status_text": "Update preparation failed", + "button_disabled": false, + }) 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..f212c95 --- /dev/null +++ b/addons/godot_ai/utils/update_mixed_state.gd @@ -0,0 +1,141 @@ +@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] + var child_path := current.path_join(entry["name"]) + if entry["is_dir"] and not d.is_link(child_path): + stack.append(child_path) + 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/uv_resolution_policy.gd b/addons/godot_ai/utils/uv_resolution_policy.gd new file mode 100644 index 0000000..dbaa8b1 --- /dev/null +++ b/addons/godot_ai/utils/uv_resolution_policy.gd @@ -0,0 +1,97 @@ +@tool +extends RefCounted + +## One policy for every production uvx launch. Package identity JSON is a +## compatibility check, not authentication: production code is resolved only +## from the canonical PyPI index, with config files and installed tool envs +## disabled. The explicit process-local qualification switch is the sole path +## that leaves index selection to the qualification environment. + +const PathTemplate := preload("res://addons/godot_ai/clients/_path_template.gd") +const PUBLIC_INDEX := "https://pypi.org/simple" +const PUBLIC_FLAT_INDEX := "https://pypi.org/simple/godot-ai/" +const QUALIFICATION_INDEX_ENV := "GODOT_AI_QUALIFICATION_PYTHON_INDEX" +const _BASE_ARGS := [ + "--isolated", + "--no-config", + "--no-env-file", + "--no-sources", + "--no-build", + "--index-strategy", "first-index", + "--keyring-provider", "disabled", +] + +## Known uv inputs capable of changing package source, candidate selection, +## interpreter, or cache. Production processes spawned by Godot temporarily +## clear them under the global process-spawn mutex; command-line policy remains +## pinned as a second boundary and for client-owned attach launches. +const _RESOLUTION_ENVIRONMENT := [ + "UV_INDEX", "UV_DEFAULT_INDEX", "UV_INDEX_URL", "UV_EXTRA_INDEX_URL", + "UV_FIND_LINKS", "UV_INDEX_STRATEGY", "UV_KEYRING_PROVIDER", + "UV_CONFIG_FILE", "UV_CONSTRAINT", "UV_BUILD_CONSTRAINT", "UV_OVERRIDE", + "UV_NO_CONFIG", "UV_NO_SOURCES", "UV_NO_BUILD", "UV_NO_BINARY", + "UV_NO_BINARY_PACKAGE", "UV_NO_VERIFY_HASHES", "UV_PRERELEASE", + "UV_RESOLUTION", "UV_FORK_STRATEGY", "UV_EXCLUDE_NEWER", + "UV_EXCLUDE_NEWER_PACKAGE", "UV_INSECURE_HOST", "UV_SYSTEM_CERTS", + "UV_NATIVE_TLS", "UV_PYTHON", "UV_PYTHON_DOWNLOADS", "UV_PROJECT", + "UV_WORKING_DIR", "UV_ENV_FILE", "UV_CACHE_DIR", "UV_TOOL_DIR", + "UV_TOOL_BIN_DIR", "UV_LINK_MODE", "UV_REFRESH", "UV_REFRESH_PACKAGE", + "UV_REINSTALL", "UV_REINSTALL_PACKAGE", +] + + +static func warm_environment() -> void: + PathTemplate.warm_env_snapshot(PackedStringArray([QUALIFICATION_INDEX_ENV])) + + +static func qualification_authorized() -> bool: + return PathTemplate.env_lookup(QUALIFICATION_INDEX_ENV) == "1" + + +static func args() -> Array[String]: + var result: Array[String] = [] + result.assign(_BASE_ARGS) + if not qualification_authorized(): + result.append_array([ + "--index", PUBLIC_INDEX, + "--default-index", PUBLIC_INDEX, + "--find-links", PUBLIC_FLAT_INDEX, + ]) + return result + + +static func is_production_command(command: Array) -> bool: + if command.is_empty() or not str(command[0]).get_file().begins_with("uvx"): + return false + return ( + _option_equals(command, "--index", PUBLIC_INDEX) + and _option_equals(command, "--default-index", PUBLIC_INDEX) + and _option_equals(command, "--find-links", PUBLIC_FLAT_INDEX) + ) + + +## Call only while the process-spawn mutex is held. Qualification commands do +## not reach this function: their explicitly authorized UV_INDEX and credentials +## must remain process-local and are never rendered into persisted argv. +static func isolate_environment() -> Dictionary: + var previous := {} + for key in _RESOLUTION_ENVIRONMENT: + previous[key] = { + "present": OS.has_environment(key), + "value": OS.get_environment(key), + } + OS.unset_environment(key) + return previous + + +static func restore_environment(previous: Dictionary) -> void: + for key in previous: + if bool(previous[key].present): + OS.set_environment(key, str(previous[key].value)) + else: + OS.unset_environment(key) + + +static func _option_equals(command: Array, option: String, expected: String) -> bool: + var index := command.find(option) + return index >= 0 and index + 1 < command.size() and str(command[index + 1]) == expected diff --git a/addons/godot_ai/utils/uv_resolution_policy.gd.uid b/addons/godot_ai/utils/uv_resolution_policy.gd.uid new file mode 100644 index 0000000..f729a81 --- /dev/null +++ b/addons/godot_ai/utils/uv_resolution_policy.gd.uid @@ -0,0 +1 @@ +uid://b3y73hby0e7j8 diff --git a/addons/godot_ai/utils/variant_serializer.gd b/addons/godot_ai/utils/variant_serializer.gd new file mode 100644 index 0000000..8e17a16 --- /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 v4 support 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/vision_routing.gd b/addons/godot_ai/vision_routing.gd new file mode 100644 index 0000000..5edc677 --- /dev/null +++ b/addons/godot_ai/vision_routing.gd @@ -0,0 +1,1090 @@ +@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 _active_mutex := Mutex.new() +var _pending: Dictionary = {} # request_id -> {payload, data, connection, provider_id, provider, params} +var _threads: Dictionary = {} # request_id -> Thread ("_test" = ping thread) +var _ping_context: Dictionary = {} +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 _is_active() -> bool: + _active_mutex.lock() + var active := _active + _active_mutex.unlock() + return active + + +func _set_active(active: bool) -> void: + _active_mutex.lock() + _active = active + _active_mutex.unlock() + + +## Plugin teardown: stop workers and join in-flight threads so Godot never +## destroys a Thread mid-execution during a plugin reload. +func shutdown() -> void: + _set_active(false) + ## Workers poll the synchronized active flag 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() + _ping_context.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 + + +## Drain completed Thread return values on the main thread. Workers never +## enqueue old-script Callables: shutdown can set the stop flag, join every +## worker, and discard its result with no deferred callback surviving into an +## exact-tree update or plugin reload. +func poll_completed() -> void: + if not _is_active(): + return + for rid_value in _threads.keys(): + var rid := str(rid_value) + var thread: Thread = _threads.get(rid) + if thread == null or not thread.is_started(): + _threads.erase(rid) + continue + if thread.is_alive(): + continue + var outcome: Variant = thread.wait_to_finish() + _threads.erase(rid) + if not _is_active(): + return + if not outcome is Dictionary: + if rid == "_test": + _finish_ping_result({"ok": false, "error": "worker returned an invalid result"}) + else: + _on_route_complete(rid, {"desc": "", "error": "worker returned an invalid result"}) + continue + match str(outcome.get("kind", "")): + "route": + _on_route_complete(str(outcome.get("request_id", rid)), outcome.get("result")) + "ping": + _finish_ping_result(outcome.get("result", {})) + _: + _pending.erase(rid) + + +# --- 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: + if not _is_active(): + return false + 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: + if not _is_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) -> Dictionary: + var result := _describe_blocking(provider, image_b64, prompt, api_key) + return {"kind": "route", "request_id": rid, "result": 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 _is_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 _is_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 _is_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 _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) -> Dictionary: + var result := _ping_blocking(provider, api_key) + return {"kind": "ping", "result": result} + + +func _finish_ping_result(result: Dictionary) -> void: + var provider: Dictionary = _ping_context.get("provider", {}) + var status_label: Label = _ping_context.get("status_label") + var key_source := str(_ping_context.get("key_source", "stored key")) + _ping_context.clear() + 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 + _ping_context = { + "provider": provider, + "status_label": _tab_status, + "key_source": key_source, + } + var start_err := thread.start(_ping_worker.bind(provider, api_key)) + if start_err != OK: + ## A failed thread start must not leave the button disabled and the + ## status stuck on "Testing..." forever. + _threads.erase("_test") + _ping_context.clear() + _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

_rAmX9 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