From 844ec194cbe082bccd499ef819f6c7e827f31c9f Mon Sep 17 00:00:00 2001 From: adtpdn Date: Mon, 15 Jun 2026 14:28:26 +0800 Subject: [PATCH] Replace dasher-pack with unified animation-pack using original Blender bone names --- addons/godot_ai/LICENSE | 21 + addons/godot_ai/README.md | 53 + addons/godot_ai/client_configurator.gd | 620 ++++ addons/godot_ai/client_configurator.gd.uid | 1 + addons/godot_ai/clients/_atomic_write.gd | 161 ++ addons/godot_ai/clients/_atomic_write.gd.uid | 1 + addons/godot_ai/clients/_base.gd | 211 ++ addons/godot_ai/clients/_base.gd.uid | 1 + addons/godot_ai/clients/_cli_exec.gd | 143 + addons/godot_ai/clients/_cli_exec.gd.uid | 1 + addons/godot_ai/clients/_cli_finder.gd | 175 ++ addons/godot_ai/clients/_cli_finder.gd.uid | 1 + addons/godot_ai/clients/_cli_strategy.gd | 152 + addons/godot_ai/clients/_cli_strategy.gd.uid | 1 + addons/godot_ai/clients/_json_strategy.gd | 263 ++ addons/godot_ai/clients/_json_strategy.gd.uid | 1 + addons/godot_ai/clients/_manual_command.gd | 113 + .../godot_ai/clients/_manual_command.gd.uid | 1 + addons/godot_ai/clients/_path_template.gd | 62 + addons/godot_ai/clients/_path_template.gd.uid | 1 + addons/godot_ai/clients/_registry.gd | 71 + addons/godot_ai/clients/_registry.gd.uid | 1 + addons/godot_ai/clients/_toml_strategy.gd | 269 ++ addons/godot_ai/clients/_toml_strategy.gd.uid | 1 + addons/godot_ai/clients/antigravity.gd | 19 + addons/godot_ai/clients/antigravity.gd.uid | 1 + addons/godot_ai/clients/cherry_studio.gd | 20 + addons/godot_ai/clients/cherry_studio.gd.uid | 1 + addons/godot_ai/clients/claude_code.gd | 24 + addons/godot_ai/clients/claude_code.gd.uid | 1 + addons/godot_ai/clients/claude_desktop.gd | 24 + addons/godot_ai/clients/claude_desktop.gd.uid | 1 + addons/godot_ai/clients/cline.gd | 29 + addons/godot_ai/clients/cline.gd.uid | 1 + addons/godot_ai/clients/codex.gd | 18 + addons/godot_ai/clients/codex.gd.uid | 1 + addons/godot_ai/clients/cursor.gd | 12 + addons/godot_ai/clients/cursor.gd.uid | 1 + addons/godot_ai/clients/gemini_cli.gd | 16 + addons/godot_ai/clients/gemini_cli.gd.uid | 1 + addons/godot_ai/clients/kilo_code.gd | 24 + addons/godot_ai/clients/kilo_code.gd.uid | 1 + addons/godot_ai/clients/kimi_code.gd | 15 + addons/godot_ai/clients/kimi_code.gd.uid | 1 + addons/godot_ai/clients/kiro.gd | 17 + addons/godot_ai/clients/kiro.gd.uid | 1 + addons/godot_ai/clients/opencode.gd | 21 + addons/godot_ai/clients/opencode.gd.uid | 1 + addons/godot_ai/clients/qwen_code.gd | 16 + addons/godot_ai/clients/qwen_code.gd.uid | 1 + addons/godot_ai/clients/roo_code.gd | 29 + addons/godot_ai/clients/roo_code.gd.uid | 1 + addons/godot_ai/clients/trae.gd | 16 + addons/godot_ai/clients/trae.gd.uid | 1 + addons/godot_ai/clients/vscode.gd | 20 + addons/godot_ai/clients/vscode.gd.uid | 1 + addons/godot_ai/clients/vscode_insiders.gd | 17 + .../godot_ai/clients/vscode_insiders.gd.uid | 1 + addons/godot_ai/clients/windsurf.gd | 16 + addons/godot_ai/clients/windsurf.gd.uid | 1 + addons/godot_ai/clients/zed.gd | 19 + addons/godot_ai/clients/zed.gd.uid | 1 + addons/godot_ai/connection.gd | 492 ++++ addons/godot_ai/connection.gd.uid | 1 + .../godot_ai/debugger/mcp_debugger_plugin.gd | 790 ++++++ .../debugger/mcp_debugger_plugin.gd.uid | 1 + addons/godot_ai/dispatcher.gd | 293 ++ addons/godot_ai/dispatcher.gd.uid | 1 + addons/godot_ai/dock_panels/log_viewer.gd | 95 + addons/godot_ai/dock_panels/log_viewer.gd.uid | 1 + .../godot_ai/dock_panels/port_picker_panel.gd | 78 + .../dock_panels/port_picker_panel.gd.uid | 1 + addons/godot_ai/handlers/_node_validator.gd | 71 + .../godot_ai/handlers/_node_validator.gd.uid | 1 + addons/godot_ai/handlers/_param_validators.gd | 48 + .../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 | 528 ++++ .../handlers/animation_presets.gd.uid | 1 + addons/godot_ai/handlers/animation_values.gd | 465 +++ .../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 | 359 +++ 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 | 131 + addons/godot_ai/handlers/batch_handler.gd.uid | 1 + addons/godot_ai/handlers/camera_handler.gd | 1151 ++++++++ .../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 | 143 + addons/godot_ai/handlers/camera_values.gd.uid | 1 + addons/godot_ai/handlers/client_handler.gd | 43 + .../godot_ai/handlers/client_handler.gd.uid | 1 + .../handlers/control_draw_recipe_handler.gd | 325 +++ .../control_draw_recipe_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 | 1098 ++++++++ .../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 | 112 + .../handlers/filesystem_handler.gd.uid | 1 + addons/godot_ai/handlers/input_handler.gd | 278 ++ addons/godot_ai/handlers/input_handler.gd.uid | 1 + addons/godot_ai/handlers/material_handler.gd | 788 ++++++ .../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 | 255 ++ .../godot_ai/handlers/material_values.gd.uid | 1 + addons/godot_ai/handlers/node_handler.gd | 866 ++++++ addons/godot_ai/handlers/node_handler.gd.uid | 1 + addons/godot_ai/handlers/particle_handler.gd | 761 +++++ .../godot_ai/handlers/particle_handler.gd.uid | 1 + addons/godot_ai/handlers/particle_presets.gd | 282 ++ .../godot_ai/handlers/particle_presets.gd.uid | 1 + addons/godot_ai/handlers/particle_values.gd | 228 ++ .../godot_ai/handlers/particle_values.gd.uid | 1 + .../handlers/physics_shape_handler.gd | 337 +++ .../handlers/physics_shape_handler.gd.uid | 1 + addons/godot_ai/handlers/project_handler.gd | 262 ++ .../godot_ai/handlers/project_handler.gd.uid | 1 + addons/godot_ai/handlers/resource_handler.gd | 398 +++ .../godot_ai/handlers/resource_handler.gd.uid | 1 + addons/godot_ai/handlers/scene_handler.gd | 267 ++ addons/godot_ai/handlers/scene_handler.gd.uid | 1 + addons/godot_ai/handlers/script_handler.gd | 398 +++ .../godot_ai/handlers/script_handler.gd.uid | 1 + addons/godot_ai/handlers/signal_handler.gd | 258 ++ .../godot_ai/handlers/signal_handler.gd.uid | 1 + addons/godot_ai/handlers/test_handler.gd | 82 + addons/godot_ai/handlers/test_handler.gd.uid | 1 + addons/godot_ai/handlers/texture_handler.gd | 199 ++ .../godot_ai/handlers/texture_handler.gd.uid | 1 + addons/godot_ai/handlers/theme_handler.gd | 488 ++++ addons/godot_ai/handlers/theme_handler.gd.uid | 1 + addons/godot_ai/handlers/ui_handler.gd | 533 ++++ addons/godot_ai/handlers/ui_handler.gd.uid | 1 + addons/godot_ai/mcp_dock.gd | 2496 +++++++++++++++++ addons/godot_ai/mcp_dock.gd.uid | 1 + addons/godot_ai/plugin.cfg | 7 + addons/godot_ai/plugin.gd | 1690 +++++++++++ addons/godot_ai/plugin.gd.uid | 1 + addons/godot_ai/runtime/draw_recipe.gd | 107 + addons/godot_ai/runtime/draw_recipe.gd.uid | 1 + addons/godot_ai/runtime/game_helper.gd | 869 ++++++ addons/godot_ai/runtime/game_helper.gd.uid | 1 + addons/godot_ai/runtime/logger_loader.gd | 56 + addons/godot_ai/runtime/logger_loader.gd.uid | 1 + addons/godot_ai/runtime/loggers/.gdignore | 0 .../godot_ai/runtime/loggers/editor_logger.gd | 151 + .../godot_ai/runtime/loggers/game_logger.gd | 158 ++ addons/godot_ai/telemetry.gd | 199 ++ addons/godot_ai/telemetry.gd.uid | 1 + addons/godot_ai/testing/stub_backtrace.gd | 38 + addons/godot_ai/testing/stub_backtrace.gd.uid | 1 + addons/godot_ai/testing/test_runner.gd | 244 ++ addons/godot_ai/testing/test_runner.gd.uid | 1 + addons/godot_ai/testing/test_suite.gd | 277 ++ addons/godot_ai/testing/test_suite.gd.uid | 1 + addons/godot_ai/tool_catalog.gd | 86 + addons/godot_ai/tool_catalog.gd.uid | 1 + addons/godot_ai/update_reload_runner.gd | 532 ++++ addons/godot_ai/update_reload_runner.gd.uid | 1 + addons/godot_ai/utils/class_introspection.gd | 239 ++ .../godot_ai/utils/class_introspection.gd.uid | 1 + addons/godot_ai/utils/editor_log_buffer.gd | 104 + .../godot_ai/utils/editor_log_buffer.gd.uid | 1 + addons/godot_ai/utils/error_codes.gd | 84 + 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 | 50 + addons/godot_ai/utils/game_log_buffer.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 | 63 + addons/godot_ai/utils/log_buffer.gd.uid | 1 + addons/godot_ai/utils/mcp_adoption_label.gd | 23 + .../godot_ai/utils/mcp_adoption_label.gd.uid | 1 + .../utils/mcp_client_refresh_state.gd | 98 + .../utils/mcp_client_refresh_state.gd.uid | 1 + addons/godot_ai/utils/mcp_server_state.gd | 189 ++ addons/godot_ai/utils/mcp_server_state.gd.uid | 1 + addons/godot_ai/utils/mcp_startup_path.gd | 34 + addons/godot_ai/utils/mcp_startup_path.gd.uid | 1 + addons/godot_ai/utils/path_validator.gd | 171 ++ addons/godot_ai/utils/path_validator.gd.uid | 1 + addons/godot_ai/utils/port_resolver.gd | 315 +++ addons/godot_ai/utils/port_resolver.gd.uid | 1 + addons/godot_ai/utils/resource_io.gd | 131 + addons/godot_ai/utils/resource_io.gd.uid | 1 + addons/godot_ai/utils/scene_path.gd | 146 + addons/godot_ai/utils/scene_path.gd.uid | 1 + addons/godot_ai/utils/server_lifecycle.gd | 904 ++++++ addons/godot_ai/utils/server_lifecycle.gd.uid | 1 + addons/godot_ai/utils/server_version_check.gd | 136 + .../utils/server_version_check.gd.uid | 1 + addons/godot_ai/utils/settings.gd | 39 + 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 + addons/godot_ai/utils/update_manager.gd | 443 +++ addons/godot_ai/utils/update_manager.gd.uid | 1 + addons/godot_ai/utils/update_mixed_state.gd | 140 + .../godot_ai/utils/update_mixed_state.gd.uid | 1 + addons/godot_ai/utils/uv_cache_cleanup.gd | 161 ++ addons/godot_ai/utils/uv_cache_cleanup.gd.uid | 1 + addons/godot_ai/utils/variant_serializer.gd | 71 + .../godot_ai/utils/variant_serializer.gd.uid | 1 + .../utils/windows_port_reservation.gd | 166 ++ .../utils/windows_port_reservation.gd.uid | 1 + assets/characters/Bob.glb | Bin 1044860 -> 1044861 bytes assets/characters/Bob.glb.bak | Bin 0 -> 1044860 bytes assets/characters/Bob.glb.import | 8 +- assets/characters/Gatot.glb | Bin 1322540 -> 1322536 bytes assets/characters/Gatot.glb.bak | Bin 0 -> 1322540 bytes assets/characters/Gatot.glb.import | 8 +- assets/characters/Masbro.glb | Bin 1174088 -> 1174082 bytes assets/characters/Masbro.glb.bak | Bin 0 -> 1174088 bytes assets/characters/Masbro.glb.import | 8 +- assets/characters/Oldpop.glb | Bin 1008892 -> 1008888 bytes assets/characters/Oldpop.glb.bak | Bin 0 -> 1008892 bytes assets/characters/Oldpop.glb.import | 8 +- assets/characters/animation-0.glb | Bin 0 -> 447860 bytes assets/characters/animation-0.glb.bak | Bin 0 -> 447860 bytes ..._hit.glb.import => animation-0.glb.import} | 18 +- .../characters/animations/animation-pack.res | Bin 462 -> 505795 bytes assets/characters/animations/dasher-pack.res | Bin 16746 -> 0 bytes assets/characters/animations/dasher-pack.tres | 631 ----- .../animations/dasher_getting_hit.res | Bin 8866 -> 0 bytes assets/characters/animations/dasher_hit.res | Bin 10094 -> 0 bytes assets/characters/animations/dasher_hold.res | Bin 6480 -> 0 bytes assets/characters/animations/dasher_put.res | Bin 7821 -> 0 bytes assets/characters/animations/dasher_stun.res | Bin 6905 -> 0 bytes assets/characters/animations/dasher_take.res | Bin 7822 -> 0 bytes .../characters/character_generalizer.gd.uid | 1 + .../characters/dashers/dasher_getting_hit.glb | Bin 59864 -> 0 bytes assets/characters/dashers/dasher_hit.glb | Bin 59848 -> 0 bytes .../characters/dashers/dasher_hit.glb.import | 42 - assets/characters/dashers/dasher_hold.glb | Bin 59772 -> 0 bytes .../characters/dashers/dasher_hold.glb.import | 42 - assets/characters/dashers/dasher_put.glb | Bin 59768 -> 0 bytes .../characters/dashers/dasher_put.glb.import | 42 - assets/characters/dashers/dasher_stun.glb | Bin 59848 -> 0 bytes .../characters/dashers/dasher_stun.glb.import | 42 - assets/characters/dashers/dasher_take.glb | Bin 59772 -> 0 bytes .../characters/dashers/dasher_take.glb.import | 42 - assets/characters/dashers/tekton_hold.glb | Bin 26048 -> 0 bytes .../characters/dashers/tekton_hold.glb.import | 42 - assets/characters/dashers/tekton_put.glb | Bin 26048 -> 0 bytes .../characters/dashers/tekton_put.glb.import | 42 - assets/characters/dashers/tekton_take.glb | Bin 26048 -> 0 bytes .../characters/dashers/tekton_take.glb.import | 42 - assets/models/meshes/block.res | Bin 11641 -> 11654 bytes scenes/player.gd | 53 +- scenes/player.tscn | 12 +- scenes/static_tekton_mesh.tscn | 2 +- scripts/managers/camera_context_manager.gd | 2 +- tools/README.md | 79 +- tools/build_animation_pack.gd | 196 ++ tools/build_animation_pack.gd.uid | 1 + tools/build_dasher_pack.gd | 52 - tools/build_dasher_pack_headless.gd | 48 - tools/build_patch.gd | 53 - tools/convert_dasher_animations.gd | 185 -- tools/convert_dasher_animations_headless.gd | 256 -- .../convert_dasher_animations_headless.gd.uid | 1 - tools/dump_anim_names.gd | 20 - tools/dump_anim_names.gd.uid | 1 - tools/dump_bones.gd | 32 - tools/dump_bones.gd.uid | 1 - tools/dump_masbro.gd | 15 - tools/dump_masbro.gd.uid | 1 - tools/rename_rig_in_glb.py | 90 + tools/test_types.gd | 8 - tools/test_types.gd.uid | 1 - tools/verify_dasher.gd | 33 - tools/verify_dasher.gd.uid | 1 - tools/verify_dasher_tracks.gd | 13 - tools/verify_dasher_tracks.gd.uid | 1 - tools/verify_pack.gd | 16 - tools/verify_pack.gd.uid | 1 - tools/verify_raw.gd | 19 - tools/verify_raw.gd.uid | 1 - tools/verify_walk.gd | 34 - tools/verify_walk.gd.uid | 1 - tools/verify_walk_tracks.gd | 12 - tools/verify_walk_tracks.gd.uid | 1 - 297 files changed, 28680 insertions(+), 1884 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/_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/antigravity.gd create mode 100644 addons/godot_ai/clients/antigravity.gd.uid create mode 100644 addons/godot_ai/clients/cherry_studio.gd create mode 100644 addons/godot_ai/clients/cherry_studio.gd.uid create mode 100644 addons/godot_ai/clients/claude_code.gd create mode 100644 addons/godot_ai/clients/claude_code.gd.uid create mode 100644 addons/godot_ai/clients/claude_desktop.gd create mode 100644 addons/godot_ai/clients/claude_desktop.gd.uid create mode 100644 addons/godot_ai/clients/cline.gd create mode 100644 addons/godot_ai/clients/cline.gd.uid create mode 100644 addons/godot_ai/clients/codex.gd create mode 100644 addons/godot_ai/clients/codex.gd.uid create mode 100644 addons/godot_ai/clients/cursor.gd create mode 100644 addons/godot_ai/clients/cursor.gd.uid create mode 100644 addons/godot_ai/clients/gemini_cli.gd create mode 100644 addons/godot_ai/clients/gemini_cli.gd.uid create mode 100644 addons/godot_ai/clients/kilo_code.gd create mode 100644 addons/godot_ai/clients/kilo_code.gd.uid create mode 100644 addons/godot_ai/clients/kimi_code.gd create mode 100644 addons/godot_ai/clients/kimi_code.gd.uid create mode 100644 addons/godot_ai/clients/kiro.gd create mode 100644 addons/godot_ai/clients/kiro.gd.uid create mode 100644 addons/godot_ai/clients/opencode.gd create mode 100644 addons/godot_ai/clients/opencode.gd.uid create mode 100644 addons/godot_ai/clients/qwen_code.gd create mode 100644 addons/godot_ai/clients/qwen_code.gd.uid create mode 100644 addons/godot_ai/clients/roo_code.gd create mode 100644 addons/godot_ai/clients/roo_code.gd.uid create mode 100644 addons/godot_ai/clients/trae.gd create mode 100644 addons/godot_ai/clients/trae.gd.uid create mode 100644 addons/godot_ai/clients/vscode.gd create mode 100644 addons/godot_ai/clients/vscode.gd.uid create mode 100644 addons/godot_ai/clients/vscode_insiders.gd create mode 100644 addons/godot_ai/clients/vscode_insiders.gd.uid create mode 100644 addons/godot_ai/clients/windsurf.gd create mode 100644 addons/godot_ai/clients/windsurf.gd.uid create mode 100644 addons/godot_ai/clients/zed.gd create mode 100644 addons/godot_ai/clients/zed.gd.uid create mode 100644 addons/godot_ai/connection.gd create mode 100644 addons/godot_ai/connection.gd.uid create mode 100644 addons/godot_ai/debugger/mcp_debugger_plugin.gd create mode 100644 addons/godot_ai/debugger/mcp_debugger_plugin.gd.uid create mode 100644 addons/godot_ai/dispatcher.gd create mode 100644 addons/godot_ai/dispatcher.gd.uid create mode 100644 addons/godot_ai/dock_panels/log_viewer.gd create mode 100644 addons/godot_ai/dock_panels/log_viewer.gd.uid create mode 100644 addons/godot_ai/dock_panels/port_picker_panel.gd create mode 100644 addons/godot_ai/dock_panels/port_picker_panel.gd.uid create mode 100644 addons/godot_ai/handlers/_node_validator.gd create mode 100644 addons/godot_ai/handlers/_node_validator.gd.uid create mode 100644 addons/godot_ai/handlers/_param_validators.gd create mode 100644 addons/godot_ai/handlers/_param_validators.gd.uid create mode 100644 addons/godot_ai/handlers/_property_errors.gd create mode 100644 addons/godot_ai/handlers/_property_errors.gd.uid create mode 100644 addons/godot_ai/handlers/animation_handler.gd create mode 100644 addons/godot_ai/handlers/animation_handler.gd.uid create mode 100644 addons/godot_ai/handlers/animation_presets.gd create mode 100644 addons/godot_ai/handlers/animation_presets.gd.uid create mode 100644 addons/godot_ai/handlers/animation_values.gd create mode 100644 addons/godot_ai/handlers/animation_values.gd.uid create mode 100644 addons/godot_ai/handlers/api_handler.gd create mode 100644 addons/godot_ai/handlers/api_handler.gd.uid create mode 100644 addons/godot_ai/handlers/audio_handler.gd create mode 100644 addons/godot_ai/handlers/audio_handler.gd.uid create mode 100644 addons/godot_ai/handlers/autoload_handler.gd create mode 100644 addons/godot_ai/handlers/autoload_handler.gd.uid create mode 100644 addons/godot_ai/handlers/batch_handler.gd create mode 100644 addons/godot_ai/handlers/batch_handler.gd.uid create mode 100644 addons/godot_ai/handlers/camera_handler.gd create mode 100644 addons/godot_ai/handlers/camera_handler.gd.uid create mode 100644 addons/godot_ai/handlers/camera_presets.gd create mode 100644 addons/godot_ai/handlers/camera_presets.gd.uid create mode 100644 addons/godot_ai/handlers/camera_values.gd create mode 100644 addons/godot_ai/handlers/camera_values.gd.uid create mode 100644 addons/godot_ai/handlers/client_handler.gd create mode 100644 addons/godot_ai/handlers/client_handler.gd.uid create mode 100644 addons/godot_ai/handlers/control_draw_recipe_handler.gd create mode 100644 addons/godot_ai/handlers/control_draw_recipe_handler.gd.uid create mode 100644 addons/godot_ai/handlers/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/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/ui_handler.gd create mode 100644 addons/godot_ai/handlers/ui_handler.gd.uid create mode 100644 addons/godot_ai/mcp_dock.gd create mode 100644 addons/godot_ai/mcp_dock.gd.uid create mode 100644 addons/godot_ai/plugin.cfg create mode 100644 addons/godot_ai/plugin.gd create mode 100644 addons/godot_ai/plugin.gd.uid create mode 100644 addons/godot_ai/runtime/draw_recipe.gd create mode 100644 addons/godot_ai/runtime/draw_recipe.gd.uid create mode 100644 addons/godot_ai/runtime/game_helper.gd create mode 100644 addons/godot_ai/runtime/game_helper.gd.uid create mode 100644 addons/godot_ai/runtime/logger_loader.gd create mode 100644 addons/godot_ai/runtime/logger_loader.gd.uid create mode 100644 addons/godot_ai/runtime/loggers/.gdignore create mode 100644 addons/godot_ai/runtime/loggers/editor_logger.gd create mode 100644 addons/godot_ai/runtime/loggers/game_logger.gd create mode 100644 addons/godot_ai/telemetry.gd create mode 100644 addons/godot_ai/telemetry.gd.uid create mode 100644 addons/godot_ai/testing/stub_backtrace.gd create mode 100644 addons/godot_ai/testing/stub_backtrace.gd.uid create mode 100644 addons/godot_ai/testing/test_runner.gd create mode 100644 addons/godot_ai/testing/test_runner.gd.uid create mode 100644 addons/godot_ai/testing/test_suite.gd create mode 100644 addons/godot_ai/testing/test_suite.gd.uid create mode 100644 addons/godot_ai/tool_catalog.gd create mode 100644 addons/godot_ai/tool_catalog.gd.uid create mode 100644 addons/godot_ai/update_reload_runner.gd create mode 100644 addons/godot_ai/update_reload_runner.gd.uid create mode 100644 addons/godot_ai/utils/class_introspection.gd create mode 100644 addons/godot_ai/utils/class_introspection.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/log_backtrace.gd create mode 100644 addons/godot_ai/utils/log_backtrace.gd.uid create mode 100644 addons/godot_ai/utils/log_buffer.gd create mode 100644 addons/godot_ai/utils/log_buffer.gd.uid create mode 100644 addons/godot_ai/utils/mcp_adoption_label.gd create mode 100644 addons/godot_ai/utils/mcp_adoption_label.gd.uid create mode 100644 addons/godot_ai/utils/mcp_client_refresh_state.gd create mode 100644 addons/godot_ai/utils/mcp_client_refresh_state.gd.uid create mode 100644 addons/godot_ai/utils/mcp_server_state.gd create mode 100644 addons/godot_ai/utils/mcp_server_state.gd.uid create mode 100644 addons/godot_ai/utils/mcp_startup_path.gd create mode 100644 addons/godot_ai/utils/mcp_startup_path.gd.uid create mode 100644 addons/godot_ai/utils/path_validator.gd create mode 100644 addons/godot_ai/utils/path_validator.gd.uid create mode 100644 addons/godot_ai/utils/port_resolver.gd create mode 100644 addons/godot_ai/utils/port_resolver.gd.uid create mode 100644 addons/godot_ai/utils/resource_io.gd create mode 100644 addons/godot_ai/utils/resource_io.gd.uid create mode 100644 addons/godot_ai/utils/scene_path.gd create mode 100644 addons/godot_ai/utils/scene_path.gd.uid create mode 100644 addons/godot_ai/utils/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/update_manager.gd create mode 100644 addons/godot_ai/utils/update_manager.gd.uid create mode 100644 addons/godot_ai/utils/update_mixed_state.gd create mode 100644 addons/godot_ai/utils/update_mixed_state.gd.uid create mode 100644 addons/godot_ai/utils/uv_cache_cleanup.gd create mode 100644 addons/godot_ai/utils/uv_cache_cleanup.gd.uid create mode 100644 addons/godot_ai/utils/variant_serializer.gd create mode 100644 addons/godot_ai/utils/variant_serializer.gd.uid create mode 100644 addons/godot_ai/utils/windows_port_reservation.gd create mode 100644 addons/godot_ai/utils/windows_port_reservation.gd.uid create mode 100644 assets/characters/Bob.glb.bak create mode 100644 assets/characters/Gatot.glb.bak create mode 100644 assets/characters/Masbro.glb.bak create mode 100644 assets/characters/Oldpop.glb.bak create mode 100644 assets/characters/animation-0.glb create mode 100644 assets/characters/animation-0.glb.bak rename assets/characters/{dashers/dasher_getting_hit.glb.import => animation-0.glb.import} (66%) delete mode 100644 assets/characters/animations/dasher-pack.res delete mode 100644 assets/characters/animations/dasher-pack.tres delete mode 100644 assets/characters/animations/dasher_getting_hit.res delete mode 100644 assets/characters/animations/dasher_hit.res delete mode 100644 assets/characters/animations/dasher_hold.res delete mode 100644 assets/characters/animations/dasher_put.res delete mode 100644 assets/characters/animations/dasher_stun.res delete mode 100644 assets/characters/animations/dasher_take.res create mode 100644 assets/characters/character_generalizer.gd.uid delete mode 100644 assets/characters/dashers/dasher_getting_hit.glb delete mode 100644 assets/characters/dashers/dasher_hit.glb delete mode 100644 assets/characters/dashers/dasher_hit.glb.import delete mode 100644 assets/characters/dashers/dasher_hold.glb delete mode 100644 assets/characters/dashers/dasher_hold.glb.import delete mode 100644 assets/characters/dashers/dasher_put.glb delete mode 100644 assets/characters/dashers/dasher_put.glb.import delete mode 100644 assets/characters/dashers/dasher_stun.glb delete mode 100644 assets/characters/dashers/dasher_stun.glb.import delete mode 100644 assets/characters/dashers/dasher_take.glb delete mode 100644 assets/characters/dashers/dasher_take.glb.import delete mode 100644 assets/characters/dashers/tekton_hold.glb delete mode 100644 assets/characters/dashers/tekton_hold.glb.import delete mode 100644 assets/characters/dashers/tekton_put.glb delete mode 100644 assets/characters/dashers/tekton_put.glb.import delete mode 100644 assets/characters/dashers/tekton_take.glb delete mode 100644 assets/characters/dashers/tekton_take.glb.import create mode 100644 tools/build_animation_pack.gd create mode 100644 tools/build_animation_pack.gd.uid delete mode 100644 tools/build_dasher_pack.gd delete mode 100644 tools/build_dasher_pack_headless.gd delete mode 100644 tools/build_patch.gd delete mode 100644 tools/convert_dasher_animations.gd delete mode 100644 tools/convert_dasher_animations_headless.gd delete mode 100644 tools/convert_dasher_animations_headless.gd.uid delete mode 100644 tools/dump_anim_names.gd delete mode 100644 tools/dump_anim_names.gd.uid delete mode 100644 tools/dump_bones.gd delete mode 100644 tools/dump_bones.gd.uid delete mode 100644 tools/dump_masbro.gd delete mode 100644 tools/dump_masbro.gd.uid create mode 100644 tools/rename_rig_in_glb.py delete mode 100644 tools/test_types.gd delete mode 100644 tools/test_types.gd.uid delete mode 100644 tools/verify_dasher.gd delete mode 100644 tools/verify_dasher.gd.uid delete mode 100644 tools/verify_dasher_tracks.gd delete mode 100644 tools/verify_dasher_tracks.gd.uid delete mode 100644 tools/verify_pack.gd delete mode 100644 tools/verify_pack.gd.uid delete mode 100644 tools/verify_raw.gd delete mode 100644 tools/verify_raw.gd.uid delete mode 100644 tools/verify_walk.gd delete mode 100644 tools/verify_walk.gd.uid delete mode 100644 tools/verify_walk_tracks.gd delete mode 100644 tools/verify_walk_tracks.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..c8efadb --- /dev/null +++ b/addons/godot_ai/README.md @@ -0,0 +1,53 @@ +# Godot AI + +Connect AI assistants to a live Godot editor via the [Model Context Protocol](https://modelcontextprotocol.io/introduction) (MCP). + +Godot AI bridges Claude Code, Codex, Antigravity, and other MCP clients with your editor — inspect scenes, create nodes, modify properties, run tests, search project files, and more, all from a prompt. + +## Quick Start + +1. Copy `addons/godot_ai/` into your project's `addons/` folder +2. Enable the plugin: **Project > Project Settings > Plugins > Godot AI** +3. Pick your MCP client in the **Godot AI** dock and press **Configure** + +The plugin auto-starts the MCP server and connects over WebSocket. No manual configuration required. + +## Requirements + +- Godot 4.3+ (4.4+ recommended) +- [uv](https://docs.astral.sh/uv/) (used to install the Python server) +
+ Install uv + + **macOS / Linux:** + ```bash + curl -LsSf https://astral.sh/uv/install.sh | sh + ``` + + **Windows (PowerShell):** + ```powershell + powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" + ``` + + **Homebrew (macOS / Linux):** + ```bash + brew install uv + ``` + + **pipx:** + ```bash + pipx install uv + ``` + + See the [uv install docs](https://docs.astral.sh/uv/getting-started/installation/) for more options. + +
+- An MCP client ([Claude Code](https://docs.anthropic.com/en/docs/claude-code) | [Codex](https://openai.com/index/codex/) | [Antigravity](https://www.antigravity.dev/)) + +## Documentation + +Full documentation, contributing guide, and source code: [github.com/hi-godot/godot-ai](https://github.com/hi-godot/godot-ai) + +## License + +[MIT](LICENSE) diff --git a/addons/godot_ai/client_configurator.gd b/addons/godot_ai/client_configurator.gd new file mode 100644 index 0000000..6d1e7cd --- /dev/null +++ b/addons/godot_ai/client_configurator.gd @@ -0,0 +1,620 @@ +@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 CliStrategy := preload("res://addons/godot_ai/clients/_cli_strategy.gd") +const ManualCommand := preload("res://addons/godot_ai/clients/_manual_command.gd") +const CliFinder := preload("res://addons/godot_ai/clients/_cli_finder.gd") +const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd") + +const 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 +const SETTING_WS_PORT := "godot_ai/ws_port" +const SETTING_STARTUP_TRACE := "godot_ai/log_startup_timing" + + +## Active HTTP port: user override (if in range) or `DEFAULT_HTTP_PORT`. +static func http_port() -> int: + return _read_port_setting(McpSettings.SETTING_HTTP_PORT, DEFAULT_HTTP_PORT) + + +## Active WebSocket port: user override (if in range) or `DEFAULT_WS_PORT`. +static func ws_port() -> int: + return _read_port_setting(SETTING_WS_PORT, DEFAULT_WS_PORT) + + +static func http_url() -> String: + return "http://127.0.0.1:%d/mcp" % http_port() + + +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) + + +static func _register_port_setting(es: EditorSettings, key: String, default_port: int) -> void: + if not es.has_setting(key): + es.set_setting(key, default_port) + es.set_initial_value(key, default_port, false) + es.add_property_info({ + "name": key, + "type": TYPE_INT, + "hint": PROPERTY_HINT_RANGE, + "hint_string": "%d,%d,1" % [MIN_PORT, MAX_PORT], + }) + + +static func _register_bool_setting(es: EditorSettings, key: String, default_value: bool) -> void: + if not es.has_setting(key): + es.set_setting(key, default_value) + es.set_initial_value(key, default_value, false) + es.add_property_info({ + "name": key, + "type": TYPE_BOOL, + }) + + +static func startup_trace_enabled() -> bool: + var raw := OS.get_environment(STARTUP_TRACE_ENV).strip_edges().to_lower() + if raw == "1" or raw == "true" or raw == "yes" or raw == "on": + return true + if Engine.is_editor_hint(): + var es := EditorInterface.get_editor_settings() + if es != null and es.has_setting(SETTING_STARTUP_TRACE): + return bool(es.get_setting(SETTING_STARTUP_TRACE)) + return false + + +## 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. +static func excluded_domains() -> String: + var es := EditorInterface.get_editor_settings() + if es == null or not es.has_setting(McpSettings.SETTING_EXCLUDED_DOMAINS): + return "" + var raw := str(es.get_setting(McpSettings.SETTING_EXCLUDED_DOMAINS)) + 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) + + +## Clamp `start` into the legal port range, then walk +## `candidate`..`candidate+span-1` and return the first port that is NOT +## currently excluded by Windows' winnat reservation table. Falls back to the +## clamped candidate if nothing clears (caller can apply anyway — user may +## just retry). On non-Windows this is a no-op: all ports pass, returns the +## clamped candidate. +static func suggest_free_port(start: int, span: int = 2048) -> int: + var candidate := clampi(start, MIN_PORT, MAX_PORT - span + 1) + return WindowsPortReservation.suggest_non_excluded_port(candidate, span, MAX_PORT) + + +# --- 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 = "") -> Dictionary: + var client := ClientRegistry.get_by_id(id) + if client == null: + return {"status": "error", "message": "Unknown client: %s" % id} + ## 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 result := _dispatch_configure(client, url) + ## Trust-but-verify: a strategy may report ok and have actually written the + ## file, yet the entry is missing/stale on the read-back path — most often + ## because the user's installed client is reading a different file than + ## `path_template` resolves to (issue #201). Re-read the live state and + ## surface a clear error before the dock reports a bogus green dot. + return _verify_post_state(client, result, Client.Status.CONFIGURED, url, "configure") + + +static func check_status(id: String) -> Client.Status: + var client := ClientRegistry.get_by_id(id) + if client == null: + return Client.Status.NOT_CONFIGURED + return _dispatch_check_status(client, http_url()) + + +static func check_status_for_url(id: String, url: String) -> Client.Status: + var client := ClientRegistry.get_by_id(id) + if client == null: + return Client.Status.NOT_CONFIGURED + return _dispatch_check_status(client, url) + + +static func check_status_for_url_with_cli_path(id: String, url: String, cli_path: String) -> Client.Status: + return check_status_details_for_url_with_cli_path(id, url, cli_path).get("status", Client.Status.NOT_CONFIGURED) + + +## Detailed variant used by the dock refresh worker. Returns +## `{"status": Status, "error_msg": String}` so the worker can surface +## "probe timed out" on the row instead of silently flipping it to +## NOT_CONFIGURED. Callers that only need the status can use the simpler +## helper above. +static func check_status_details_for_url_with_cli_path(id: String, url: String, cli_path: String) -> Dictionary: + 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": ""} + return _dispatch_check_status_with_cli_path_details(client, url, cli_path) + + +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} + + +## 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 = "") -> Dictionary: + var client := ClientRegistry.get_by_id(id) + if client == null: + return {"status": "error", "message": "Unknown client: %s" % id} + if url.is_empty(): + url = http_url() + var result := _dispatch_remove(client) + return _verify_post_state(client, result, Client.Status.NOT_CONFIGURED, url, "remove") + + +# --- Strategy dispatch + verify (testable seam) -------------------------- + +static func _dispatch_configure(client: Client, url: String) -> Dictionary: + match client.config_type: + "json": + return JsonStrategy.configure(client, SERVER_NAME, url) + "toml": + return TomlStrategy.configure(client, SERVER_NAME, url) + "cli": + # #463: fall back to writing the config file directly when the CLI + # binary isn't on PATH (Claude Code as a VS Code/Cursor extension). + if client.has_json_fallback() and CliStrategy.resolve_cli_path(client).is_empty(): + return JsonStrategy.configure(client, SERVER_NAME, url) + return CliStrategy.configure(client, SERVER_NAME, url) + return {"status": "error", "message": "Unknown config_type for %s: %s" % [client.id, client.config_type]} + + +static func _dispatch_remove(client: Client) -> Dictionary: + match client.config_type: + "json": + return JsonStrategy.remove(client, SERVER_NAME) + "toml": + return TomlStrategy.remove(client, SERVER_NAME) + "cli": + # #463: mirror the configure fallback so Remove also works without + # the CLI binary — otherwise a fallback-written entry is unremovable. + if client.has_json_fallback() and CliStrategy.resolve_cli_path(client).is_empty(): + return JsonStrategy.remove(client, SERVER_NAME) + return CliStrategy.remove(client, SERVER_NAME) + return {"status": "error", "message": "Unknown config_type for %s: %s" % [client.id, client.config_type]} + + +static func _dispatch_check_status(client: Client, url: String) -> Client.Status: + return _dispatch_check_status_with_cli_path(client, url, "") + + +static func _dispatch_check_status_with_cli_path(client: Client, url: String, cli_path: String) -> Client.Status: + return _dispatch_check_status_with_cli_path_details(client, url, cli_path).get("status", Client.Status.NOT_CONFIGURED) + + +static func _dispatch_check_status_with_cli_path_details(client: Client, url: String, cli_path: String) -> Dictionary: + match client.config_type: + "json": + return {"status": JsonStrategy.check_status(client, SERVER_NAME, url), "error_msg": ""} + "toml": + return {"status": TomlStrategy.check_status(client, SERVER_NAME, url), "error_msg": ""} + "cli": + 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(): + return {"status": JsonStrategy.check_status(client, SERVER_NAME, url), "error_msg": ""} + return CliStrategy.check_status_details(client, SERVER_NAME, url, resolved_cli) + return {"status": Client.Status.NOT_CONFIGURED, "error_msg": ""} + + +## 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, +) -> Dictionary: + if result.get("status") != "ok": + return result + var actual := _dispatch_check_status(client, url) + if actual == expected: + return result + var path := client.resolved_config_path() + var path_hint := "" if path.is_empty() else " Inspect %s and remove the godot-ai entry by hand if needed." % path + return { + "status": "error", + "message": "%s reported %s ok but verification still reads %s (expected %s).%s" % [ + client.display_name, action, + Client.status_label(actual), Client.status_label(expected), + path_hint, + ], + } + + +static func manual_command(id: String) -> String: + var client := ClientRegistry.get_by_id(id) + if client == null: + return "" + return ManualCommand.build(client, SERVER_NAME, http_url(), client.resolved_config_path()) + + +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" + + +## 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` — UI dropdown in the dock, +## persists per-editor-install. Wins over the env var so a UI action +## always takes effect without relaunching the editor. +## 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 chose via the dock dropdown. + # Guarded on `Engine.is_editor_hint()` so this is a no-op when the + # plugin code runs inside the game subprocess (where EditorInterface + # isn't available). See CLAUDE.md "Game-side code: gate on + # Engine.is_editor_hint(), not OS.has_feature("editor")". + if Engine.is_editor_hint(): + var es := EditorInterface.get_editor_settings() + if es != null and es.has_setting(MODE_OVERRIDE_SETTING): + var setting_val := str(es.get_setting(MODE_OVERRIDE_SETTING)).strip_edges().to_lower() + if setting_val == "dev" or setting_val == "user": + return setting_val + # 2. Env var fallback. + var raw := OS.get_environment(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()) + return dir != null and dir.is_link(path) + + +## `refresh` forces uvx to re-fetch PyPI index metadata on spawn — used by +## `_start_server`'s one-shot retry when the first attempt exited fast with +## no pid-file on the uvx tier (stale-index-cache failure mode). No-op on +## other tiers: dev_venv and system resolve locally, so the flag has nowhere +## to go. See plugin.gd::_should_retry_with_refresh. +static func get_server_command(refresh: bool = false) -> Array[String]: + ## `mode_override() == "user"` skips the dev_venv tier even when a nearby + ## .venv exists — the UI dropdown 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() + ## 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. + print("MCP | using uvx (godot-ai==%s)%s" % [version, " [refresh]" if refresh else ""]) + var cmd: Array[String] = [uvx] + if refresh: + cmd.append("--refresh") + cmd.append_array(["--from", "godot-ai==%s" % version, "godot-ai"]) + return cmd + + var system_cmd := _find_system_install() + if not system_cmd.is_empty(): + print("MCP | using system install: %s" % system_cmd) + return [system_cmd] + + push_warning("MCP | no server found — install uv or run: pip install godot-ai") + return [] + + +## Which tier `get_server_command` would resolve to, without side-effects. +## Returned as a stable string so handshakes and session_list can expose it +## to MCP callers. Values track the `Literal` on the Python side. +static func get_server_launch_mode() -> String: + if mode_override() != "user" and not _cached_venv_python().is_empty(): + return "dev_venv" + if not find_uvx().is_empty(): + return "uvx" + if not _find_system_install().is_empty(): + return "system" + return "unknown" + + +static func find_uvx() -> String: + return CliFinder.find(_uvx_cli_names()) + + +static func _uvx_cli_names() -> Array[String]: + var names: Array[String] = [] + names.append("uvx.exe" if OS.get_name() == "Windows" else "uvx") + return names + + +## Drop the `CliFinder` cache for the platform-specific uvx binary +## name. Pairs with `invalidate_uv_version_cache()` so the dock's +## `_on_install_uv` can refresh both caches with one call each. The +## OS-specific name matters: Windows caches under `uvx.exe`, every +## other platform under `uvx`; hard-coding `"uvx"` here would leave +## the CLI-path cache stale on Windows after a fresh install and the +## dock would keep showing "uv: not found" for the rest of the session. +static func invalidate_uvx_cli_cache() -> void: + for name in _uvx_cli_names(): + CliFinder.invalidate(name) + + +## Drop the entire `CliFinder` cache. Called from any explicit-user-action +## refresh path (`force=true` in `_request_client_status_refresh` — manual +## Refresh button, popup-open, compat wrapper, future external API) so a +## freshly-installed CLI (claude, codex, gemini, …) gets detected without +## an editor restart. Per-CLI invalidation (`invalidate_uvx_cli_cache`) is +## preferred when the dock knows which binary changed; this catch-all +## handles the "any CLI may have been installed since the last sweep" case. +## +## Thread safety: `CliFinder.invalidate()` guards `_cache` / `_searched` +## with a mutex so it can race safely against worker threads calling +## `find()` from `_run_client_action_worker`. The mutex is held only +## across the dictionary clear, never across `OS.execute`, 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 a single cold `OS.execute(uvx, +## ["--version"])` adds ~80 ms to the dock's first paint on Linux and +## more on Windows. 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 output: Array = [] + if OS.execute(uvx, ["--version"], output, true) == 0 and output.size() > 0: + _uv_version_cache = output[0].strip_edges() + 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 = "" + + +static var _venv_python_cache: String = "" +static var _venv_python_searched: bool = false + + +static func _cached_venv_python() -> String: + if not _venv_python_searched: + _venv_python_cache = _find_venv_python() + _venv_python_searched = true + return _venv_python_cache + + +static func _find_venv_python() -> String: + return _find_venv_python_in(ProjectSettings.globalize_path("res://").rstrip("/")) + + +## Pure path-based lookup so tests can drive it with a scratch dir instead of +## monkey-patching `res://`. Only treats a `.venv/bin/python` as a godot-ai dev +## venv if a sibling `src/godot_ai/` exists in the same parent dir — otherwise +## an unrelated user venv (e.g. `~/.venv` from a data-science side project) +## gets picked up and `python -m godot_ai` fails with ModuleNotFoundError about +## 5s into startup, cascading into an infinite reconnect loop. The retry-with- +## refresh recovery in `plugin.gd::_should_retry_with_refresh` only fires on +## the uvx tier, so the dev_venv misidentification has no escape hatch — the +## detection has to be right the first time. +static func _find_venv_python_in(start_dir: String) -> String: + var dir := start_dir.rstrip("/") + 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/" + for i in 5: + 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: + 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 "" + + +static func _find_system_install() -> String: + var cmd := "which" if OS.get_name() != "Windows" else "where" + var output: Array = [] + if OS.execute(cmd, ["godot-ai"], output, true) == 0 and output.size() > 0: + var found: String = output[0].strip_edges() + if not found.is_empty(): + return found + return "" 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..68ba723 --- /dev/null +++ b/addons/godot_ai/clients/_atomic_write.gd @@ -0,0 +1,161 @@ +@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: + 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) + + var tmp_path := path + ".tmp" + var file := FileAccess.open(tmp_path, FileAccess.WRITE) + if file == null: + return false + # Lock the temp inode down BEFORE writing any bytes. FileAccess.open creates + # it at the umask default (often 0644); chmod'ing the still-empty file first + # means the config contents are never on disk under a world-readable mode in + # the create->chmod gap. rename preserves the inode mode, so the swapped-in + # file lands correct and is never briefly world-readable under the target name. + _apply_mode(tmp_path, target_mode) + file.store_string(content) + # Push Godot's internal buffer out to the OS before the rename. Godot + # exposes no fsync, so the bytes aren't guaranteed durable on the physical + # disk until the OS flushes its own cache — a power loss in that window can + # still lose the data. But flush() ensures the rename can't be ordered ahead + # of the write at the application layer, which is the failure this guards. + file.flush() + file.close() + # Re-assert the mode on the closed inode. The pre-write chmod above closes + # the world-readable window; this second apply is the authoritative one + # (a chmod issued while the FileAccess handle is still open doesn't reliably + # stick inside the editor) and guarantees the final mode before the rename, + # which preserves it. + _apply_mode(tmp_path, target_mode) + + # Best-effort: snapshot the prior file before we touch the target so we + # can restore on a failed swap. The backup is also kept on success as a + # one-shot rollback aid for the user — give it the same (preserved) mode + # so a 0600 config's backup isn't itself a world-readable copy. + # + # copy_absolute creates the backup at the umask default and we can only + # chmod it afterward, so there's a sub-millisecond window where the backup + # carries default perms. Accepted: it duplicates bytes already sitting at + # `path` (which the caller created 0600) inside the user's own config dir, + # and Godot exposes no API to create the copy pre-chmod'd. Not worth + # reimplementing copy by hand to shave that window. + var backup_path := path + ".backup" + var backup_made := false + if had_original: + DirAccess.remove_absolute(backup_path) + if DirAccess.copy_absolute(path, backup_path) == OK: + backup_made = true + _apply_mode(backup_path, target_mode) + + if DirAccess.rename_absolute(tmp_path, path) == OK: + return true + + # Rename-over-existing rejected (Windows + AV / lock timing, some SMB + # shares). Use overwrite-copy as the recovery path: copy_absolute never + # removes the original before writing the new bytes, so a failure here + # leaves the user's prior config in place rather than nuking it. + if DirAccess.copy_absolute(tmp_path, path) == OK and _written_size_matches(path, content): + # copy_absolute creates the destination with the default mode, so + # re-apply the preserved/owner-only mode after the copy lands. + _apply_mode(path, target_mode) + DirAccess.remove_absolute(tmp_path) + return true + + # Copy didn't land cleanly. Restore the destination to its pre-call state. + if backup_made: + # Restore the snapshot we took before the swap. `copy_absolute` + # overwrites the destination, so we don't pre-remove `path` — the + # pre-remove created a window where `path` was gone if the + # subsequent copy itself failed. If the restore copy fails now the + # user's prior bytes are still in `.backup` for manual recovery + # and the false return value tells the caller the swap didn't + # complete. + DirAccess.copy_absolute(backup_path, path) + _apply_mode(path, target_mode) + elif not had_original and FileAccess.file_exists(path): + # No prior file existed but copy_absolute landed partial bytes at + # `path`. Remove them so the failure leaves nothing on disk rather + # than a truncated/invalid new file. The `file_exists` guard keeps + # us off non-file destinations (a path that points at a directory + # yields `had_original=false` too, but we must not try to delete + # the directory). Issue #297 PR review. + DirAccess.remove_absolute(path) + # (If `had_original` is true but the snapshot couldn't be taken, the + # original on disk is whatever copy_absolute managed to write before + # failing. This is a best-effort path — the false return value tells the + # caller the swap didn't complete; recovery beyond that requires a + # backup we couldn't take.) + DirAccess.remove_absolute(tmp_path) + return false + + +static func _resolve_target_mode(path: String, had_original: bool) -> int: + # Preserve the prior file's POSIX mode on a rewrite; default a brand-new + # config (or any case we can't read a mode for) to owner read+write (0600). + # + # get_unix_permissions returns 0 both on Windows (no POSIX perms) and for a + # genuine 0000 file. Treating 0 as "use the 0600 floor" is deliberate, not a + # missed case: these are config files the plugin must read and write, 0000 is + # unusable, and re-applying 0000 would lock the owner out next run. 0600 is + # still owner-only so this never widens access. (A genuinely-0000 file can't + # reach a rewrite through the config strategies anyway — their read-first + # guard fails to open it and refuses the write before we get here.) + if had_original: + var existing := FileAccess.get_unix_permissions(path) + if existing > 0: + return existing + return FileAccess.UNIX_READ_OWNER | FileAccess.UNIX_WRITE_OWNER + + +static func _apply_mode(path: String, mode: int) -> void: + # Best-effort. set_unix_permissions returns ERR_UNAVAILABLE on platforms + # without POSIX permissions (Windows); that's expected and ignored so the + # write still works there. mode <= 0 should never happen (resolve always + # returns >0) but is guarded so a future caller can't chmod a file to nothing. + if mode <= 0: + return + var err := FileAccess.set_unix_permissions(path, mode) + # Surface a real chmod failure (not the Windows no-op) so permission + # hardening on a sensitive config doesn't fail completely silently. + if err != OK and err != ERR_UNAVAILABLE: + push_warning("MCP | could not set permissions on %s (error %d)" % [path, err]) + + +static func _written_size_matches(path: String, content: String) -> bool: + # `store_string` writes UTF-8 bytes with no BOM and no newline translation, + # so the byte length on disk must match `to_utf8_buffer().size()` exactly. + var f := FileAccess.open(path, FileAccess.READ) + if f == null: + return false + var on_disk := f.get_length() + f.close() + return on_disk == content.to_utf8_buffer().size() diff --git a/addons/godot_ai/clients/_atomic_write.gd.uid b/addons/godot_ai/clients/_atomic_write.gd.uid new file mode 100644 index 0000000..add9f7f --- /dev/null +++ b/addons/godot_ai/clients/_atomic_write.gd.uid @@ -0,0 +1 @@ +uid://6fkb5uau0r4h diff --git a/addons/godot_ai/clients/_base.gd b/addons/godot_ai/clients/_base.gd new file mode 100644 index 0000000..e5a9532 --- /dev/null +++ b/addons/godot_ai/clients/_base.gd @@ -0,0 +1,211 @@ +@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 doesn't match `http_url()` — typical after the +## user changes `godot_ai/http_port` and reloads. Distinguishing this from +## `NOT_CONFIGURED` lets the dock surface a "your saved client URLs are 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" + +var id: String = "" ## stable key, e.g. "cursor" +var display_name: String = "" ## "Cursor" +var config_type: String = "" ## "json" | "toml" | "cli" +var doc_url: String = "" + +# 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 = {} + +## Path inside the config object where the per-server map lives. +## Cursor / Claude Desktop / most others: ["mcpServers"] +## VS Code: ["servers"] +## OpenCode: ["mcp"] +var server_key_path: PackedStringArray = PackedStringArray() + +## Field inside the entry dict that holds our server URL. +## "url" by default; some clients use "serverUrl" or "httpUrl". +var entry_url_field: String = "url" + +## Required entry fields — written on every Configure AND verified by the +## default verifier. Use this for transport pins (e.g. `type: +## "streamable-http"`) where a missing/wrong value breaks negotiation: a +## legacy entry without the pin fails verification and surfaces as drift. +## +## DO NOT put user-mutable state here (auto-approval lists, `disabled` +## flags, opt-in toggles). Verifying those treats every user customisation +## as drift, and Configure-All-Mismatched then silently overwrites them +## back to defaults — see the `entry_initial_fields` doc below. +var entry_extra_fields: Dictionary = {} + +## Default fields written ONLY when the entry doesn't yet exist. Reconfigure +## preserves whatever the user (or the client itself) has set; the verifier +## ignores these keys entirely. Use for opt-in flags and user-state arrays — +## e.g. Roo / Cline / Kilo `alwaysAllow` / `autoApprove` lists, `disabled: +## false`, `isActive: true`. The pre-#229 behaviour was equivalent: per- +## client `entry_builder` lambdas seeded these as defaults but the +## per-client `verify_entry` lambdas only checked transport pins, so a +## user-customised array was `CONFIGURED`, not drift. Splitting the field +## restores that contract under the data-only descriptor model. +var entry_initial_fields: Dictionary = {} + +## stdio→HTTP bridge mode for clients that don't speak HTTP natively. +## NONE — entry is `{[entry_url_field]: url, **entry_extra_fields, +## ...entry_initial_fields (only for new entries)}` +## FLAT — Claude Desktop shape: `{"command": , "args": [...bridge...]}` +## Verifier ALSO accepts a future url-style entry. +## +## Enum (vs. String) so a typo in a descriptor fails at parse time instead of +## silently falling through `match` to the non-bridge path. +enum UvxBridge { NONE, FLAT } +var entry_uvx_bridge: UvxBridge = UvxBridge.NONE + +## Paths whose existence implies the user has this client installed. +## Used purely for the dock's "installed" badge. +var detect_paths: PackedStringArray = PackedStringArray() + +# 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. Today only `claude_code` populates these. +var cli_register_template: PackedStringArray = PackedStringArray() +var cli_unregister_template: PackedStringArray = PackedStringArray() +## Args run to read current state; stdout is scanned for the server name and +## URL. Presence of `name` AND `url` → CONFIGURED, name only → MISMATCH, +## neither → NOT_CONFIGURED. +var cli_status_args: PackedStringArray = PackedStringArray() + +# Codex / TOML clients ----------------------------------------------------- +## Dotted TOML path under which our entry lives, e.g. ["mcp_servers", "godot-ai"]. +## Strategies build the [section."name"] header from this. +var toml_section_path: PackedStringArray = PackedStringArray() +var toml_legacy_section_aliases: PackedStringArray = PackedStringArray() +## Lines (without the [header]) emitted under the section, with `{url}` +## tokens. Substituted at call time. +var toml_body_template: PackedStringArray = PackedStringArray() + + +## Resolved absolute config path for this client on the current OS. +func resolved_config_path() -> String: + return McpPathTemplate.resolve(path_template) + + +## 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: + var resolved := McpPathTemplate.expand(p) + if not resolved.is_empty() and (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 + + +# ---------- stdio→http bridge helpers (Claude Desktop) -------------------- + +## Pinned mcp-proxy release used by every stdio-only client's bridge. uvx's +## cache key is version-specific, so pinning guarantees all users run the +## same vetted bridge — a malicious or broken future release on PyPI can't +## silently break everyone's Configure flow. Bump deliberately when the +## upstream publishes something we want. +const MCP_PROXY_VERSION := "0.11.0" + + +## Resolve `uvx` to an absolute path. GUI-launched apps (Claude Desktop) +## often run with a minimal PATH that excludes ~/.local/bin on macOS / +## Linux, so a bare "uvx" string in the config would fail at spawn time +## with the same "Server disconnected" symptom we're trying to cure. The +## shared three-tier McpCliFinder covers the well-known install dirs; +## returns bare "uvx" as a last-resort fallback so the entry is still +## well-formed even if the lookup failed. +static func resolve_uvx_path() -> String: + var names: Array[String] = [] + names.append("uvx.exe" if OS.get_name() == "Windows" else "uvx") + var resolved := McpCliFinder.find(names) + return resolved if not resolved.is_empty() else "uvx" + + +## Build the `mcp-proxy` bridge argv (without the leading uvx command). +## Callers splice this into the client-specific command shape. +static func mcp_proxy_bridge_args(url: String) -> Array: + return ["mcp-proxy==" + MCP_PROXY_VERSION, "--transport", "streamablehttp", url] + + +## Environment overrides written alongside every auto-configured uvx-bridge +## entry. `UV_LINK_MODE=copy` tells uv to copy shared C extensions into each +## `builds-v0\.tmpXXXXXX\` build venv instead of hard-linking them from +## `archive-v0\`. On Windows that breaks the lock race documented in +## `utils/uv_cache_cleanup.gd` and the README — the running godot-ai server +## holds `_pydantic_core.pyd` mapped, the build venv's hard-linked copy +## inherits the lock, uv's post-install cleanup fails, and the MCP launcher +## reports "pywin32 wheel invalid / file in use" with no working transport. +## Cost on macOS/Linux is a few extra MB in the uvx cache — well worth it +## to keep one config shape across platforms. +static func bridge_env_for_uvx() -> Dictionary: + return {"UV_LINK_MODE": "copy"} 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..3ea83ac --- /dev/null +++ b/addons/godot_ai/clients/_cli_exec.gd @@ -0,0 +1,143 @@ +@tool +class_name McpCliExec +extends RefCounted + +## Wall-clock-bounded CLI invocation. Every dock shell-out to a per-client +## CLI (`claude mcp list`, `claude mcp add ...`, etc.) goes through here so +## a hung subprocess can't trap the calling thread forever. +## +## Without the timeout, a contended `claude mcp list` has been observed to +## hang for 6+ minutes (issues #238, #239) — wedging the dock's status +## refresh worker, and on the Configure / Remove paths the editor main +## thread itself. +## +## Why poll/kill instead of `OS.execute(..., true)`: GDScript can't +## interrupt a blocking `OS.execute`, so a hung CLI takes its caller's +## thread with it. `OS.execute_with_pipe` returns immediately with a PID; +## we drive the wait ourselves and `OS.kill` the orphan if budget +## expires. CLI registry commands have bounded output (a few hundred +## bytes), so we don't bother draining the pipe during the poll loop — +## the kernel buffer absorbs it. +## +## Returns a Dictionary with: +## exit_code: process exit code (0 = success). -1 on timeout / spawn failure. +## stdout: captured stdout text. May be partial on timeout. +## stderr: captured stderr text. May be partial on timeout. Empty when +## `capture_stderr` is false. +## output: stdout + (newline + stderr if non-empty). Convenience for +## the common case of "show whatever the CLI said when it +## failed" — `claude mcp add` writes its real diagnostics to +## stderr, so callers that only read `stdout` would surface +## a generic "exit code 1" instead. +## timed_out: true if we killed the process at the wall-clock budget. +## spawn_failed: true if `OS.execute_with_pipe` didn't return a usable PID. + +const DEFAULT_TIMEOUT_MS := 8000 +const _POLL_INTERVAL_MS := 50 + + +static func run( + exe: String, + args: Array, + timeout_ms: int = DEFAULT_TIMEOUT_MS, + capture_stderr: bool = true +) -> Dictionary: + if exe.is_empty(): + return _spawn_failed_result() + + var spawn_exe := exe + var spawn_args := args + if OS.get_name() == "Windows": + var lower := exe.to_lower() + if lower.ends_with(".cmd") or lower.ends_with(".bat"): + ## CreateProcessW can't launch `.cmd` / `.bat` scripts on its + ## own — they're cmd.exe input, not PE binaries. Without this + ## wrap, the moment `McpCliFinder` resolves a Node-style shim + ## (npm's `claude.cmd`, pnpm's wrappers, …) the next + ## `OS.execute_with_pipe` surfaces "Could not create child + ## process: ..." in Godot's output log (#251). Passing + ## `exe` as a separate argv element keeps spaces in the path + ## quoted by Godot's standard quoter — no manual escaping. + spawn_exe = "cmd.exe" + spawn_args = ["/c", exe] + spawn_args.append_array(args) + + var info := OS.execute_with_pipe(spawn_exe, spawn_args) + if info.is_empty(): + return _spawn_failed_result() + + var pid: int = int(info.get("pid", -1)) + var stdio: Variant = info.get("stdio", null) + var stderr_pipe: Variant = info.get("stderr", null) + if pid <= 0: + _close_pipes(stdio, stderr_pipe) + return _spawn_failed_result() + + var deadline := Time.get_ticks_msec() + maxi(timeout_ms, _POLL_INTERVAL_MS) + while OS.is_process_running(pid): + if Time.get_ticks_msec() >= deadline: + ## Read whatever made it to the pipes before we kill the + ## process — partial output beats blank "timed out" when the + ## CLI was emitting useful diagnostics on its way to hanging. + var partial_stdout := _drain_pipe(stdio) + var partial_stderr := _drain_pipe(stderr_pipe) if capture_stderr else "" + OS.kill(pid) + _close_pipes(stdio, stderr_pipe) + return { + "exit_code": -1, + "stdout": partial_stdout, + "stderr": partial_stderr, + "output": _join_streams(partial_stdout, partial_stderr), + "timed_out": true, + "spawn_failed": false, + } + OS.delay_msec(_POLL_INTERVAL_MS) + + var stdout := _drain_pipe(stdio) + var stderr_text := _drain_pipe(stderr_pipe) if capture_stderr else "" + _close_pipes(stdio, stderr_pipe) + + return { + "exit_code": OS.get_process_exit_code(pid), + "stdout": stdout, + "stderr": stderr_text, + "output": _join_streams(stdout, stderr_text), + "timed_out": false, + "spawn_failed": false, + } + + +static func _spawn_failed_result() -> Dictionary: + return { + "exit_code": -1, + "stdout": "", + "stderr": "", + "output": "", + "timed_out": false, + "spawn_failed": true, + } + + +static func _drain_pipe(pipe: Variant) -> String: + if pipe is FileAccess: + return (pipe as FileAccess).get_as_text() + return "" + + +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..5c6c7af --- /dev/null +++ b/addons/godot_ai/clients/_cli_finder.gd @@ -0,0 +1,175 @@ +@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 + `OS.execute`) 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 = {} + + +## 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 + `OS.execute` (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: + var shell := OS.get_environment("SHELL") + if shell.is_empty(): + shell = "/bin/bash" + var login_output: Array = [] + var stripped := exe_name.trim_suffix(".exe") + var login_exit := OS.execute(shell, ["-lc", "command -v %s" % stripped], login_output, true) + if login_exit == 0 and login_output.size() > 0: + var login_found: String = login_output[0].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 output: Array = [] + var exit_code := OS.execute(lookup, [exe_name], output, true) + if exit_code == 0 and output.size() > 0: + var lines := PackedStringArray(output[0].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]: + var home := OS.get_environment("HOME") + if home.is_empty(): + home = OS.get_environment("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 := OS.get_environment("LOCALAPPDATA") + var prog := OS.get_environment("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..6067a0e --- /dev/null +++ b/addons/godot_ai/clients/_cli_strategy.gd @@ -0,0 +1,152 @@ +@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. No descriptor-supplied Callables — see `_base.gd` for why. +## +## Every shell-out goes through `McpCliExec.run`, which wraps the call in a +## wall-clock timeout. A hung CLI (e.g. `claude mcp list` under +## inter-Claude-Code contention) gets killed at the budget instead of +## locking up the caller forever — see issues #238 / #239. + +const _CONFIGURE_TIMEOUT_MS := 10000 +const _REMOVE_TIMEOUT_MS := 10000 +const _STATUS_TIMEOUT_MS := 6000 + + +static func configure(client: McpClient, server_name: String, server_url: String) -> Dictionary: + var cli := _resolve_cli(client) + if cli.is_empty(): + return {"status": "error", "message": "%s not found" % client.display_name} + + # Best-effort prior cleanup so re-configure is idempotent. Bounded to + # the same budget — a hung unregister shouldn't block the configure + # that follows. + if not client.cli_unregister_template.is_empty(): + var pre_args := _format_args(client.cli_unregister_template, server_name, server_url) + McpCliExec.run(cli, pre_args, _REMOVE_TIMEOUT_MS) + + if client.cli_register_template.is_empty(): + return {"status": "error", "message": "%s descriptor missing cli_register_template" % client.display_name} + var args := _format_args(client.cli_register_template, server_name, server_url) + var result := McpCliExec.run(cli, args, _CONFIGURE_TIMEOUT_MS) + if result.get("timed_out", false): + return { + "status": "error", + "message": "Configure %s timed out after %ds — see 'Run this manually' below to retry by hand" % [ + client.display_name, _CONFIGURE_TIMEOUT_MS / 1000, + ], + } + if result.get("spawn_failed", false): + return {"status": "error", "message": "Failed to spawn %s" % client.display_name} + if int(result.get("exit_code", -1)) == 0: + return {"status": "ok", "message": "%s configured (HTTP: %s)" % [client.display_name, 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 +## `server_url`. The matching rule is the only sensible one for "list MCP +## entries" output across CLI clients we currently support: name AND url +## present → CONFIGURED; name only → MISMATCH; neither → NOT_CONFIGURED. +static func check_status(client: McpClient, server_name: String, server_url: String) -> McpClient.Status: + return check_status_with_cli_path(client, server_name, server_url, _resolve_cli(client)) + + +static func check_status_with_cli_path(client: McpClient, server_name: String, server_url: String, cli: String) -> McpClient.Status: + return check_status_details(client, server_name, server_url, cli).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) -> 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 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(server_url) < 0: + return _status_details(McpClient.Status.CONFIGURED_MISMATCH) + return _status_details(McpClient.Status.CONFIGURED) + + +static func _status_details(status: McpClient.Status, error_msg: String = "") -> Dictionary: + return {"status": status, "error_msg": error_msg} + + +static func remove(client: McpClient, server_name: String) -> Dictionary: + var cli := _resolve_cli(client) + if cli.is_empty(): + return {"status": "error", "message": "%s not found" % client.display_name} + if client.cli_unregister_template.is_empty(): + return {"status": "error", "message": "%s descriptor missing cli_unregister_template" % client.display_name} + var args := _format_args(client.cli_unregister_template, server_name, "") + var result := McpCliExec.run(cli, args, _REMOVE_TIMEOUT_MS) + if result.get("timed_out", false): + return { + "status": "error", + "message": "Remove %s timed out after %ds — see 'Run this manually' below to retry by hand" % [ + client.display_name, _REMOVE_TIMEOUT_MS / 1000, + ], + } + if result.get("spawn_failed", false): + return {"status": "error", "message": "Failed to spawn %s" % client.display_name} + if int(result.get("exit_code", -1)) == 0: + return {"status": "ok", "message": "%s configuration removed" % client.display_name} + ## `claude mcp add` writes its real failure diagnostics to stderr, so + ## prefer `output` (stdout + stderr) over `stdout` alone — otherwise + ## the user sees "exit code 1" instead of the actual error. + var combined := str(result.get("output", "")).strip_edges() + var err := combined if not combined.is_empty() else "exit code %d" % int(result.get("exit_code", -1)) + return {"status": "error", "message": "Failed to remove %s: %s" % [client.display_name, err]} + + +## Substitute `{name}` and `{url}` tokens in every template entry. +## Tokens match verbatim — `{name_suffix}` is NOT touched, so callers don't +## have to worry about partial-token collisions in their argv. +static func format_args(template: PackedStringArray, server_name: String, server_url: String) -> Array[String]: + return _format_args(template, server_name, server_url) + + +static func _format_args(template: PackedStringArray, server_name: String, server_url: String) -> Array[String]: + var out: Array[String] = [] + for arg in template: + var s := String(arg) + s = s.replace("{name}", server_name) + s = s.replace("{url}", server_url) + out.append(s) + return out + + +static func _resolve_cli(client: McpClient) -> String: + return McpCliFinder.find(McpClient._array_from_packed(client.cli_names)) + + +static func resolve_cli_path(client: McpClient) -> String: + return _resolve_cli(client) diff --git a/addons/godot_ai/clients/_cli_strategy.gd.uid b/addons/godot_ai/clients/_cli_strategy.gd.uid new file mode 100644 index 0000000..f84a92b --- /dev/null +++ b/addons/godot_ai/clients/_cli_strategy.gd.uid @@ -0,0 +1 @@ +uid://bvib7d8eabbcm diff --git a/addons/godot_ai/clients/_json_strategy.gd b/addons/godot_ai/clients/_json_strategy.gd new file mode 100644 index 0000000..1e3fdba --- /dev/null +++ b/addons/godot_ai/clients/_json_strategy.gd @@ -0,0 +1,263 @@ +@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) -> Dictionary: + var path := client.resolved_config_path() + if path.is_empty(): + return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name} + + var read := _read_or_init(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"]]} + var config: Dictionary = read["data"] + var holder := _ensure_path(config, client.server_key_path) + ## Pass the existing entry through so `build_entry` can preserve user-mutable + ## state (auto-approval lists, `disabled` toggles) instead of resetting it + ## to descriptor defaults on every Configure click. See `entry_initial_fields` + ## docs in `_base.gd`. + var existing: Variant = holder.get(server_name, null) + holder[server_name] = build_entry(client, server_url, existing) + + if not McpAtomicWrite.write(path, JSON.stringify(_narrow_integral_numbers(config), "\t", false)): + return {"status": "error", "message": "Cannot write to %s" % path} + return {"status": "ok", "message": "%s configured (HTTP: %s)" % [client.display_name, server_url]} + + +static func check_status(client: McpClient, server_name: String, server_url: String) -> McpClient.Status: + var path := client.resolved_config_path() + if path.is_empty() or not FileAccess.file_exists(path): + return McpClient.Status.NOT_CONFIGURED + var read := _read_or_init(path) + if not read["ok"]: + return McpClient.Status.NOT_CONFIGURED + var config: Dictionary = read["data"] + var holder := _walk_path(config, client.server_key_path) + if not (holder is Dictionary) or not holder.has(server_name): + return McpClient.Status.NOT_CONFIGURED + var entry = holder[server_name] + if not (entry is Dictionary): + return McpClient.Status.NOT_CONFIGURED + ## An entry under `server_name` exists — if the URL doesn't match, + ## that's drift (the user changed the port and the client config is stale), + ## not "never configured". The dock surfaces that as an amber banner. + return McpClient.Status.CONFIGURED if verify_entry(client, entry, server_url) else McpClient.Status.CONFIGURED_MISMATCH + + +static func remove(client: McpClient, server_name: String) -> Dictionary: + var path := client.resolved_config_path() + if path.is_empty() or not FileAccess.file_exists(path): + return {"status": "ok", "message": "Not configured"} + var read := _read_or_init(path) + if not read["ok"]: + return {"status": "error", "message": "Refusing to rewrite %s: %s." % [path, read["error"]]} + var config: Dictionary = read["data"] + var holder := _walk_path(config, client.server_key_path) + if holder is Dictionary and holder.has(server_name): + holder.erase(server_name) + if not McpAtomicWrite.write(path, JSON.stringify(_narrow_integral_numbers(config), "\t", false)): + return {"status": "error", "message": "Cannot write to %s" % path} + return {"status": "ok", "message": "%s configuration removed" % client.display_name} + + +## Synthesize the entry dict the strategy will write under +## `server_key_path[server_name]`. For non-bridge clients this is the +## existing entry (if any) with `entry_url_field` + every +## `entry_extra_fields` key force-set (the verified type pins) and every +## `entry_initial_fields` key set ONLY when absent (preserves user state +## like `alwaysAllow`/`autoApprove` arrays). For bridge clients (Claude +## Desktop) it composes the uvx + mcp-proxy command shape unconditionally +## — the bridge form has no user-mutable surface. +static func build_entry(client: McpClient, server_url: String, existing: Variant = null) -> Dictionary: + match client.entry_uvx_bridge: + McpClient.UvxBridge.FLAT: + return { + "command": McpClient.resolve_uvx_path(), + "args": McpClient.mcp_proxy_bridge_args(server_url), + "env": _merge_bridge_env(existing), + } + var entry: Dictionary = (existing as Dictionary).duplicate() 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. For bridge clients, recognise the +## bridge form (and, for `flat`, the future url-style form too — keeps the +## tolerance Claude Desktop has had since the npx-bridge migration). +## +## For non-bridge 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) -> bool: + match client.entry_uvx_bridge: + McpClient.UvxBridge.FLAT: + # Future url-style entry: accept if Claude Desktop ever speaks HTTP natively. + if entry.get(client.entry_url_field, "") == server_url: + return true + var cmd = entry.get("command", "") + if not (cmd is String and _command_is_uvx_like(cmd as String)): + return false + if not _bridge_args_are_valid(entry.get("args", []), server_url): + return false + return _bridge_env_matches(entry) + 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 + + +## Pre-fix entries lack `env.UV_LINK_MODE=copy` and hit the Windows uvx +## hard-link race documented in `utils/uv_cache_cleanup.gd`. Flag them as +## drift so the dock surfaces an amber banner and a Configure-click +## rewrites the entry with the env pin. Every key in `bridge_env_for_uvx()` +## must match verbatim — extra user keys are tolerated so a hand-added +## `PYTHONUNBUFFERED=1` etc. doesn't trigger drift forever. +static func _bridge_env_matches(entry: Dictionary) -> bool: + var env = entry.get("env", null) + if not (env is Dictionary): + return false + var pin := McpClient.bridge_env_for_uvx() + for k in pin: + if env.get(k) != pin[k]: + return false + return true + + +## Configure rewrites the bridge entry wholesale (the bridge form is +## identity-defined by command+args+env), but the verifier tolerates extra +## user-added env keys like `HTTP_PROXY` / `PYTHONUNBUFFERED`. Without +## merging, a Configure click on a CONFIGURED_MISMATCH entry would silently +## drop those keys — so layer the UV_LINK_MODE pin over whatever env block +## already exists on disk. New entries with no prior env get just the pin. +static func _merge_bridge_env(existing: Variant) -> Dictionary: + var pin := McpClient.bridge_env_for_uvx() + if not (existing is Dictionary): + return pin + var existing_env = (existing as Dictionary).get("env", null) + if not (existing_env is Dictionary): + return pin + var merged: Dictionary = (existing_env as Dictionary).duplicate() + for k in pin: + merged[k] = pin[k] + return merged + + +## Basename match for `uvx` / `uvx.exe`, accepting both the bare-name +## fallback and an absolute path resolved by `McpCliFinder`. Used by the +## FLAT bridge verifier — the only place we ever inspect a stored bridge +## command/path. +static func _command_is_uvx_like(cmd: String) -> bool: + var basename := cmd.get_file() + return basename == "uvx" or basename == "uvx.exe" + + +## Strict bridge-argv check: the args array must include the pinned +## `mcp-proxy` package spec, the `--transport streamablehttp` selector, and +## the expected URL. Pre-fix `args.has(url)` was lenient — entries with the +## wrong transport (`--transport sse`) or a different package would still +## verify CONFIGURED, hiding the broken bridge. Match `mcp-proxy` by prefix +## so a future MCP_PROXY_VERSION bump doesn't churn the verifier. +static func _bridge_args_are_valid(args: Variant, server_url: String) -> bool: + if not (args is Array): + return false + var has_mcp_proxy := false + for a in args: + if a is String and (a as String).begins_with("mcp-proxy"): + has_mcp_proxy = true + break + if not has_mcp_proxy: + return false + if not (args.has("--transport") and args.has("streamablehttp") and args.has(server_url)): + return false + return true + + +## Returns {"ok": true, "data": Dictionary} when the file is absent or parses +## cleanly, and {"ok": false, "error": String} when the file exists with +## non-empty content we cannot safely round-trip. Callers must NOT fall back +## to an empty dict on the error path — doing so blows away the user's other +## MCP entries on the next write. +static func _read_or_init(path: String) -> Dictionary: + if not FileAccess.file_exists(path): + return {"ok": true, "data": {}} + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + var err := FileAccess.get_open_error() + return {"ok": false, "error": "could not open for reading (error %d)" % err} + var content := file.get_as_text() + file.close() + # Strip a UTF-8 BOM if present — some editors (notably on Windows) save + # JSON with a leading , which Godot's JSON.parse rejects outright. + # Previously this landed on the "unparseable → wipe" path. + if content.begins_with(""): + content = content.substr(1) + if content.strip_edges().is_empty(): + return {"ok": true, "data": {}} + var json := JSON.new() + if json.parse(content) != OK: + var msg := "JSON parse error on line %d: %s" % [json.get_error_line(), json.get_error_message()] + push_warning("MCP | %s in %s" % [msg, path]) + return {"ok": false, "error": msg} + if not (json.data is Dictionary): + return {"ok": false, "error": "top-level value is %s, expected object" % type_string(typeof(json.data))} + return {"ok": true, "data": json.data} + + +## Walk a key path, creating intermediate Dicts as needed. Returns the leaf Dict. +static func _ensure_path(root: Dictionary, key_path: PackedStringArray) -> Dictionary: + var cur := root + for key in key_path: + var next = cur.get(key) + if not (next is Dictionary): + next = {} + cur[key] = next + cur = next + return cur + + +## Walk a key path, returning the leaf Dict if all hops exist; else null. +static func _walk_path(root: Dictionary, key_path: PackedStringArray) -> Variant: + var cur: Variant = root + for key in key_path: + if not (cur is Dictionary) or not cur.has(key): + return null + cur = cur[key] + return cur + + +## Godot's JSON.parse turns every JSON number into a float, so a later +## JSON.stringify re-emits the user's integer fields as "8080.0" — which strict +## consumers (Go's encoding/json into an int field, etc.) reject, and which +## needlessly rewrites every number across the user's *other* entries. Re-narrow +## exactly-representable integral floats back to int so they serialize without +## the ".0". Walks dicts/arrays in place and returns the (same) value. +## +## Integers above 2^53 already lost precision when Godot parsed them to double, +## so they're left as the float Godot produced rather than faking exactness — +## byte-perfect preservation would require not parsing the file at all, and such +## magnitudes don't occur in MCP client configs. +static func _narrow_integral_numbers(value: Variant) -> Variant: + match typeof(value): + TYPE_FLOAT: + if is_finite(value) and value == floor(value) and absf(value) <= 9007199254740992.0: + return int(value) + TYPE_DICTIONARY: + for k in value: + value[k] = _narrow_integral_numbers(value[k]) + TYPE_ARRAY: + for i in value.size(): + value[i] = _narrow_integral_numbers(value[i]) + return value diff --git a/addons/godot_ai/clients/_json_strategy.gd.uid b/addons/godot_ai/clients/_json_strategy.gd.uid new file mode 100644 index 0000000..5e41fbb --- /dev/null +++ b/addons/godot_ai/clients/_json_strategy.gd.uid @@ -0,0 +1 @@ +uid://g8a4iijpk22w diff --git a/addons/godot_ai/clients/_manual_command.gd b/addons/godot_ai/clients/_manual_command.gd new file mode 100644 index 0000000..f0d7c13 --- /dev/null +++ b/addons/godot_ai/clients/_manual_command.gd @@ -0,0 +1,113 @@ +@tool +class_name McpManualCommand +extends RefCounted + +## 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) -> String: + match client.config_type: + "cli": + return _build_cli(client, server_name, server_url, resolved_path) + "json": + return _build_json(client, server_name, server_url, resolved_path) + "toml": + return _build_toml(client, server_name, server_url, resolved_path) + 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.) +static func _build_cli(client: McpClient, server_name: String, server_url: String, resolved_path: String = "") -> String: + if client.cli_register_template.is_empty() or client.cli_names.is_empty(): + return "" + 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 args := McpCliStrategy.format_args(client.cli_register_template, server_name, server_url) + var parts: Array[String] = [short_name] + parts.append_array(args) + var cmd := " ".join(parts) + # #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), + ] + return cmd + + +static func _build_json(client: McpClient, server_name: String, server_url: String, resolved_path: String) -> String: + var entry := McpJsonStrategy.build_entry(client, server_url) + var entry_text := _format_entry_inline(entry) + var key := client.server_key_path[0] if client.server_key_path.size() > 0 else "mcpServers" + return "Edit %s and add under \"%s\":\n \"%s\": %s" % [resolved_path, key, server_name, entry_text] + + +static func _build_toml(client: McpClient, _server_name: String, server_url: String, resolved_path: String) -> String: + var header := _toml_header(client) + 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) + + +## 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..b89205d --- /dev/null +++ b/addons/godot_ai/clients/_path_template.gd @@ -0,0 +1,62 @@ +@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. + + +## Pick the right entry from a {"darwin": ..., "windows": ..., "linux": ...} map. +static func resolve(template_map: Dictionary) -> String: + var key := _os_key() + if not template_map.has(key): + # Allow "unix" as a shorthand for both macOS and Linux. + if (key == "darwin" or key == "linux") and template_map.has("unix"): + key = "unix" + else: + return "" + var template: String = template_map[key] + return expand(template) + + +## Substitute env vars and ~ in a single template string. +static func expand(template: String) -> String: + if template.is_empty(): + return "" + var out := template + if out.begins_with("~/") or out == "~": + var home := _home() + out = home if out == "~" else home.path_join(out.substr(2)) + # $HOME, $APPDATA, $LOCALAPPDATA, $USERPROFILE, $XDG_CONFIG_HOME + for var_name in ["XDG_CONFIG_HOME", "LOCALAPPDATA", "USERPROFILE", "APPDATA", "HOME"]: + var token := "$%s" % var_name + if out.find(token) >= 0: + var value := OS.get_environment(var_name) + if value.is_empty() and var_name == "XDG_CONFIG_HOME": + value = _home().path_join(".config") + if value.is_empty() and var_name == "APPDATA": + value = _home().path_join("AppData/Roaming") + if value.is_empty() and var_name == "LOCALAPPDATA": + value = _home().path_join("AppData/Local") + if value.is_empty() and var_name == "HOME": + value = _home() + out = out.replace(token, value) + return out + + +static func _os_key() -> String: + match OS.get_name(): + "macOS": + return "darwin" + "Windows": + return "windows" + _: + return "linux" + + +static func _home() -> String: + var h := OS.get_environment("HOME") + if h.is_empty(): + h = OS.get_environment("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..5be8bba --- /dev/null +++ b/addons/godot_ai/clients/_registry.gd @@ -0,0 +1,71 @@ +@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 preload below. + +const _CLIENT_SCRIPTS := [ + preload("res://addons/godot_ai/clients/claude_code.gd"), + preload("res://addons/godot_ai/clients/claude_desktop.gd"), + preload("res://addons/godot_ai/clients/codex.gd"), + preload("res://addons/godot_ai/clients/antigravity.gd"), + preload("res://addons/godot_ai/clients/cursor.gd"), + preload("res://addons/godot_ai/clients/windsurf.gd"), + preload("res://addons/godot_ai/clients/vscode.gd"), + preload("res://addons/godot_ai/clients/vscode_insiders.gd"), + preload("res://addons/godot_ai/clients/zed.gd"), + preload("res://addons/godot_ai/clients/gemini_cli.gd"), + preload("res://addons/godot_ai/clients/cline.gd"), + preload("res://addons/godot_ai/clients/kilo_code.gd"), + preload("res://addons/godot_ai/clients/roo_code.gd"), + preload("res://addons/godot_ai/clients/kiro.gd"), + preload("res://addons/godot_ai/clients/trae.gd"), + preload("res://addons/godot_ai/clients/cherry_studio.gd"), + preload("res://addons/godot_ai/clients/opencode.gd"), + preload("res://addons/godot_ai/clients/qwen_code.gd"), + preload("res://addons/godot_ai/clients/kimi_code.gd"), +] + +static var _instances: Array[McpClient] = [] +static var _by_id: Dictionary = {} + + +static func all() -> Array[McpClient]: + if _instances.is_empty(): + _load() + return _instances + + +static func get_by_id(id: String) -> McpClient: + if _instances.is_empty(): + _load() + 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: + if _instances.is_empty(): + _load() + return _by_id.has(id) + + +static func _load() -> void: + _instances.clear() + _by_id.clear() + for script in _CLIENT_SCRIPTS: + var inst: McpClient = script.new() + if inst.id.is_empty(): + push_warning("MCP | client descriptor %s has empty id" % script.resource_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 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..8458d41 --- /dev/null +++ b/addons/godot_ai/clients/_toml_strategy.gd @@ -0,0 +1,269 @@ +@tool +class_name McpTomlStrategy +extends RefCounted + +## Minimal TOML upsert: replace or insert one [section."name"] block whose body +## comes from substituting `{url}` in `client.toml_body_template`. No +## descriptor-supplied Callables — see `_base.gd`. + + +static func configure(client: McpClient, _server_name: String, server_url: String) -> Dictionary: + var path := client.resolved_config_path() + if path.is_empty(): + return {"status": "error", "message": "Could not resolve config path for %s" % client.display_name} + + var read := _read_or_init(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"]]} + if client.toml_body_template.is_empty(): + return {"status": "error", "message": "%s descriptor missing toml_body_template" % client.display_name} + var lines: Array[String] = _split_lines(String(read["data"])) + var body: PackedStringArray = format_body(client.toml_body_template, server_url) + + var section := _find_section(lines, _all_headers(client)) + var header := _primary_header(client) + var new_lines: Array[String] = [header] + for b in body: + new_lines.append(b) + + var output: Array[String] = [] + if section.is_empty(): + output.append_array(lines) + if not output.is_empty() and not output[-1].strip_edges().is_empty(): + output.append("") + output.append_array(new_lines) + else: + output.append_array(_slice(lines, 0, section["start"])) + output.append_array(new_lines) + output.append_array(_slice(lines, section["end"], lines.size())) + + if not McpAtomicWrite.write(path, "\n".join(output)): + return {"status": "error", "message": "Cannot write to %s" % path} + return {"status": "ok", "message": "%s configured (HTTP: %s)" % [client.display_name, server_url]} + + +static func check_status(client: McpClient, _server_name: String, server_url: String) -> McpClient.Status: + var path := client.resolved_config_path() + if path.is_empty() or not FileAccess.file_exists(path): + return McpClient.Status.NOT_CONFIGURED + var read := _read_or_init(path) + if not read["ok"]: + return McpClient.Status.NOT_CONFIGURED + var lines: Array[String] = _split_lines(String(read["data"])) + var section := _find_section(lines, _all_headers(client)) + if section.is_empty(): + return McpClient.Status.NOT_CONFIGURED + + var configured_url := "" + var enabled := true + for i in range(section["start"] + 1, section["end"]): + var trimmed := lines[i].strip_edges() + if trimmed.begins_with("url ="): + var first := trimmed.find("\"") + var last := trimmed.rfind("\"") + if first >= 0 and last > first: + configured_url = trimmed.substr(first + 1, last - first - 1) + elif trimmed.begins_with("enabled ="): + enabled = trimmed.to_lower().find("false") < 0 + ## Section exists with our `SERVER_NAME` header — a URL mismatch (or a + ## disabled entry) is drift, not "never configured". See `_base.gd`. + if configured_url != server_url or not enabled: + return McpClient.Status.CONFIGURED_MISMATCH + return McpClient.Status.CONFIGURED + + +static func remove(client: McpClient, _server_name: String) -> Dictionary: + var path := client.resolved_config_path() + 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) + ## Subtables in the namespace (e.g. [mcp_servers.godot-ai.tools.session_list] + ## that codex users add to set per-tool approval_mode) must be removed + ## too. Leaving them behind keeps `mcp_servers.godot-ai` implicitly + ## defined, so a later configure that writes [mcp_servers."godot-ai"] + ## produces a duplicate-key TOML error. + 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(): + if _is_any_section_header(lines[i]): + break + i += 1 + continue + output.append(lines[i]) + i += 1 + + if not McpAtomicWrite.write(path, "\n".join(output)): + return {"status": "error", "message": "Cannot write to %s" % path} + return {"status": "ok", "message": "%s configuration removed" % client.display_name} + + +## Substitute `{url}` in every 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 + + +# --- helpers -------------------------------------------------------------- + +## Returns {"ok": true, "data": String} when the file is absent or readable, +## and {"ok": false, "error": String} when the file exists but cannot be +## opened. Callers must NOT fall back to an empty string on the error path — +## doing so blows away the user's other MCP entries on the next write. +static func _read_or_init(path: String) -> Dictionary: + if not FileAccess.file_exists(path): + return {"ok": true, "data": ""} + var 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 t := f.get_as_text() + f.close() + return {"ok": true, "data": t} + + +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: + # Quoted form: [section."name"] for ids that contain hyphens. + 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] + ## TOML accepts bare keys ([A-Za-z0-9_-]+) unquoted in section headers, + ## so [mcp_servers.godot-ai] is a valid hand-written form of the same + ## logical key we emit as [mcp_servers."godot-ai"]. Match both during + ## reconfigure / status / remove or a hand-edited (or older-plugin) + ## bare-key file gets a duplicate quoted section appended that breaks + ## the user's TOML parser. + 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 p in parts: + if not _is_bare_key(String(p)): + return "" + return "[%s]" % ".".join(parts) + + +static func _is_bare_key(s: String) -> bool: + if s.is_empty(): + return false + for i in range(s.length()): + var c := s.unicode_at(i) + var alpha := (c >= 65 and c <= 90) or (c >= 97 and c <= 122) + var digit := c >= 48 and c <= 57 + var dash_or_under := c == 45 or c == 95 # '-' or '_' + if not (alpha or digit or dash_or_under): + return false + return true + + +## Subtable prefixes derived from each header in `headers`. Strips the +## closing `]` and appends `.` so a header `[a.b]` becomes the prefix +## `[a.b.` — matching subtables `[a.b.]` but NOT siblings like +## `[a.b-other]` (next char must be a dot, not anything bare-key-valid). +static func _subtable_prefixes(headers: Array[String]) -> Array[String]: + var out: Array[String] = [] + for h in headers: + if h.length() > 2 and h.ends_with("]"): + out.append(h.substr(0, h.length() - 1) + ".") + return out + + +## Mirror of `_matches_any_header` for subtable prefixes — line must +## start with `[a.b.` and have a closing `]` followed only by whitespace +## or a comment. +static func _matches_subtable_prefix(line: String, prefixes: Array[String]) -> bool: + var trimmed := line.strip_edges() + for p in prefixes: + if not trimmed.begins_with(p): + continue + var rest := trimmed.substr(p.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 + + +## Exact-header match. We cannot use a simple prefix check because +## `[mcp_servers."godot-ai"` is a prefix of `[mcp_servers."godot-ai-dev"]`, +## which would silently delete unrelated sections during remove(). +static func _matches_any_header(line: String, headers: Array[String]) -> bool: + var trimmed := line.strip_edges() + for h in headers: + if not trimmed.begins_with(h): + continue + var remainder := trimmed.substr(h.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 {} + + +## Generic "is this line a TOML section header" check that tolerates an +## inline comment after the closing `]`, e.g. `[next_section] # note`. +## The pre-fix `nt.begins_with("[") and nt.ends_with("]")` rejected those +## lines, so a hand-written comment after a header would let the +## section-deletion / section-end loops walk straight through into the +## following section and clobber unrelated content. +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("#") 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/antigravity.gd b/addons/godot_ai/clients/antigravity.gd new file mode 100644 index 0000000..0d75d31 --- /dev/null +++ b/addons/godot_ai/clients/antigravity.gd @@ -0,0 +1,19 @@ +@tool +extends McpClient + + +func _init() -> void: + id = "antigravity" + display_name = "Antigravity" + config_type = "json" + doc_url = "https://www.antigravity.dev/" + path_template = { + "unix": "~/.gemini/antigravity/mcp_config.json", + "windows": "$USERPROFILE/.gemini/antigravity/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} + detect_paths = PackedStringArray(path_template.values()) diff --git a/addons/godot_ai/clients/antigravity.gd.uid b/addons/godot_ai/clients/antigravity.gd.uid new file mode 100644 index 0000000..0721a2f --- /dev/null +++ b/addons/godot_ai/clients/antigravity.gd.uid @@ -0,0 +1 @@ +uid://b4l1g0apa2hch diff --git a/addons/godot_ai/clients/cherry_studio.gd b/addons/godot_ai/clients/cherry_studio.gd new file mode 100644 index 0000000..ed5c78e --- /dev/null +++ b/addons/godot_ai/clients/cherry_studio.gd @@ -0,0 +1,20 @@ +@tool +extends McpClient + + +func _init() -> void: + id = "cherry_studio" + display_name = "Cherry Studio" + config_type = "json" + doc_url = "https://docs.cherry-ai.com/advanced-basic/mcp" + path_template = { + "darwin": "~/Library/Application Support/CherryStudio/mcp_servers.json", + "windows": "$APPDATA/CherryStudio/mcp_servers.json", + "linux": "$XDG_CONFIG_HOME/CherryStudio/mcp_servers.json", + } + server_key_path = PackedStringArray(["mcpServers"]) + entry_extra_fields = {"type": "streamableHttp"} + ## `isActive` is user-state (they may have toggled the server off in the UI). + ## Seed on first Configure but preserve across reconfigure. + entry_initial_fields = {"isActive": true} + detect_paths = PackedStringArray(path_template.values()) diff --git a/addons/godot_ai/clients/cherry_studio.gd.uid b/addons/godot_ai/clients/cherry_studio.gd.uid new file mode 100644 index 0000000..7ada8cd --- /dev/null +++ b/addons/godot_ai/clients/cherry_studio.gd.uid @@ -0,0 +1 @@ +uid://dwbuykxvbv5f7 diff --git a/addons/godot_ai/clients/claude_code.gd b/addons/godot_ai/clients/claude_code.gd new file mode 100644 index 0000000..54f9755 --- /dev/null +++ b/addons/godot_ai/clients/claude_code.gd @@ -0,0 +1,24 @@ +@tool +extends McpClient + + +func _init() -> void: + id = "claude_code" + display_name = "Claude Code" + config_type = "cli" + doc_url = "https://docs.anthropic.com/en/docs/claude-code" + cli_names = PackedStringArray(["claude", "claude.exe"] if OS.get_name() == "Windows" else ["claude"]) + cli_register_template = PackedStringArray( + ["mcp", "add", "--scope", "user", "--transport", "http", "{name}", "{url}"] + ) + cli_unregister_template = PackedStringArray(["mcp", "remove", "{name}"]) + cli_status_args = PackedStringArray(["mcp", "list"]) + ## #463: JSON fallback for when the `claude` binary isn't on PATH — e.g. + ## Claude Code installed only as a VS Code / Cursor extension. The CLI is + ## still preferred whenever it resolves; this is what gets written + ## otherwise. `claude mcp add --scope user --transport http` produces + ## exactly this shape under `mcpServers` in ~/.claude.json: + ## "godot-ai": { "type": "http", "url": "" } + path_template = {"unix": "~/.claude.json", "windows": "~/.claude.json"} + server_key_path = PackedStringArray(["mcpServers"]) + entry_extra_fields = {"type": "http"} 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..658e006 --- /dev/null +++ b/addons/godot_ai/clients/claude_desktop.gd @@ -0,0 +1,24 @@ +@tool +extends McpClient + +## Claude Desktop's mcpServers entries are stdio-only, so we bridge our HTTP +## server through `uvx mcp-proxy --transport streamablehttp `. `uvx` is +## already a plugin prereq, so this works without requiring Node.js. + + +func _init() -> void: + id = "claude_desktop" + display_name = "Claude Desktop" + config_type = "json" + doc_url = "https://claude.ai/download" + 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", + } + server_key_path = PackedStringArray(["mcpServers"]) + ## FLAT bridge: `{"command": "", "args": [...]}`. The default + ## verifier ALSO accepts a future url-style entry (Claude Desktop has + ## been tolerant of both forms since the npx→uvx bridge migration). + entry_uvx_bridge = McpClient.UvxBridge.FLAT + detect_paths = PackedStringArray(path_template.values()) 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..3ef7fc3 --- /dev/null +++ b/addons/godot_ai/clients/cline.gd @@ -0,0 +1,29 @@ +@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" + doc_url = "https://github.com/cline/cline" + 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": []} + detect_paths = PackedStringArray(path_template.values()) diff --git a/addons/godot_ai/clients/cline.gd.uid b/addons/godot_ai/clients/cline.gd.uid new file mode 100644 index 0000000..95e20f1 --- /dev/null +++ b/addons/godot_ai/clients/cline.gd.uid @@ -0,0 +1 @@ +uid://d36nywn2nkgts diff --git a/addons/godot_ai/clients/codex.gd b/addons/godot_ai/clients/codex.gd new file mode 100644 index 0000000..c64e6bb --- /dev/null +++ b/addons/godot_ai/clients/codex.gd @@ -0,0 +1,18 @@ +@tool +extends McpClient + + +func _init() -> void: + id = "codex" + display_name = "Codex" + config_type = "toml" + doc_url = "https://openai.com/index/codex/" + path_template = {"unix": "~/.codex/config.toml", "windows": "$USERPROFILE/.codex/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"]) + toml_body_template = PackedStringArray([ + "url = \"{url}\"", + "enabled = true", + ]) + 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..dbd5d9f --- /dev/null +++ b/addons/godot_ai/clients/cursor.gd @@ -0,0 +1,12 @@ +@tool +extends McpClient + + +func _init() -> void: + id = "cursor" + display_name = "Cursor" + config_type = "json" + doc_url = "https://docs.cursor.com/context/model-context-protocol" + path_template = {"unix": "~/.cursor/mcp.json", "windows": "$USERPROFILE/.cursor/mcp.json"} + server_key_path = PackedStringArray(["mcpServers"]) + detect_paths = PackedStringArray(path_template.values()) diff --git a/addons/godot_ai/clients/cursor.gd.uid b/addons/godot_ai/clients/cursor.gd.uid new file mode 100644 index 0000000..e0c7ddf --- /dev/null +++ b/addons/godot_ai/clients/cursor.gd.uid @@ -0,0 +1 @@ +uid://bvpbssfanukef diff --git a/addons/godot_ai/clients/gemini_cli.gd b/addons/godot_ai/clients/gemini_cli.gd new file mode 100644 index 0000000..bcaa6f9 --- /dev/null +++ b/addons/godot_ai/clients/gemini_cli.gd @@ -0,0 +1,16 @@ +@tool +extends McpClient + + +func _init() -> void: + id = "gemini_cli" + display_name = "Gemini CLI" + config_type = "json" + doc_url = "https://github.com/google-gemini/gemini-cli" + path_template = { + "unix": "~/.gemini/settings.json", + "windows": "$USERPROFILE/.gemini/settings.json", + } + server_key_path = PackedStringArray(["mcpServers"]) + entry_url_field = "httpUrl" + detect_paths = PackedStringArray(path_template.values()) 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/kilo_code.gd b/addons/godot_ai/clients/kilo_code.gd new file mode 100644 index 0000000..9ff0869 --- /dev/null +++ b/addons/godot_ai/clients/kilo_code.gd @@ -0,0 +1,24 @@ +@tool +extends McpClient + + +func _init() -> void: + id = "kilo_code" + display_name = "Kilo Code" + config_type = "json" + doc_url = "https://kilocode.ai/docs/features/mcp/using-mcp-in-kilo-code" + 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": []} + detect_paths = PackedStringArray(path_template.values()) 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..8f412e7 --- /dev/null +++ b/addons/godot_ai/clients/kimi_code.gd @@ -0,0 +1,15 @@ +@tool +extends McpClient + + +func _init() -> void: + id = "kimi_code" + display_name = "Kimi Code" + config_type = "cli" + doc_url = "https://moonshotai.github.io/kimi-cli/" + cli_names = PackedStringArray(["kimi", "kimi.exe"] if OS.get_name() == "Windows" else ["kimi"]) + cli_register_template = PackedStringArray( + ["mcp", "add", "--transport", "http", "{name}", "{url}"] + ) + cli_unregister_template = PackedStringArray(["mcp", "remove", "{name}"]) + cli_status_args = PackedStringArray(["mcp", "list"]) 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..3cb3159 --- /dev/null +++ b/addons/godot_ai/clients/kiro.gd @@ -0,0 +1,17 @@ +@tool +extends McpClient + + +func _init() -> void: + id = "kiro" + display_name = "Kiro" + config_type = "json" + doc_url = "https://kiro.dev/docs/mcp" + 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} + detect_paths = PackedStringArray(path_template.values()) 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..aa72c8c --- /dev/null +++ b/addons/godot_ai/clients/opencode.gd @@ -0,0 +1,21 @@ +@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" + doc_url = "https://opencode.ai/docs/mcp-servers" + path_template = { + "unix": "~/.config/opencode/opencode.json", + "windows": "$HOME/.config/opencode/opencode.json", + } + 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} + detect_paths = PackedStringArray(path_template.values()) diff --git a/addons/godot_ai/clients/opencode.gd.uid b/addons/godot_ai/clients/opencode.gd.uid new file mode 100644 index 0000000..dc2ad00 --- /dev/null +++ b/addons/godot_ai/clients/opencode.gd.uid @@ -0,0 +1 @@ +uid://s8n0vfirf2pj diff --git a/addons/godot_ai/clients/qwen_code.gd b/addons/godot_ai/clients/qwen_code.gd new file mode 100644 index 0000000..608f237 --- /dev/null +++ b/addons/godot_ai/clients/qwen_code.gd @@ -0,0 +1,16 @@ +@tool +extends McpClient + + +func _init() -> void: + id = "qwen_code" + display_name = "Qwen Code" + config_type = "json" + doc_url = "https://github.com/QwenLM/qwen-code" + path_template = { + "unix": "~/.qwen/settings.json", + "windows": "$USERPROFILE/.qwen/settings.json", + } + server_key_path = PackedStringArray(["mcpServers"]) + entry_url_field = "httpUrl" + detect_paths = PackedStringArray(path_template.values()) 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..789c028 --- /dev/null +++ b/addons/godot_ai/clients/roo_code.gd @@ -0,0 +1,29 @@ +@tool +extends McpClient + + +func _init() -> void: + id = "roo_code" + display_name = "Roo Code" + config_type = "json" + doc_url = "https://docs.roocode.com/features/mcp/using-mcp-in-roo" + 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": []} + detect_paths = PackedStringArray(path_template.values()) 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..5f6ba37 --- /dev/null +++ b/addons/godot_ai/clients/trae.gd @@ -0,0 +1,16 @@ +@tool +extends McpClient + + +func _init() -> void: + id = "trae" + display_name = "Trae" + config_type = "json" + doc_url = "https://docs.trae.ai/ide/model-context-protocol" + 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"]) + detect_paths = PackedStringArray(path_template.values()) 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..169e515 --- /dev/null +++ b/addons/godot_ai/clients/vscode.gd @@ -0,0 +1,20 @@ +@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" + doc_url = "https://code.visualstudio.com/docs/copilot/chat/mcp-servers" + 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"} + detect_paths = PackedStringArray(path_template.values()) 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..7304983 --- /dev/null +++ b/addons/godot_ai/clients/vscode_insiders.gd @@ -0,0 +1,17 @@ +@tool +extends McpClient + + +func _init() -> void: + id = "vscode_insiders" + display_name = "VS Code Insiders" + config_type = "json" + doc_url = "https://code.visualstudio.com/docs/copilot/chat/mcp-servers" + 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"} + detect_paths = PackedStringArray(path_template.values()) 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..8963ded --- /dev/null +++ b/addons/godot_ai/clients/windsurf.gd @@ -0,0 +1,16 @@ +@tool +extends McpClient + + +func _init() -> void: + id = "windsurf" + display_name = "Windsurf" + config_type = "json" + doc_url = "https://docs.codeium.com/windsurf/mcp" + path_template = { + "unix": "~/.codeium/windsurf/mcp_config.json", + "windows": "$USERPROFILE/.codeium/windsurf/mcp_config.json", + } + server_key_path = PackedStringArray(["mcpServers"]) + entry_url_field = "serverUrl" + detect_paths = PackedStringArray(path_template.values()) 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..8c5cf48 --- /dev/null +++ b/addons/godot_ai/clients/zed.gd @@ -0,0 +1,19 @@ +@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" + doc_url = "https://zed.dev/docs/assistant/model-context-protocol" + 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"]) + detect_paths = PackedStringArray(path_template.values()) 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/connection.gd b/addons/godot_ai/connection.gd new file mode 100644 index 0000000..2f09536 --- /dev/null +++ b/addons/godot_ai/connection.gd @@ -0,0 +1,492 @@ +@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_EVERY_N_ATTEMPTS := 10 +## 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 ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd") +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Emitted whenever the underlying WebSocket open/closed state flips. +## Subscribers (e.g. the plugin-side telemetry helper) use this to drain +## events that were enqueued before the socket was ready. Emitted with +## ``true`` on first OPEN per connect, ``false`` on transition to CLOSED +## (including ``disconnect_from_server()``). +signal connection_state_changed(is_open: bool) + +var _peer := WebSocketPeer.new() +## Set by plugin.gd after resolving the configured WebSocket port once for the +## server spawn. Reconnects reuse this cached value so they keep dialing the +## same port the Python server was asked to bind. +var ws_port := ClientConfigurator.DEFAULT_WS_PORT +var _url := "" +var _connected := false +var _reconnect_attempt := 0 +var _reconnect_timer := 0.0 +var _session_id := "" +## Godot-AI Python package version reported by the server in its `handshake_ack` +## reply. Empty until the ack lands. Older servers (pre-handshake_ack) leave +## this empty forever — callers that gate on it (the dock's mismatch banner) +## must treat empty as "unknown, don't raise a false alarm". +var server_version := "" + +var dispatcher +var log_buffer +## 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 + 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() + + match _peer.get_ready_state(): + WebSocketPeer.STATE_OPEN: + if not _connected: + _connected = true + _reconnect_attempt = 0 + log_buffer.log("connected to server") + _send_handshake() + connection_state_changed.emit(true) + + _drain_inbound_packets(_peer) + + _check_state_changes() + + if dispatcher: + for response in dispatcher.tick(): + _send_json(response) + + WebSocketPeer.STATE_CLOSED: + if _connected: + _connected = false + _clear_on_disconnect() + var code := _peer.get_close_code() + log_buffer.log("disconnected (code %d)" % code) + connection_state_changed.emit(false) + _reconnect_timer -= delta + if _reconnect_timer <= 0.0: + _attempt_reconnect() + + WebSocketPeer.STATE_CLOSING: + pass + WebSocketPeer.STATE_CONNECTING: + pass + + +## Drain up to PACKET_DRAIN_CAP_PER_TICK inbound packets and dispatch each +## via `_handle_message`. Anything past the cap stays in the peer's queue +## and gets picked up next tick. The cumulative spillover count is logged +## (via `log_buffer`) only when the cap was actually hit AND packets remain +## — sustained flood thus emits one log line per tick with the running +## total, while a normal-traffic frame stays silent. +## +## `peer` is untyped (Variant) so tests can inject a duck-typed fake with +## `get_available_packet_count()` + `get_packet()`. Production passes the +## real `_peer: WebSocketPeer`. +func _drain_inbound_packets(peer) -> Dictionary: + var drained := 0 + while peer.get_available_packet_count() > 0 and drained < PACKET_DRAIN_CAP_PER_TICK: + var raw: String = peer.get_packet().get_string_from_utf8() + _handle_message(raw) + drained += 1 + + var spilled := 0 + if drained >= PACKET_DRAIN_CAP_PER_TICK and peer.get_available_packet_count() > 0: + spilled = peer.get_available_packet_count() + _packet_spillover_total += spilled + if log_buffer: + log_buffer.log( + ( + "[backpressure] inbound drain capped at %d/tick;" + + " %d packets spilled to next frame (cumulative %d)" + ) + % [PACKET_DRAIN_CAP_PER_TICK, spilled, _packet_spillover_total] + ) + + return {"drained": drained, "spilled": spilled} + + +var is_connected: bool: + get: return _connected + + +func disconnect_from_server() -> void: + if _connected: + _peer.close(1000, "Plugin unloading") + _connected = false + connection_state_changed.emit(false) + + +## Reset per-connection state that was filled in by the previous server +## and must NOT bleed into the next one. `force_restart_server` swaps +## servers without reloading the plugin, so without this reset the dock +## would keep showing the killed server's version until the next ack. +## Also fires on plain reconnect-loop drops — correct either way. +func _clear_on_disconnect() -> void: + server_version = "" + ## Reset the spillover counter so a flood pattern from the previous + ## connection doesn't pollute the next one's `logs_read` baseline. + _packet_spillover_total = 0 + if dispatcher: + dispatcher.clear_deferred_responses() + + +## 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) + + +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 + if _should_log_reconnect_attempt(_reconnect_attempt): + log_buffer.log( + "reconnecting (attempt %d; next retry in %.0fs if needed)" + % [_reconnect_attempt, delay] + ) + ## 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() + _peer.outbound_buffer_size = OUTBOUND_BUFFER_LIMIT_BYTES + _connect_to_server() + + +func pause() -> void: + _pause_depth += 1 + + +func resume() -> void: + _pause_depth = maxi(0, _pause_depth - 1) + + +func pause_depth() -> int: + return _pause_depth + + +static func _reconnect_delay_for_attempt(attempt_index: int) -> float: + var delay_idx := mini(attempt_index, RECONNECT_DELAYS.size() - 1) + return RECONNECT_DELAYS[delay_idx] + + +static func _should_log_reconnect_attempt(attempt_number: int) -> bool: + ## Log the first few failures for immediate diagnostics, then only periodic + ## progress markers. Reconnect continues indefinitely; the log should not. + return ( + attempt_number <= RECONNECT_VERBOSE_ATTEMPTS + or attempt_number % RECONNECT_LOG_EVERY_N_ATTEMPTS == 0 + ) + + +func _log_blocked_notice_once() -> void: + if _blocked_notice_logged: + return + _blocked_notice_logged = true + if log_buffer and not connect_block_reason.is_empty(): + log_buffer.log(connect_block_reason) + + +func _send_handshake() -> void: + _last_readiness = get_readiness() + _send_json({ + "type": "handshake", + "session_id": _session_id, + "godot_version": Engine.get_version_info().get("string", "unknown"), + "project_path": ProjectSettings.globalize_path("res://"), + "plugin_version": ClientConfigurator.get_plugin_version(), + "protocol_version": 1, + "readiness": _last_readiness, + "editor_pid": OS.get_process_id(), + "server_launch_mode": ClientConfigurator.get_server_launch_mode(), + }) + + +func _handle_message(raw: String) -> void: + var parsed = JSON.parse_string(raw) + if parsed == null: + push_warning("MCP: failed to parse message: %s" % raw) + return + if not (parsed is Dictionary): + return + if parsed.get("type", "") == "handshake_ack": + server_version = str(parsed.get("server_version", "")) + return + if parsed.has("request_id") and parsed.has("command"): + if dispatcher: + dispatcher.enqueue(parsed) + + +## 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 _send_json(response) and dispatcher != null: + dispatcher.complete_deferred_response(request_id) + + +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() + + +var _last_scene_path := "" +var _last_play_state := 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: + log_buffer.log("[event] readiness -> %s" % readiness) + + +func _get_current_scene_path() -> String: + var scene_root := EditorInterface.get_edited_scene_root() + return scene_root.scene_file_path if scene_root else "" + + +func _send_json(data: Dictionary) -> bool: + if not _connected: + return false + var text := JSON.stringify(data) + var message_bytes := text.to_utf8_buffer().size() + var buffered_bytes := _peer.get_current_outbound_buffered_amount() + 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 + + +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) + 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, + }, + }, + } + + +## Build a human-readable session ID of form "@<4hex>" from the project path. +## The slug is derived from the project directory name so agents can recognize +## which editor they're targeting; the hex suffix disambiguates same-project twins. +static func _make_session_id(project_path: String) -> String: + var base := project_path.rstrip("/\\").get_file() + if base == "": + base = "project" + var slug := _slugify(base) + if slug == "": + slug = "project" + var suffix := _rand_hex(4) + return "%s@%s" % [slug, suffix] + + +static func _slugify(s: String) -> String: + var out := "" + var prev_dash := false + for c in s.to_lower(): + if (c >= "a" and c <= "z") or (c >= "0" and c <= "9"): + out += c + prev_dash = false + elif not prev_dash and out != "": + out += "-" + prev_dash = true + return out.trim_suffix("-") + + +static func _rand_hex(n: int) -> String: + var bytes := PackedByteArray() + var byte_count := int(ceil(float(n) / 2.0)) + for i in byte_count: + bytes.append(randi() % 256) + return bytes.hex_encode().substr(0, n) diff --git a/addons/godot_ai/connection.gd.uid b/addons/godot_ai/connection.gd.uid new file mode 100644 index 0000000..ff78405 --- /dev/null +++ b/addons/godot_ai/connection.gd.uid @@ -0,0 +1 @@ +uid://bmnk8rsotiks2 diff --git a/addons/godot_ai/debugger/mcp_debugger_plugin.gd b/addons/godot_ai/debugger/mcp_debugger_plugin.gd new file mode 100644 index 0000000..7863f7a --- /dev/null +++ b/addons/godot_ai/debugger/mcp_debugger_plugin.gd @@ -0,0 +1,790 @@ +@tool +class_name McpDebuggerPlugin +extends EditorDebuggerPlugin + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Editor-side half of the game-process capture bridge. +## +## The game-side counterpart (`plugin/addons/godot_ai/runtime/game_helper.gd`, +## registered as autoload `_mcp_game_helper`) listens on EngineDebugger's +## message channel. This plugin sends "mcp:take_screenshot" requests and +## routes the replies back through the WebSocket McpConnection using the +## request_id the MCP dispatcher threaded through params. +## +## Why this exists: the game always runs as a separate OS process. Even +## "Embed Game Mode" on Windows/Linux (and macOS 4.5+) just reparents the +## game's window into the editor — the game's framebuffer is never reachable +## from the editor's Viewport. The debugger channel is the engine's own +## supported IPC and works identically regardless of embed mode. + +const CAPTURE_PREFIX := "mcp" +## CI runners under xvfb can be slow to spin up the game subprocess and +## register the autoload's capture. 8s keeps the message responsive for +## interactive users while still covering slow-CI startup. +const DEFAULT_TIMEOUT_SEC := 8.0 +## How long to wait for the game-side autoload to beacon mcp:hello +## before sending the screenshot request. Godot's debugger drops +## messages whose prefix has no registered capture, so sending +## take_screenshot before the game registers its "mcp" capture is a +## silent black hole. On CI the game subprocess has been observed +## taking ~15s to boot + register. +const GAME_READY_WAIT_SEC := 20.0 +## #500: how long to wait for the game-side autoload to beacon mcp:hello before +## issuing a game_eval. This is deliberately MUCH shorter than the 20s +## screenshot wait above: the eval path's total editor-side budget is this wait +## plus the 10s eval backstop (request_game_eval's timeout_sec), and that total +## MUST stay below the 15s game_eval timeout enforced at two layers: the Python +## server's send_command budget (src/godot_ai/handlers/editor.py::game_eval) and +## this plugin's own deferred budget (dispatcher.gd's 15000ms game_eval entry, +## editor/plugin-side — not server-side). Either firing produces the opaque tail. +## With the 20s screenshot wait, a not-yet-ready game made the editor poll past +## the 15s deadline, so the server gave up first with an opaque +## ~15s TimeoutError instead of the actionable "Is the game actually running?" +## error below ever reaching the client (#500's residual TimeoutError bucket). +## 3s wait + 10s backstop = 13s, comfortably under the 15s server timeout, so +## the actionable error always wins. A game launched moments before the eval +## still has the 3s grace to register; if it needs longer, the user gets a fast, +## clear "is it running?" rather than a 15s hang. +const EVAL_READY_WAIT_SEC := 3.0 +## #490: how long to wait for the game's mcp:eval_compiled beacon before +## concluding the eval source failed to compile. A parse error aborts the +## game-side handler before it can reply, so without this we'd wait the +## full eval timeout for a syntax mistake. reload() of valid source is +## sub-millisecond, so 3s is comfortably clear of false positives. +const EVAL_COMPILE_GRACE_SEC := 3.0 +## #490: once an eval compiles, the editor polls the game every this many +## seconds with mcp:eval_check. A backgrounded play-in-editor game has a +## frozen idle loop (no _process / SceneTreeTimer ticks) so it can't +## self-report a runtime error that aborted the eval — but its debugger +## capture callback still answers a probe. The editor's own loop keeps +## ticking, so it drives the poll. 0.35s keeps detection well under a second +## without flooding the channel; most evals reply before the first probe. +const EVAL_PROBE_INTERVAL_SEC := 0.35 + +var _log_buffer: McpLogBuffer +var _game_log_buffer: McpGameLogBuffer + +## Pending request_id -> {connection, timer, timeout_callable}. +## We retain the bound timeout lambda so `_clear_pending` can disconnect +## it on success/error; otherwise the SceneTreeTimer pins the captured +## request_id until `timeout_sec` elapses (8s default). +var _pending: Dictionary = {} + +## Flipped true when the game-side autoload sends its "mcp:hello" boot +## beacon for the current project_run. Reset as soon as a new run is +## requested, before Godot has attached the fresh debugger session, so +## editor_state cannot leak readiness from the previous game process. +var _game_ready := false +var _game_run_token := 0 +var _ready_run_token := -1 +var _game_session_id := -1 +var _game_run_active := false +signal game_ready + + +func _init(log_buffer: McpLogBuffer = null, game_log_buffer: McpGameLogBuffer = null) -> void: + _log_buffer = log_buffer + _game_log_buffer = game_log_buffer + + +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: + _game_ready = false + _ready_run_token = -1 + _game_session_id = session_id + + +func begin_game_run() -> void: + _game_run_token += 1 + _game_run_active = true + _game_ready = false + _ready_run_token = -1 + _game_session_id = -1 + if _log_buffer: + _log_buffer.log("[debug] game capture pending run token %d" % _game_run_token) + + +func end_game_run() -> void: + _game_run_active = false + _game_ready = false + _ready_run_token = -1 + _game_session_id = -1 + + +func is_game_capture_ready() -> bool: + return _game_run_active and _game_ready and _ready_run_token == _game_run_token + + +func _capture(message: String, data: Array, session_id: int) -> bool: + ## Godot passes the full "prefix:tail" string as `message`. + match message: + "mcp:screenshot_response": + _on_screenshot_response(data) + return true + "mcp:screenshot_error": + _on_screenshot_error(data) + return true + "mcp:log_batch": + _on_log_batch(data) + return true + "mcp:hello": + if not _game_run_active: + if _log_buffer: + _log_buffer.log("[debug] ignored mcp:hello with no active game run") + return true + if _game_session_id != -1 and session_id != _game_session_id: + if _log_buffer: + _log_buffer.log("[debug] ignored stale mcp:hello from debugger session %d (current %d)" % [session_id, _game_session_id]) + return true + ## Boot beacon from the game-side autoload. Tells us the + ## game has registered its "mcp" capture and is safe to send + ## take_screenshot to — before this, Godot's debugger would + ## drop our message silently. Also marks a fresh play + ## cycle: rotate the game-log buffer so each run starts + ## clean and gets a new run_id. + _game_ready = true + _ready_run_token = _game_run_token + game_ready.emit() + if _game_log_buffer: + var run_id := _game_log_buffer.clear_for_new_run() + if _log_buffer: + _log_buffer.log("[debug] <- mcp:hello from game_helper (run %s)" % run_id) + elif _log_buffer: + _log_buffer.log("[debug] <- mcp:hello from game_helper") + return true + "mcp:eval_response": + _on_eval_response(data) + return true + "mcp:eval_error": + _on_eval_error(data) + return true + "mcp:eval_ack": + _on_eval_ack(data) + return true + "mcp:eval_compiled": + _on_eval_compiled(data) + return true + "mcp:eval_runtime_error": + _on_eval_runtime_error(data) + return true + "mcp:game_command_response": + _on_game_command_response(data) + return true + "mcp:game_command_error": + _on_game_command_error(data) + return true + return false + + +func _on_log_batch(data: Array) -> void: + if _game_log_buffer == null: + return + ## data layout: [[[level, text, details?], ...]] + if data.is_empty() or not (data[0] is Array): + return + var entries: Array = data[0] + for entry in entries: + if entry is Dictionary: + var dict_details: Dictionary = {} + var raw_dict_details = entry.get("details", {}) + if raw_dict_details is Dictionary: + dict_details = raw_dict_details + _game_log_buffer.append(str(entry.get("level", "info")), str(entry.get("text", "")), dict_details) + continue + if not (entry is Array) or entry.size() < 2: + continue + var details: Dictionary = {} + if entry.size() > 2 and entry[2] is Dictionary: + details = entry[2] + _game_log_buffer.append(str(entry[0]), str(entry[1]), details) + + +## Request a game-process framebuffer capture over the debugger channel. +## Reply is pushed back through `connection` out-of-band because the MCP +## dispatcher has already returned a deferred-response marker for this +## request_id. Synchronous from the caller's perspective — if the +## game-side autoload hasn't beaconed yet, the wait + send run as a +## fire-and-forget coroutine kicked off from here. Structured this way +## so the call site in EditorHandler stays a plain non-await invocation. +func request_game_screenshot( + request_id: String, + max_resolution: int, + connection: McpConnection, + timeout_sec: float = DEFAULT_TIMEOUT_SEC, +) -> void: + if request_id.is_empty(): + push_warning("MCP debugger: screenshot request missing request_id") + return + + var tree := Engine.get_main_loop() as SceneTree + if tree == null: + _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, + "Editor main loop is not a SceneTree — cannot schedule capture") + return + + if is_game_capture_ready(): + _send_take_screenshot(tree, request_id, max_resolution, connection, timeout_sec) + return + + ## Not ready yet — run the wait-then-send flow as a detached + ## coroutine. It keeps itself alive via the signal subscription on + ## tree.process_frame; the caller doesn't need to (and shouldn't) + ## await this entrypoint. + if _log_buffer: + _log_buffer.log("[debug] waiting for game_helper hello (%s)" % request_id) + _wait_then_send(tree, request_id, max_resolution, connection, timeout_sec) + + +## Coroutine: poll each editor frame until the mcp:hello beacon arrives +## (flipping _game_ready true) or the deadline elapses. Once resolved, +## either dispatch the capture or return an actionable timeout error. +func _wait_then_send( + tree: SceneTree, + request_id: String, + max_resolution: int, + connection: McpConnection, + timeout_sec: float, +) -> void: + var deadline := Time.get_ticks_msec() + int(GAME_READY_WAIT_SEC * 1000.0) + while not is_game_capture_ready() and Time.get_ticks_msec() < deadline: + await tree.process_frame + if not is_game_capture_ready(): + _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, + "Game-side autoload never registered its debugger capture within %ds. Is the game actually running? Check Project Settings → Autoload for _mcp_game_helper." % int(GAME_READY_WAIT_SEC)) + 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 + + connection.send_deferred_response(request_id, { + "data": { + "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], + } + }) + 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) + + +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 + _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, + "Game screenshot timed out. The running game must include the _mcp_game_helper autoload (added automatically when the plugin is enabled — check Project Settings → Autoload). If the autoload is missing, re-enable the plugin and relaunch the game. For headless or custom-main-loop builds, use source='viewport' instead.") + 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: + if connection == null or not is_instance_valid(connection): + return + var err := ErrorCodes.make(code, message) + 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, +## and its message (the timeout_callable below) is intentionally generic. Drop +## timeout_sec at/below 8s and it pre-empts the game's actionable "Eval +## exceeded 8s" message — see the TIMEOUT ORDERING note on EVAL_TIMEOUT_SEC. +## +## #500: the *not-ready* path adds EVAL_READY_WAIT_SEC (3s) on top of this 10s +## backstop. That sum (13s) must also stay below the dispatcher/server 15s +## budget, or a not-yet-ready game makes the server time out opaquely before +## the editor's actionable error returns — which is exactly the residual ~15s +## TimeoutError bucket #500 tracked down. Keep EVAL_READY_WAIT_SEC + timeout_sec +## < 15s if you tune either. +func request_game_eval( + code: String, + request_id: String, + connection: McpConnection, + timeout_sec: float = 10.0, +) -> void: + if request_id.is_empty(): + push_warning("MCP debugger: eval request missing request_id") + return + + var tree := Engine.get_main_loop() as SceneTree + if tree == null: + _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, + "Editor main loop is not a SceneTree — cannot schedule eval") + return + + if is_game_capture_ready(): + _send_eval(tree, code, request_id, connection, timeout_sec) + return + + if _log_buffer: + _log_buffer.log("[debug] waiting for game_helper hello before eval (%s)" % request_id) + _wait_then_eval(tree, code, request_id, connection, timeout_sec) + + +func _wait_then_eval( + tree: SceneTree, + code: String, + request_id: String, + connection: McpConnection, + timeout_sec: float, +) -> void: + ## #500: eval uses EVAL_READY_WAIT_SEC (not the 20s GAME_READY_WAIT_SEC) so + ## the not-ready path returns its actionable error before the 15s server-side + ## command timeout fires an opaque TimeoutError. See EVAL_READY_WAIT_SEC. + var deadline := Time.get_ticks_msec() + int(EVAL_READY_WAIT_SEC * 1000.0) + while not is_game_capture_ready() and Time.get_ticks_msec() < deadline: + await tree.process_frame + if not is_game_capture_ready(): + ## #518: EVAL_GAME_NOT_READY (not INTERNAL_ERROR) — the play session is up + ## but the game-side capture didn't register within the short wait. Fast + ## and caller-actionable; classifying it apart from the opaque 10s hang + ## keeps the INTERNAL_ERROR telemetry bucket meaning "the eval truly hung". + _send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY, + "Game-side capture didn't register within %ds. The play session is already running, so the game is most likely still booting — wait a moment and retry. If it persists, the _mcp_game_helper autoload is missing or disabled (Project Settings → Autoload; added automatically when the plugin is enabled), or the game uses a custom main loop." % int(EVAL_READY_WAIT_SEC)) + return + _send_eval(tree, code, request_id, connection, timeout_sec) + + +func _send_eval( + tree: SceneTree, + code: String, + request_id: String, + connection: McpConnection, + timeout_sec: float, +) -> void: + var session: EditorDebuggerSession = _first_active_session() + if session == null: + ## #518: capture reported ready but the debugger session is no longer live + ## (the game just stopped / is restarting) — a not-ready race, so the same + ## caller-actionable EVAL_GAME_NOT_READY rather than the opaque hang bucket. + _send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY, + "Game-side capture registered but its debugger session is no longer active — the game likely just stopped or is restarting. Confirm it's running and retry.") + return + + var timer: SceneTreeTimer = tree.create_timer(timeout_sec) + var timeout_callable := func() -> void: + var pending_entry = _pending.get(request_id) + if pending_entry == null: + return + _clear_pending(request_id) + var conn: McpConnection = pending_entry.connection + if conn == null or not is_instance_valid(conn): + return + _send_error(conn, request_id, ErrorCodes.INTERNAL_ERROR, + "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. Check logs_read(source='game')." % timeout_sec) + if _log_buffer: + _log_buffer.log("[debug] !! eval timeout (%s)" % request_id) + timer.timeout.connect(timeout_callable) + + ## #490: arm the compile-grace timer. _on_eval_grace concludes a parse error + ## only when the game acked the eval (it received the message and started + ## reload()) but never sent mcp:eval_compiled — see there for why a missing + ## ack must NOT be read as a compile error. + var grace: SceneTreeTimer = tree.create_timer(EVAL_COMPILE_GRACE_SEC) + var grace_callable := func() -> void: _on_eval_grace(request_id) + grace.timeout.connect(grace_callable) + + _pending[request_id] = { + "connection": connection, + "timer": timer, + "timeout_callable": timeout_callable, + "grace_timer": grace, + "grace_callable": grace_callable, + "acked": false, + "compiled": false, + } + + session.send_message("mcp:eval", [request_id, code]) + if _log_buffer: + _log_buffer.log("[debug] -> mcp:eval (%s)" % request_id) + + +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) + + +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 + _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, message) + if _log_buffer: + _log_buffer.log("[debug] <- mcp:eval_error (%s): %s" % [request_id, message]) + + +## #490: the game sends this at the top of _handle_eval, BEFORE reload() (so it +## survives a parse-error abort). It positively signals "the game received this +## eval and started compiling it" — letting _on_eval_grace tell a real parse +## error (acked, never compiled) apart from a message the game hasn't serviced +## yet (never acked — main thread blocked by a long frame/load or a CPU-bound +## prior eval). +func _on_eval_ack(data: Array) -> void: + if data.is_empty(): + return + var request_id: String = data[0] + var pending_entry = _pending.get(request_id) + if pending_entry == null: + return + pending_entry["acked"] = true + if _log_buffer: + _log_buffer.log("[debug] <- mcp:eval_ack (%s)" % request_id) + + +## #490: compile-grace timer fired. Conclude a parse error ONLY when the game +## acked the eval (started reload()) but never sent mcp:eval_compiled. If it +## never acked, the game simply hasn't serviced the message yet — NOT a parse +## error — so leave _pending intact and let the normal eval timeout handle it +## rather than false-failing a valid eval and dropping its eventual real reply. +func _on_eval_grace(request_id: String) -> void: + var pending_entry = _pending.get(request_id) + if pending_entry == null or pending_entry.get("compiled", false): + return + if not pending_entry.get("acked", false): + if _log_buffer: + _log_buffer.log("[debug] eval grace: no ack yet, deferring to timeout (%s)" % request_id) + return + _clear_pending(request_id) + var conn: McpConnection = pending_entry.connection + if conn == null or not is_instance_valid(conn): + return + _send_error(conn, request_id, ErrorCodes.EVAL_COMPILE_ERROR, + "Game eval failed to compile — likely a GDScript syntax/parse error. The parse error text is in the editor's Output/Debugger panel; it is not capturable from the running game. Check your eval code's syntax.") + if _log_buffer: + _log_buffer.log("[debug] !! eval compile error (%s)" % request_id) + + +## #490: the game sends this the instant reload() of the eval source +## succeeds. Flips the pending entry's `compiled` flag so the compile-grace +## timer won't fire a false EVAL_COMPILE_ERROR. +func _on_eval_compiled(data: Array) -> void: + if data.is_empty(): + return + var request_id: String = data[0] + var pending_entry = _pending.get(request_id) + if pending_entry == null: + return + pending_entry["compiled"] = true + if _log_buffer: + _log_buffer.log("[debug] <- mcp:eval_compiled (%s)" % request_id) + ## #490: compiled OK — start polling for a runtime error that may have + ## aborted execute(). A backgrounded game can't self-report it, so the + ## editor probes via mcp:eval_check until the eval resolves. + _arm_eval_probe(request_id) + + +## #490: the game reported a runtime error that aborted the eval — either +## from its _process fast path (focused game) or in answer to an editor +## eval_check probe (backgrounded game). Reply fast with the real error text +## instead of waiting for the hang timeout. +func _on_eval_runtime_error(data: Array) -> void: + if data.size() < 2: + return + var request_id: String = data[0] + var message: String = data[1] + var pending_entry = _pending.get(request_id) + if pending_entry == null: + return + _clear_pending(request_id) + var connection: McpConnection = pending_entry.connection + if connection == null or not is_instance_valid(connection): + return + var msg := "Game eval raised a runtime error: %s" % message if not message.is_empty() else "Game eval raised a runtime error (no message captured). Check logs_read(source='game')." + _send_error(connection, request_id, ErrorCodes.EVAL_RUNTIME_ERROR, msg) + if _log_buffer: + _log_buffer.log("[debug] <- mcp:eval_runtime_error (%s): %s" % [request_id, message]) + + +## #490: arm one probe tick for an in-flight eval. Re-arms itself each tick +## until the request resolves — eval_response / eval_runtime_error / +## eval_compile_error / hang-timeout all call _clear_pending, which erases the +## entry and stops the chain. Uses the editor's own SceneTreeTimer because the +## editor loop keeps ticking even while a backgrounded game's loop is frozen. +func _arm_eval_probe(request_id: String) -> void: + var pending_entry = _pending.get(request_id) + if pending_entry == null: + return + var tree := Engine.get_main_loop() as SceneTree + if tree == null: + return + var probe_timer: SceneTreeTimer = tree.create_timer(EVAL_PROBE_INTERVAL_SEC) + var probe_callable := func() -> void: _on_eval_probe_tick(request_id) + pending_entry["probe_timer"] = probe_timer + pending_entry["probe_callable"] = probe_callable + probe_timer.timeout.connect(probe_callable) + + +## #490: poke the game for a runtime-error verdict, then re-arm. The game's +## _handle_eval_check answers with mcp:eval_runtime_error if a script error +## aborted this eval, else stays silent and we poll again next interval. +func _on_eval_probe_tick(request_id: String) -> void: + if not _pending.has(request_id): + return ## resolved — stop probing + var session: EditorDebuggerSession = _first_active_session() + if session != null and session.is_active(): + session.send_message("mcp:eval_check", [request_id]) + _arm_eval_probe(request_id) + + +## --- game_command: curated runtime game operations --- + +func request_game_command( + op: String, + params: Dictionary, + request_id: String, + connection: McpConnection, + timeout_sec: float = 10.0, +) -> void: + if request_id.is_empty(): + push_warning("MCP debugger: game command request missing request_id") + return + + var tree := Engine.get_main_loop() as SceneTree + if tree == null: + _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, + "Editor main loop is not a SceneTree — cannot schedule game command") + return + + if is_game_capture_ready(): + _send_game_command(tree, op, params, request_id, connection, timeout_sec) + return + + if _log_buffer: + _log_buffer.log("[debug] waiting for game_helper hello before game_command (%s)" % request_id) + _wait_then_game_command(tree, op, params, request_id, connection, timeout_sec) + + +func _wait_then_game_command( + tree: SceneTree, + op: String, + params: Dictionary, + request_id: String, + connection: McpConnection, + timeout_sec: float, +) -> void: + var deadline := Time.get_ticks_msec() + int(GAME_READY_WAIT_SEC * 1000.0) + while not is_game_capture_ready() and Time.get_ticks_msec() < deadline: + await tree.process_frame + if not is_game_capture_ready(): + _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, + "Game-side autoload never registered its debugger capture within %ds. Is the game actually running?" % int(GAME_READY_WAIT_SEC)) + 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..baf2f12 --- /dev/null +++ b/addons/godot_ai/dispatcher.gd @@ -0,0 +1,293 @@ +@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 +var _pending_deferred: Dictionary = {} # request_id -> {command, started_ms, timeout_ms} +var _log_buffer +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, + "stop_project": 4500, + "take_screenshot": 30000, + "game_eval": 15000, + "game_command": 15000, +} +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") +const FuzzySuggestions := preload("res://addons/godot_ai/utils/fuzzy_suggestions.gd") + + +func _init(log_buffer: McpLogBuffer) -> void: + _log_buffer = log_buffer + + +## Register a command handler. The callable receives (params: Dictionary) -> Dictionary. +func register(command_name: String, handler: Callable) -> void: + _handlers[command_name] = handler + + +## Drop registered handlers, queued commands, and the log buffer ref so +## plugin.gd can release RefCounted handlers before Godot reloads their +## class_name scripts (issue #46). After clear(), the dispatcher is inert. +func clear() -> void: + _handlers.clear() + _command_queue.clear() + _pending_deferred.clear() + _log_buffer = null + + +## 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 _handlers.has(command): + return ErrorCodes.make(ErrorCodes.UNKNOWN_COMMAND, "Unknown command: %s" % command) + return _call_handler(command, params) + + +## Whether a command is registered. +func has_command(command: String) -> bool: + return _handlers.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, _handlers.keys(), limit, threshold, 0.0, 0.0) + + +## Enqueue a raw command dict received from the WebSocket. +func enqueue(cmd: Dictionary) -> void: + _command_queue.append(cmd) + + +func pending_deferred_count() -> int: + return _pending_deferred.size() + + +func clear_deferred_responses() -> void: + _pending_deferred.clear() + + +func has_pending_deferred_response(request_id: String) -> bool: + return request_id.is_empty() or _pending_deferred.has(request_id) + + +func complete_deferred_response(request_id: String) -> bool: + if request_id.is_empty(): + return true + if not _pending_deferred.has(request_id): + return false + _pending_deferred.erase(request_id) + return true + + +## Handlers whose response flows out-of-band (e.g. debugger-channel capture) +## return this marker so tick() skips auto-sending a response. The handler is +## responsible for pushing the final response via McpConnection._send_json when +## the async operation completes. The dispatcher tracks the request_id and emits +## DEFERRED_TIMEOUT if the out-of-band response never arrives. The request_id is +## threaded through params under the "_request_id" key so the handler can +## correlate the response. +const DEFERRED_RESPONSE := {"_deferred": true} + + +## Process queued commands within a frame budget (milliseconds). +## Returns an array of response dictionaries to send back. +func tick(budget_ms: float = 4.0) -> Array[Dictionary]: + var responses: Array[Dictionary] = _collect_deferred_timeouts() + var start := Time.get_ticks_msec() + var idx := 0 + + while idx < _command_queue.size() and (Time.get_ticks_msec() - start) < budget_ms: + var cmd: Dictionary = _command_queue[idx] + var response := _dispatch(cmd) + if not response.get("_deferred", false): + responses.append(response) + idx += 1 + + if idx > 0: + _command_queue = _command_queue.slice(idx) + + return responses + + +func _dispatch(cmd: Dictionary) -> Dictionary: + var request_id: String = cmd.get("request_id", "") + var command: String = cmd.get("command", "") + var raw_params: Dictionary = cmd.get("params", {}) + ## Duplicate so the internal _request_id key we thread through doesn't + ## mutate the queued command's params (which is the same dict we're + ## about to JSON-log below, and which later readers like batch_execute + ## shouldn't see dispatcher-internal metadata from). + var params: Dictionary = raw_params.duplicate() + params["_request_id"] = request_id + + if mcp_logging: + _log_buffer.log("[recv] %s(%s)" % [command, JSON.stringify(raw_params)]) + + var result: Dictionary + + if _handlers.has(command): + result = _call_handler(command, params) + else: + result = ErrorCodes.make(ErrorCodes.UNKNOWN_COMMAND, "Unknown command: %s" % command) + + if result.get("_deferred", false): + _register_deferred(request_id, command) + 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() + + 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: + var result: Dictionary = _handlers[command].call(params) + ## 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 + + +func _register_deferred(request_id: String, command: String) -> void: + if request_id.is_empty(): + return + _pending_deferred[request_id] = { + "command": command, + "started_ms": Time.get_ticks_msec(), + "timeout_ms": _deferred_timeout_ms_for_command(command), + } + + +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() + responses.append(response) + if mcp_logging and _log_buffer != null: + _log_buffer.log("[defer] %s (request %s) -> timeout" % [command, request_id]) + return responses + + +static func _capture_compact_backtrace(max_frames: int = 8) -> String: + # Use Engine.call() instead of a direct Engine.capture_script_backtraces() + # reference: the method is Godot 4.4+, and 4.3's GDScript parser type-checks + # the static call against GDScriptNativeClass at parse time and rejects the + # whole script even when guarded by has_method() at runtime. + if Engine.has_method("capture_script_backtraces"): + var traces: Array = Engine.call("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..9149394 --- /dev/null +++ b/addons/godot_ai/dock_panels/log_viewer.gd @@ -0,0 +1,95 @@ +@tool +extends VBoxContainer + +## Dock subpanel — renders the MCP request/response log buffer. Owns its own +## UI subtree, the line-count cursor, and the display-visibility toggle. Emits +## `logging_enabled_changed` so the dock can route the flag onto the +## connection dispatcher without the panel knowing the routing exists. +## +## Extracted from mcp_dock.gd as part of audit-v2 #360 — see the comment at +## the top of mcp_dock.gd for the broader extraction story. + +signal logging_enabled_changed(enabled: bool) + +const Dock := preload("res://addons/godot_ai/mcp_dock.gd") + +## Untyped: a `: McpLogBuffer` annotation hits the class_name registry at +## script-load and trips the self-update parse hazard (#398). The type fence +## stays on the `setup(log_buffer: McpLogBuffer)` parameter. +var _log_buffer +var _log_display: RichTextLabel +var _log_toggle: CheckButton +## Last `McpLogBuffer.total_logged()` value painted into the display. Tracking +## the buffer's monotonic sequence (rather than its bounded `total_count()`) +## keeps the viewer painting once the ring fills — a size-based cursor would +## freeze at MAX_LINES on every subsequent append. See PR #392 for the bug. +var _last_log_seq := 0 + + +## Build the UI synchronously here so callers (and detached-tree tests that +## instantiate the dock with `McpDockScript.new()` and never enter the tree) +## can interact with the panel's controls right after `setup()`. Mirrors the +## pre-extraction inline-build behavior that test_dock.gd relies on. +## +## Idempotent: `_log_display == null` covers an unlikely double-`setup()` call +## without rebuilding (which would orphan the prior controls). +func setup(log_buffer: McpLogBuffer) -> void: + _log_buffer = log_buffer + if _log_display == null: + _build_ui() + + +func _build_ui() -> void: + size_flags_vertical = Control.SIZE_EXPAND_FILL + 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" + _log_toggle.button_pressed = true + _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.size_flags_vertical = Control.SIZE_EXPAND_FILL + _log_display.custom_minimum_size = Vector2(0, 120) + _log_display.scroll_following = true + _log_display.bbcode_enabled = false + _log_display.selection_enabled = true + add_child(_log_display) + + +## Called from McpDock._process when the panel is visible. Appends any new +## log lines since the last tick. +func tick() -> void: + if _log_buffer == null or _log_display == null: + return + var seq: int = _log_buffer.total_logged() + if seq == _last_log_seq: + return + if seq < _last_log_seq: + ## Buffer cleared via `McpLogBuffer.clear()` (the `clear_logs` MCP + ## tool / `logs_clear` handler). The buffer resets `_total_logged` + ## to 0, flipping the sequence backward. Without this branch the + ## display would keep showing pre-clear lines forever — the viewer + ## drifts permanently out of sync with the buffer. Reset display + + ## cursor so the next append paints over a clean slate. + _log_display.clear() + _last_log_seq = 0 + if seq == 0: + return + var new_lines: Array[String] = _log_buffer.get_recent(seq - _last_log_seq) + for line in new_lines: + _log_display.add_text(line + "\n") + _last_log_seq = seq + + +func _on_log_toggled(enabled: bool) -> void: + _log_display.visible = enabled + logging_enabled_changed.emit(enabled) diff --git a/addons/godot_ai/dock_panels/log_viewer.gd.uid b/addons/godot_ai/dock_panels/log_viewer.gd.uid new file mode 100644 index 0000000..c261627 --- /dev/null +++ b/addons/godot_ai/dock_panels/log_viewer.gd.uid @@ -0,0 +1 @@ +uid://cr5nbnd6vj3b8 diff --git a/addons/godot_ai/dock_panels/port_picker_panel.gd b/addons/godot_ai/dock_panels/port_picker_panel.gd new file mode 100644 index 0000000..30c3ed3 --- /dev/null +++ b/addons/godot_ai/dock_panels/port_picker_panel.gd @@ -0,0 +1,78 @@ +@tool +extends VBoxContainer + +## Dock subpanel — port-change escape hatch surfaced inside the spawn-failure +## crash panel when the HTTP port is contested (PORT_EXCLUDED, FOREIGN_PORT). +## Emits `port_apply_requested(new_port)` after range-validation; the dock +## handles writing the EditorSetting and reloading the plugin. +## +## Extracted from mcp_dock.gd as part of audit-v2 #360 — see the comment at +## the top of mcp_dock.gd for the broader extraction story. + +const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd") + +signal port_apply_requested(new_port: int) + +var _spinbox: SpinBox + + +## Build the UI synchronously here so callers (and detached-tree tests that +## instantiate the dock with `McpDockScript.new()` and never enter the tree) +## can interact with the panel's controls right after `setup()`. Mirrors the +## pre-extraction inline-build behavior that test_dock.gd relies on. +## +## Idempotent: `_spinbox == null` covers an unlikely double-`setup()` call +## without rebuilding (which would orphan the prior controls). +func setup() -> void: + if _spinbox == null: + _build_ui() + + +func _build_ui() -> void: + add_theme_constant_override("separation", 4) + visible = false + + var picker_row := HBoxContainer.new() + picker_row.add_theme_constant_override("separation", 6) + + _spinbox = SpinBox.new() + _spinbox.min_value = ClientConfigurator.MIN_PORT + _spinbox.max_value = ClientConfigurator.MAX_PORT + _spinbox.step = 1 + _spinbox.value = ClientConfigurator.http_port() + _spinbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL + picker_row.add_child(_spinbox) + + var apply_btn := Button.new() + apply_btn.text = "Apply + Reload" + apply_btn.tooltip_text = ( + "Saves godot_ai/http_port to Editor Settings and reloads the plugin so" + + " the server spawns on the new port." + ) + apply_btn.pressed.connect(_on_apply_pressed) + picker_row.add_child(apply_btn) + + add_child(picker_row) + + +## Re-seed the spinbox with a fresh suggestion every time the panel surfaces, +## so a stale value from a previous spawn-failure round can't carry over. Note +## that this OVERWRITES any unsaved user input — fine in practice because the +## dock's `_update_crash_panel` only calls this on `server_status` transitions +## (`if server_status == _last_server_status: return` short-circuit), so a +## user typing into the spinbox between transitions keeps their value. If the +## state flips while the picker is visible (e.g. `PORT_EXCLUDED` → `FOREIGN_PORT`), +## the in-flight edit is clobbered — accept that, the suggestion is more current. +func seed_suggested_port() -> void: + if _spinbox == null: + return + _spinbox.value = ClientConfigurator.suggest_free_port( + ClientConfigurator.http_port() + 1 + ) + + +func _on_apply_pressed() -> void: + var new_port: int = int(_spinbox.value) + if new_port < ClientConfigurator.MIN_PORT or new_port > ClientConfigurator.MAX_PORT: + return + port_apply_requested.emit(new_port) diff --git a/addons/godot_ai/dock_panels/port_picker_panel.gd.uid b/addons/godot_ai/dock_panels/port_picker_panel.gd.uid new file mode 100644 index 0000000..38b6320 --- /dev/null +++ b/addons/godot_ai/dock_panels/port_picker_panel.gd.uid @@ -0,0 +1 @@ +uid://hlggbo1q65eq diff --git a/addons/godot_ai/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..713a1c4 --- /dev/null +++ b/addons/godot_ai/handlers/_param_validators.gd @@ -0,0 +1,48 @@ +@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. Handlers should guard untrusted values with one of +## the require_*() helpers below and return its error dict on mismatch. + + +## 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)], + ) + + +## Returns null iff `value` is an int. Floats are rejected — JSON decoders +## that emit `1.0` for an integer slot will surface a clear error here +## rather than silently truncating downstream. +static func require_int(name: String, value: Variant) -> Variant: + if typeof(value) == TYPE_INT: + return null + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Param '%s' must be an int, got %s" % [name, type_string(typeof(value))], + ) + + +## Returns null iff `value` is a bool. +static func require_bool(name: String, value: Variant) -> Variant: + if typeof(value) == TYPE_BOOL: + return null + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Param '%s' must be a bool, got %s" % [name, type_string(typeof(value))], + ) 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..700ad75 --- /dev/null +++ b/addons/godot_ai/handlers/animation_handler.gd @@ -0,0 +1,825 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Handles AnimationPlayer authoring: creating players, animations, tracks, +## keyframes, autoplay, and dev-ergonomics playback. +## +## Animations live inside an AnimationLibrary attached to an AnimationPlayer +## node in the scene. They save with the .tscn — no separate resource file +## needed. Undo callables hold direct Animation references (not paths). +## +## Split (issue #342, audit finding #13): +## - animation_presets.gd → preset_fade / slide / shake / pulse + helpers +## - animation_values.gd → animation_list / get / validate + shared +## value coercion / serialization +## Both submodules hold a WeakRef back to this handler. The handler's +## preset_* / list / get / validate methods are thin proxies so existing +## dispatcher registrations and test fixtures don't change. + +const AnimationPresets := preload("res://addons/godot_ai/handlers/animation_presets.gd") +const AnimationValues := preload("res://addons/godot_ai/handlers/animation_values.gd") + +var _undo_redo: EditorUndoRedoManager +var _presets +var _values + +const _LOOP_MODES := { + "none": Animation.LOOP_NONE, + "linear": Animation.LOOP_LINEAR, + "pingpong": Animation.LOOP_PINGPONG, +} + +const _INTERP_MODES := { + "nearest": Animation.INTERPOLATION_NEAREST, + "linear": Animation.INTERPOLATION_LINEAR, + "cubic": Animation.INTERPOLATION_CUBIC, +} + + +func _init(undo_redo: EditorUndoRedoManager) -> void: + _undo_redo = undo_redo + _presets = AnimationPresets.new(self) + _values = AnimationValues.new(self) + + +# ============================================================================ +# animation_player_create +# ============================================================================ + +func create_player(params: Dictionary) -> Dictionary: + var parent_path: String = params.get("parent_path", "") + var node_name: String = params.get("name", "AnimationPlayer") + + var _scene_check := McpNodeValidator.require_scene_or_error() + if _scene_check.has("error"): + return _scene_check + var scene_root: Node = _scene_check.scene_root + + var parent: Node = scene_root + if not parent_path.is_empty(): + parent = McpScenePath.resolve(parent_path, scene_root) + if parent == null: + return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root)) + + var player := AnimationPlayer.new() + if not node_name.is_empty(): + player.name = node_name + + # Attach the default library before adding to tree — it persists on redo. + var library := AnimationLibrary.new() + player.add_animation_library("", library) + + _undo_redo.create_action("MCP: Create AnimationPlayer %s" % player.name) + _undo_redo.add_do_method(parent, "add_child", player, true) + _undo_redo.add_do_method(player, "set_owner", scene_root) + _undo_redo.add_do_reference(player) + _undo_redo.add_do_reference(library) + _undo_redo.add_undo_method(parent, "remove_child", player) + _undo_redo.commit_action() + + return { + "data": { + "path": McpScenePath.from_node(player, scene_root), + "parent_path": McpScenePath.from_node(parent, scene_root), + "name": String(player.name), + "undoable": true, + } + } + + +# ============================================================================ +# animation_create +# ============================================================================ + +func create_animation(params: Dictionary) -> Dictionary: + var player_path: String = params.get("player_path", "") + var anim_name: String = params.get("name", "") + var length: float = float(params.get("length", 1.0)) + var loop_mode_str: String = params.get("loop_mode", "none") + + if player_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") + if anim_name.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name") + if length <= 0.0: + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "length must be > 0 (got %s)" % length) + + if not _LOOP_MODES.has(loop_mode_str): + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, + "Invalid loop_mode '%s'. Valid: %s" % [loop_mode_str, ", ".join(_LOOP_MODES.keys())]) + + var resolved := _resolve_player(player_path, true) + if resolved.has("error"): + return resolved + var player: AnimationPlayer = resolved.player + var library: AnimationLibrary = resolved.library + var created_player: bool = resolved.get("player_created", false) + var player_parent: Node = resolved.get("player_parent", null) + var created_library := false + if library == null: + library = AnimationLibrary.new() + created_library = true + + var overwrite: bool = params.get("overwrite", false) + var old_anim: Animation = null + if library.has_animation(anim_name): + if not overwrite: + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, + "Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name) + old_anim = library.get_animation(anim_name) + + var anim := Animation.new() + anim.length = length + anim.loop_mode = _LOOP_MODES[loop_mode_str] + + _commit_animation_add("MCP: Create animation %s" % anim_name, + player, library, created_library, anim_name, anim, old_anim, + created_player, player_parent) + + return { + "data": { + "player_path": player_path, + "name": anim_name, + "length": length, + "loop_mode": loop_mode_str, + "library_created": created_library or created_player, + "animation_player_created": created_player, + "overwritten": old_anim != null, + "undoable": true, + } + } + + +# ============================================================================ +# animation_delete +# ============================================================================ + +func delete_animation(params: Dictionary) -> Dictionary: + var player_path: String = params.get("player_path", "") + var anim_name: String = params.get("animation_name", "") + + if player_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") + if anim_name.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: animation_name") + + var resolved := _resolve_player(player_path) + if resolved.has("error"): + return resolved + var player: AnimationPlayer = resolved.player + + # Use _resolve_animation so we can delete from ANY library, not just the + # default. Mirrors the read-side symmetry with animation_get / animation_play + # which already search all libraries via _resolve_animation. + var anim_resolved := _resolve_animation(player, anim_name) + if anim_resolved.has("error"): + return anim_resolved + var old_anim: Animation = anim_resolved.animation + var library: AnimationLibrary = anim_resolved.library + # Clip key within the owning library — strips the "libname/" prefix if the + # caller passed a qualified name. + var clip_key: String = anim_name + var slash := anim_name.find("/") + if slash >= 0: + clip_key = anim_name.substr(slash + 1) + + _undo_redo.create_action("MCP: Delete animation %s" % anim_name) + _undo_redo.add_do_method(library, "remove_animation", clip_key) + _undo_redo.add_undo_method(library, "add_animation", clip_key, old_anim) + _undo_redo.add_do_reference(old_anim) # prevent GC so undo→redo works + _undo_redo.commit_action() + + return { + "data": { + "player_path": player_path, + "animation_name": anim_name, + "library_key": anim_resolved.get("library_key", ""), + "undoable": true, + } + } + + +# ============================================================================ +# animation_add_property_track +# ============================================================================ + +func add_property_track(params: Dictionary) -> Dictionary: + var player_path: String = params.get("player_path", "") + var anim_name: String = params.get("animation_name", "") + var track_path: String = params.get("track_path", "") + var keyframes = params.get("keyframes", []) + var interp_str: String = params.get("interpolation", "linear") + + if player_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") + if anim_name.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: animation_name") + if track_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, + "Missing required param: track_path (format: 'NodeName:property', e.g. 'Panel:modulate')") + if not track_path.contains(":"): + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, + "track_path must include ':property' suffix (e.g. 'Panel:modulate', '.:position')") + if not _INTERP_MODES.has(interp_str): + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, + "Invalid interpolation '%s'. Valid: %s" % [interp_str, ", ".join(_INTERP_MODES.keys())]) + if typeof(keyframes) != TYPE_ARRAY or keyframes.is_empty(): + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "keyframes must be a non-empty array") + + var resolved := _resolve_player(player_path) + if resolved.has("error"): + return resolved + var player: AnimationPlayer = resolved.player + + var anim_resolved := _resolve_animation(player, anim_name) + if anim_resolved.has("error"): + return anim_resolved + var anim: Animation = anim_resolved.animation + + # Validate + pre-coerce keyframes before mutating. Coercion errors + # surface as INVALID_PARAMS rather than silently inserting garbage keys. + # Resolve the target property's type ONCE — dense clips used to re-walk + # get_property_list() per keyframe. + var ctx := AnimationValues.resolve_track_prop_context(track_path, player) + if ctx.has("error"): + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, ctx.error) + var coerced_keyframes: Array = [] + for kf in keyframes: + if typeof(kf) != TYPE_DICTIONARY: + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Each keyframe must be a dictionary") + if not "time" in kf: + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Each keyframe must have a 'time' field") + if not "value" in kf: + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Each keyframe must have a 'value' field") + var coerce_result := AnimationValues.coerce_with_context(kf.get("value"), ctx) + if coerce_result.has("error"): + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, coerce_result.error) + coerced_keyframes.append({ + "time": kf.get("time"), + "value": coerce_result.ok, + "transition": kf.get("transition", "linear"), + }) + + _create_scene_pinned_action("MCP: Add property track %s to %s" % [track_path, anim_name]) + _undo_redo.add_do_method(self, "_do_add_property_track", anim, track_path, interp_str, coerced_keyframes) + # Undo locates the track by (path, type) at undo time rather than caching + # an index captured at do time. Cached indices go stale if any other track + # mutation lands between do and undo (Godot editor, another MCP call, etc.) + _undo_redo.add_undo_method(self, "_undo_remove_track_by_path", anim, track_path, Animation.TYPE_VALUE) + _undo_redo.commit_action() + + return { + "data": { + "player_path": player_path, + "animation_name": anim_name, + "track_path": track_path, + "interpolation": interp_str, + "keyframe_count": keyframes.size(), + "undoable": true, + } + } + + +## Insert a pre-coerced track into the animation. Callers must coerce +## values against the target property before calling this (see +## AnimationValues.coerce_value_for_track) — this method runs inside the +## undo do-method path where error propagation isn't possible. +func _do_add_property_track( + anim: Animation, + track_path: String, + interp_str: String, + keyframes: Array, +) -> void: + var idx := anim.add_track(Animation.TYPE_VALUE) + anim.track_set_path(idx, NodePath(track_path)) + anim.track_set_interpolation_type(idx, _INTERP_MODES.get(interp_str, Animation.INTERPOLATION_LINEAR)) + for kf in keyframes: + var t: float = float(kf.get("time", 0.0)) + var trans: float = AnimationValues.parse_transition(kf.get("transition", "linear")) + anim.track_insert_key(idx, t, kf.get("value"), trans) + + +# ============================================================================ +# animation_add_method_track +# ============================================================================ + +func add_method_track(params: Dictionary) -> Dictionary: + var player_path: String = params.get("player_path", "") + var anim_name: String = params.get("animation_name", "") + var target_path: String = params.get("target_node_path", "") + var keyframes = params.get("keyframes", []) + + if player_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") + if anim_name.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: animation_name") + if target_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_node_path") + if target_path.contains(":"): + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, + "target_node_path is a bare NodePath without ':property' (got '%s'). " % target_path + + "Method name goes in each keyframe's 'method' field, not the path.") + if typeof(keyframes) != TYPE_ARRAY or keyframes.is_empty(): + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "keyframes must be a non-empty array") + + for kf in keyframes: + if typeof(kf) != TYPE_DICTIONARY: + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Each keyframe must be a dictionary") + if not "time" in kf: + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Each keyframe must have a 'time' field") + if not "method" in kf: + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Each keyframe must have a 'method' field") + var method_field = kf.get("method") + if typeof(method_field) != TYPE_STRING or (method_field as String).is_empty(): + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "'method' must be a non-empty string") + if kf.has("args") and typeof(kf.get("args")) != TYPE_ARRAY: + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, + "'args' must be an array if provided (got %s)" % type_string(typeof(kf.get("args")))) + + var resolved := _resolve_player(player_path) + if resolved.has("error"): + return resolved + var player: AnimationPlayer = resolved.player + + var anim_resolved := _resolve_animation(player, anim_name) + if anim_resolved.has("error"): + return anim_resolved + var anim: Animation = anim_resolved.animation + + _create_scene_pinned_action("MCP: Add method track %s to %s" % [target_path, anim_name]) + _undo_redo.add_do_method(self, "_do_add_method_track", anim, target_path, keyframes) + # Undo locates the track by (path, type) at undo time — see add_property_track. + _undo_redo.add_undo_method(self, "_undo_remove_track_by_path", anim, target_path, Animation.TYPE_METHOD) + _undo_redo.commit_action() + + return { + "data": { + "player_path": player_path, + "animation_name": anim_name, + "target_node_path": target_path, + "keyframe_count": keyframes.size(), + "undoable": true, + } + } + + +## Remove a track identified by (path, type) at undo time. Robust to +## history interleaving: if another track was added since the do, the +## find_track call still resolves to the correct index. Returns silently +## if the track is no longer present (e.g. a prior undo already removed it). +func _undo_remove_track_by_path(anim: Animation, track_path: String, track_type: int) -> void: + var idx := anim.find_track(NodePath(track_path), track_type) + if idx >= 0: + anim.remove_track(idx) + + +func _do_add_method_track(anim: Animation, target_path: String, keyframes: Array) -> void: + var idx := anim.add_track(Animation.TYPE_METHOD) + anim.track_set_path(idx, NodePath(target_path)) + for kf in keyframes: + var t: float = float(kf.get("time", 0.0)) + var method_name: String = str(kf.get("method", "")) + var args: Array = kf.get("args", []) + anim.track_insert_key(idx, t, {"method": method_name, "args": args}) + + +# ============================================================================ +# animation_set_autoplay +# ============================================================================ + +func set_autoplay(params: Dictionary) -> Dictionary: + var player_path: String = params.get("player_path", "") + var anim_name: String = params.get("animation_name", "") + + if player_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") + + var resolved := _resolve_player(player_path) + if resolved.has("error"): + return resolved + var player: AnimationPlayer = resolved.player + + # Allow empty string to clear autoplay; otherwise validate the name exists. + if not anim_name.is_empty() and not player.has_animation(anim_name): + return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, + "Animation '%s' not found on player at %s" % [anim_name, player_path]) + + var old_autoplay: String = player.autoplay + + _undo_redo.create_action("MCP: Set autoplay %s on %s" % [anim_name, player_path]) + _undo_redo.add_do_property(player, "autoplay", anim_name) + _undo_redo.add_undo_property(player, "autoplay", old_autoplay) + _undo_redo.commit_action() + + return { + "data": { + "player_path": player_path, + "animation_name": anim_name, + "previous_autoplay": old_autoplay, + "cleared": anim_name.is_empty(), + "undoable": true, + } + } + + +# ============================================================================ +# animation_play (dev ergonomics — not saved with scene) +# ============================================================================ + +func play(params: Dictionary) -> Dictionary: + var player_path: String = params.get("player_path", "") + var anim_name: String = params.get("animation_name", "") + + if player_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") + + var resolved := _resolve_player(player_path) + if resolved.has("error"): + return resolved + var player: AnimationPlayer = resolved.player + + if not anim_name.is_empty() and not player.has_animation(anim_name): + return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, + "Animation '%s' not found on player at %s" % [anim_name, player_path]) + + player.play(anim_name) + + return { + "data": { + "player_path": player_path, + "animation_name": anim_name, + "undoable": false, + "reason": "Runtime playback state — not saved with scene", + } + } + + +# ============================================================================ +# animation_stop (dev ergonomics — not saved with scene) +# ============================================================================ + +func stop(params: Dictionary) -> Dictionary: + var player_path: String = params.get("player_path", "") + + if player_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") + + var resolved := _resolve_player(player_path) + if resolved.has("error"): + return resolved + var player: AnimationPlayer = resolved.player + + player.stop() + + return { + "data": { + "player_path": player_path, + "undoable": false, + "reason": "Runtime playback state — not saved with scene", + } + } + + +# ============================================================================ +# animation_create_simple (composer) +# ============================================================================ + +func create_simple(params: Dictionary) -> Dictionary: + var player_path: String = params.get("player_path", "") + var anim_name: String = params.get("name", "") + var tweens = params.get("tweens", []) + var loop_mode_str: String = params.get("loop_mode", "none") + + if player_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") + if anim_name.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name") + if typeof(tweens) != TYPE_ARRAY or tweens.is_empty(): + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "tweens must be a non-empty array") + if not _LOOP_MODES.has(loop_mode_str): + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, + "Invalid loop_mode '%s'. Valid: %s" % [loop_mode_str, ", ".join(_LOOP_MODES.keys())]) + + # Validate all tween specs before touching the scene. + var seen_paths := {} + for spec in tweens: + if typeof(spec) != TYPE_DICTIONARY: + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Each tween spec must be a dictionary") + for field in ["target", "property", "from", "to", "duration"]: + if not field in spec: + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, + "Each tween spec must have '%s'" % field) + if float(spec.get("duration", 0.0)) <= 0.0: + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, + "tween 'duration' must be > 0") + var dup_key: String = str(spec.target) + ":" + str(spec.property) + if seen_paths.has(dup_key): + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, + "Duplicate tween target '%s' — merge keyframes into a single track " % dup_key + + "via animation_add_property_track instead of two separate tweens.") + seen_paths[dup_key] = true + + # Compute/validate length before resolving the player — a fresh auto-created + # AnimationPlayer is a detached Node that leaks if we return after creation. + var has_length: bool = params.has("length") and params.get("length") != null + var computed_length: float = 0.0 + if has_length: + computed_length = float(params.get("length")) + if computed_length <= 0.0: + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, + "'length' must be > 0 when provided (got %s)" % str(params.get("length"))) + else: + for spec in tweens: + var end_time: float = float(spec.get("delay", 0.0)) + float(spec.get("duration", 0.0)) + if end_time > computed_length: + computed_length = end_time + if computed_length <= 0.0: + computed_length = 1.0 + + var resolved := _resolve_player(player_path, true) + if resolved.has("error"): + return resolved + var player: AnimationPlayer = resolved.player + var library: AnimationLibrary = resolved.library + var created_player: bool = resolved.get("player_created", false) + var player_parent: Node = resolved.get("player_parent", null) + var created_library := false + if library == null: + library = AnimationLibrary.new() + created_library = true + + var overwrite: bool = params.get("overwrite", false) + var old_anim: Animation = null + if library.has_animation(anim_name): + if not overwrite: + if created_player: + player.queue_free() + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, + "Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name) + old_anim = library.get_animation(anim_name) + + # Pre-coerce all tween values before touching the anim — coercion errors + # surface as INVALID_PARAMS, not silent garbage keyframes. + # When the player was auto-created, it isn't in the tree yet — pass its + # future parent so the coercer can still resolve target property types. + var coerce_root: Node = player_parent if created_player else null + var per_track_keyframes: Array = [] + for spec in tweens: + var target: String = str(spec.get("target", "")) + var property: String = str(spec.get("property", "")) + var track_path: String = target + ":" + property + var duration: float = float(spec.get("duration", 1.0)) + var delay: float = float(spec.get("delay", 0.0)) + var trans_str = spec.get("transition", "linear") + var from_result := AnimationValues.coerce_value_for_track(spec.get("from"), track_path, player, coerce_root) + if from_result.has("error"): + if created_player: + player.queue_free() + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "tween '%s': %s" % [track_path, from_result.error]) + var to_result := AnimationValues.coerce_value_for_track(spec.get("to"), track_path, player, coerce_root) + if to_result.has("error"): + if created_player: + player.queue_free() + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "tween '%s': %s" % [track_path, to_result.error]) + per_track_keyframes.append({ + "track_path": track_path, + "keyframes": [ + {"time": delay, "value": from_result.ok, "transition": trans_str}, + {"time": delay + duration, "value": to_result.ok, "transition": trans_str}, + ], + }) + + # Build the animation fully in memory before touching the undo stack. + var anim := Animation.new() + anim.length = computed_length + anim.loop_mode = _LOOP_MODES[loop_mode_str] + + for entry in per_track_keyframes: + _do_add_property_track(anim, entry.track_path, "linear", entry.keyframes) + + # One atomic undo action — bundles player creation (if any), library + # creation (if any), and the animation add. A single Ctrl-Z rolls back all. + _commit_animation_add("MCP: Create animation %s (%d tracks)" % [anim_name, anim.get_track_count()], + player, library, created_library, anim_name, anim, old_anim, + created_player, player_parent) + + return { + "data": { + "player_path": player_path, + "name": anim_name, + "length": computed_length, + "loop_mode": loop_mode_str, + "track_count": anim.get_track_count(), + "library_created": created_library or created_player, + "animation_player_created": created_player, + "overwritten": old_anim != null, + "undoable": true, + } + } + + +# ============================================================================ +# Proxies — preset_* and read methods live in the submodules. Kept here so +# the dispatcher registrations and `_handler.method(...)` test fixtures stay +# unchanged across the split. +# ============================================================================ + +func preset_fade(params: Dictionary) -> Dictionary: + return _presets.preset_fade(params) + + +func preset_slide(params: Dictionary) -> Dictionary: + return _presets.preset_slide(params) + + +func preset_shake(params: Dictionary) -> Dictionary: + return _presets.preset_shake(params) + + +func preset_pulse(params: Dictionary) -> Dictionary: + return _presets.preset_pulse(params) + + +func list_animations(params: Dictionary) -> Dictionary: + return _values.list_animations(params) + + +func get_animation(params: Dictionary) -> Dictionary: + return _values.get_animation(params) + + +func validate_animation(params: Dictionary) -> Dictionary: + return _values.validate_animation(params) + + +# ============================================================================ +# Helpers — undo +# ============================================================================ + +## Shared undo setup for create_animation and create_simple. Handles fresh- +## create, overwrite, library auto-create, and player auto-create in a single +## atomic action. When `created_player` is true, the player already has the +## library attached (eagerly, from `_instantiate_player`) and the library +## doesn't need its own undo bookkeeping — it rides along with the add_child. +func _commit_animation_add( + action_label: String, + player: AnimationPlayer, + library: AnimationLibrary, + created_library: bool, + anim_name: String, + anim: Animation, + old_anim: Animation, ## null when not overwriting + created_player: bool = false, + player_parent: Node = null, +) -> void: + _undo_redo.create_action(action_label) + if created_player: + var scene_root := EditorInterface.get_edited_scene_root() + _undo_redo.add_do_method(player_parent, "add_child", player, true) + _undo_redo.add_do_method(player, "set_owner", scene_root) + _undo_redo.add_do_reference(player) + _undo_redo.add_do_reference(library) + _undo_redo.add_undo_method(player_parent, "remove_child", player) + elif created_library: + _undo_redo.add_do_method(player, "add_animation_library", "", library) + _undo_redo.add_undo_method(player, "remove_animation_library", "") + _undo_redo.add_do_reference(library) + if old_anim != null: + _undo_redo.add_do_method(library, "remove_animation", anim_name) + _undo_redo.add_do_method(library, "add_animation", anim_name, anim) + if old_anim != null: + _undo_redo.add_undo_method(library, "remove_animation", anim_name) + _undo_redo.add_undo_method(library, "add_animation", anim_name, old_anim) + _undo_redo.add_do_reference(old_anim) + else: + _undo_redo.add_undo_method(library, "remove_animation", anim_name) + _undo_redo.add_do_reference(anim) + _undo_redo.commit_action() + + +## Open a `create_action` pinned to the edited scene's history. +## +## Without an explicit context, `add_do_method(self, ...)` against a +## RefCounted handler lands in GLOBAL_HISTORY while sibling actions whose +## first do-target is a Resource (e.g. AnimationLibrary) land in the scene's +## history. Mismatched histories make the test-side `editor_undo` helper +## (walks scene first) undo the wrong action, and break batch_handler's +## rollback. Mirrors `camera_handler.gd`'s identical pinning rationale. +func _create_scene_pinned_action(action_label: String) -> void: + _undo_redo.create_action( + action_label, UndoRedo.MERGE_DISABLE, EditorInterface.get_edited_scene_root(), + ) + + +# ============================================================================ +# Helpers — resolution +# ============================================================================ + +## Resolve an AnimationPlayer and its default library for write operations. +## Returns {player, library, player_created, player_parent} on success, or an +## error dict. library is null if the player exists but has no default library +## yet — callers bundle an `add_animation_library` step into their undo action. +## +## When `create_if_missing` is true and `player_path` resolves to nothing, a +## fresh AnimationPlayer is instantiated (with an empty default library attached +## eagerly) but is NOT added to the scene tree — callers must bundle the +## add_child step into their undo action via `_commit_animation_add`. +## If the resolved node exists but isn't an AnimationPlayer, that's still an +## error — we don't clobber an existing node of a different type. +func _resolve_player(player_path: String, create_if_missing: bool = false) -> Dictionary: + var _scene_check := McpNodeValidator.require_scene_or_error() + if _scene_check.has("error"): + return _scene_check + var scene_root: Node = _scene_check.scene_root + var node := McpScenePath.resolve(player_path, scene_root) + if node == null: + if not create_if_missing: + return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_node_error(player_path, scene_root)) + return _instantiate_player(player_path, scene_root) + if not node is AnimationPlayer: + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, + "Node at %s is not an AnimationPlayer (got %s)" % [player_path, node.get_class()]) + var player := node as AnimationPlayer + var lib: AnimationLibrary = null + if player.has_animation_library(""): + lib = player.get_animation_library("") + return {"player": player, "library": lib, "player_created": false, "player_parent": null} + + +## Build a new AnimationPlayer (with empty default library) for insertion under +## the parent implied by `player_path`. Returns an error dict if the parent +## can't be resolved or the path has no usable leaf name. +func _instantiate_player(player_path: String, scene_root: Node) -> Dictionary: + var slash := player_path.rfind("/") + var parent_path: String + var player_name: String + if slash < 0: + parent_path = "" + player_name = player_path + else: + parent_path = player_path.substr(0, slash) + player_name = player_path.substr(slash + 1) + if player_name.is_empty(): + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, + "Cannot auto-create AnimationPlayer: player_path '%s' has no leaf name" % player_path) + var parent: Node + if parent_path.is_empty(): + parent = scene_root + else: + parent = McpScenePath.resolve(parent_path, scene_root) + if parent == null: + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, + "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..3feacdc --- /dev/null +++ b/addons/godot_ai/handlers/animation_presets.gd @@ -0,0 +1,528 @@ +@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(ErrorCodes.EDITOR_NOT_READY, "AnimationHandler not available") + 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(ErrorCodes.EDITOR_NOT_READY, "AnimationHandler not available") + 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(ErrorCodes.EDITOR_NOT_READY, "AnimationHandler not available") + 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(ErrorCodes.EDITOR_NOT_READY, "AnimationHandler not available") + 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..05c4a1f --- /dev/null +++ b/addons/godot_ai/handlers/animation_values.gd @@ -0,0 +1,465 @@ +@tool +extends RefCounted + +## 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(ErrorCodes.EDITOR_NOT_READY, "AnimationHandler not available") + 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(ErrorCodes.EDITOR_NOT_READY, "AnimationHandler not available") + 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(ErrorCodes.EDITOR_NOT_READY, "AnimationHandler not available") + 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: + if value is Color: + return {"ok": value} + if value is String: + var s := value as String + var a := Color.from_string(s, Color(0, 0, 0, 0)) + var b := Color.from_string(s, Color(1, 1, 1, 1)) + if a == b: + return {"ok": a} + return {"error": "Cannot parse '%s' as Color for property '%s'" % [s, prop_name]} + if value is Dictionary and value.has("r") and value.has("g") and value.has("b"): + return {"ok": Color(float(value.r), float(value.g), float(value.b), float(value.get("a", 1.0)))} + return {"error": "Cannot coerce value to Color for property '%s' (expected string, {r,g,b}, or Color)" % prop_name} + TYPE_VECTOR2: + if value is Vector2: + return {"ok": value} + if value is Dictionary and value.has("x") and value.has("y"): + return {"ok": Vector2(float(value.x), float(value.y))} + if value is Array and value.size() >= 2: + return {"ok": Vector2(float(value[0]), float(value[1]))} + return {"error": "Cannot coerce value to Vector2 for property '%s' (expected {x,y}, [x,y], or Vector2)" % prop_name} + TYPE_VECTOR3: + if value is Vector3: + return {"ok": value} + if value is Dictionary and value.has("x") and value.has("y") and value.has("z"): + return {"ok": Vector3(float(value.x), float(value.y), float(value.z))} + return {"error": "Cannot coerce value to Vector3 for property '%s' (expected {x,y,z} or Vector3)" % prop_name} + TYPE_FLOAT: + if value is int or value is float: + return {"ok": float(value)} + TYPE_INT: + if value is float or value is int: + return {"ok": int(value)} + TYPE_BOOL: + if value is int or value is float or value is bool: + return {"ok": bool(value)} + return {"ok": value} + + +# ============================================================================ +# Static helpers — parsing + serializing +# ============================================================================ + +## Parse a transition value: named string or raw float. +## Named values live in `_NAMED_TRANSITIONS` so the mapping has a single source. +static func parse_transition(v: Variant) -> float: + if v is float or v is int: + return float(v) + if v is String: + var key: String = (v as String).to_lower() + if _NAMED_TRANSITIONS.has(key): + return float(_NAMED_TRANSITIONS[key]) + return 1.0 + + +## Map an Animation.TrackType enum to a stable string. Unknown types report +## as "unknown" rather than being silently coerced to "method" — callers that +## only produce value/method tracks can ignore the others; clients that want +## to round-trip bezier/audio/etc. get an honest label to key off. +static func track_type_to_string(track_type: int) -> String: + match track_type: + Animation.TYPE_VALUE: return "value" + Animation.TYPE_METHOD: return "method" + Animation.TYPE_POSITION_3D: return "position_3d" + Animation.TYPE_ROTATION_3D: return "rotation_3d" + Animation.TYPE_SCALE_3D: return "scale_3d" + Animation.TYPE_BLEND_SHAPE: return "blend_shape" + Animation.TYPE_BEZIER: return "bezier" + Animation.TYPE_AUDIO: return "audio" + Animation.TYPE_ANIMATION: return "animation" + _: return "unknown" + + +static func loop_mode_to_string(mode: int) -> String: + match mode: + Animation.LOOP_LINEAR: return "linear" + Animation.LOOP_PINGPONG: return "pingpong" + _: return "none" + + +static func interp_to_string(mode: int) -> String: + match mode: + Animation.INTERPOLATION_NEAREST: return "nearest" + Animation.INTERPOLATION_CUBIC: return "cubic" + _: return "linear" + + +## Convert a Godot Variant to a JSON-safe value. +static func serialize_value(value: Variant) -> Variant: + if value == null: + return null + match typeof(value): + TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING: + return value + TYPE_STRING_NAME: + return str(value) + TYPE_VECTOR2: + return {"x": value.x, "y": value.y} + TYPE_VECTOR3: + return {"x": value.x, "y": value.y, "z": value.z} + TYPE_COLOR: + return {"r": value.r, "g": value.g, "b": value.b, "a": value.a} + TYPE_NODE_PATH: + return str(value) + TYPE_ARRAY: + var arr: Array = [] + for item in value: + arr.append(serialize_value(item)) + return arr + TYPE_DICTIONARY: + var out := {} + for k in value: + out[str(k)] = serialize_value(value[k]) + return out + return str(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..1610347 --- /dev/null +++ b/addons/godot_ai/handlers/api_handler.gd @@ -0,0 +1,89 @@ +@tool +extends RefCounted + +## Read-only access to version-correct Godot class metadata. + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") +const ClassIntrospection := preload("res://addons/godot_ai/utils/class_introspection.gd") +const FuzzySuggestions := preload("res://addons/godot_ai/utils/fuzzy_suggestions.gd") + +func get_class_info(params: Dictionary) -> Dictionary: + var requested_class: String = params.get("class_name", "") + if requested_class.is_empty(): + return ErrorCodes.make( + ErrorCodes.MISSING_REQUIRED_PARAM, + "Missing required param: class_name" + ) + if not ClassDB.class_exists(requested_class): + var script_class := _global_script_class(requested_class) + if not script_class.is_empty(): + return _script_class_error(requested_class, script_class) + return _unknown_class_error(requested_class) + if params.has("limit") and int(params.get("limit")) < 0: + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "limit must be >= 0; use limit=0 only when an unlimited section is needed" + ) + var section_check := ClassIntrospection.validate_sections( + params.get("sections", ClassIntrospection.DEFAULT_SECTIONS) + ) + if not section_check.invalid.is_empty(): + return _invalid_sections_error(section_check.invalid) + return {"data": ClassIntrospection.build(requested_class, params)} + + +static func _unknown_class_error(requested_class: String) -> Dictionary: + var suggestions := _suggest_classes(requested_class) + var message := "Unknown Godot class: %s" % requested_class + if not suggestions.is_empty(): + message += ". Did you mean: %s?" % ", ".join(suggestions) + var result := ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, message) + result["error"]["data"] = {"suggestions": suggestions} + return result + + +static func _suggest_classes(requested_class: String) -> Array[String]: + return FuzzySuggestions.rank(requested_class, ClassDB.get_class_list()) + + +static func _global_script_class(requested_class: String) -> Dictionary: + for raw_info in ProjectSettings.get_global_class_list(): + var info: Dictionary = raw_info + if info.get("class", "") == requested_class: + return info + return {} + + +static func _script_class_error(requested_class: String, script_class: Dictionary) -> Dictionary: + var path := str(script_class.get("path", "")) + var base := str(script_class.get("base", "")) + var message := ( + "%s is a project script class, not a ClassDB class. " + + "Use script_manage(op=\"find_symbols\", params={\"path\": \"%s\"}) for script symbols." + ) % [requested_class, path] + var result := ErrorCodes.make(ErrorCodes.WRONG_TYPE, message) + result["error"]["data"] = { + "script_class": true, + "class_name": requested_class, + "base_class": base, + "path": path, + } + return result + + +static func _invalid_sections_error(invalid_sections: Array[String]) -> Dictionary: + var suggestions := {} + for section in invalid_sections: + suggestions[section] = FuzzySuggestions.rank( + section, + ClassIntrospection.KNOWN_SECTIONS, + 3, + 0.3 + ) + var message := "Unknown class-info section(s): %s. Valid sections: %s" % [ + ", ".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..1e3428f --- /dev/null +++ b/addons/godot_ai/handlers/audio_handler.gd @@ -0,0 +1,359 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Handles AudioStreamPlayer / 2D / 3D authoring — node creation, stream +## assignment, playback-property edits, and real editor preview playback. +## +## Stream assignment loads a Godot-imported AudioStream resource from +## res:// (the editor's import step converts .ogg / .wav / .mp3 into a +## streamable AudioStream subclass before we ever see it). +## +## play() / stop() call the live node method directly — no undo, no +## persistence; they match what the inspector's play button does. + + +const _VALID_TYPES := { + "1d": "AudioStreamPlayer", + "2d": "AudioStreamPlayer2D", + "3d": "AudioStreamPlayer3D", +} + +## Whitelist of playback properties settable via audio_player_set_playback. +## Each value is the expected Variant type of the param dict value. +const _PLAYBACK_KEYS := { + "volume_db": TYPE_FLOAT, + "pitch_scale": TYPE_FLOAT, + "autoplay": TYPE_BOOL, + "bus": TYPE_STRING, +} + + +var _undo_redo: EditorUndoRedoManager + + +func _init(undo_redo: EditorUndoRedoManager) -> void: + _undo_redo = undo_redo + + +# ============================================================================ +# audio_player_create +# ============================================================================ + +func create_player(params: Dictionary) -> Dictionary: + var parent_path: String = params.get("parent_path", "") + var node_name: String = params.get("name", "AudioStreamPlayer") + var type_str: String = params.get("type", "1d") + + if not _VALID_TYPES.has(type_str): + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "Invalid audio player type '%s'. Valid: %s" % [type_str, ", ".join(_VALID_TYPES.keys())] + ) + + var _scene_check := McpNodeValidator.require_scene_or_error() + if _scene_check.has("error"): + return _scene_check + var scene_root: Node = _scene_check.scene_root + + var parent: Node = scene_root + if not parent_path.is_empty(): + parent = McpScenePath.resolve(parent_path, scene_root) + if parent == null: + return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root)) + + var node := _instantiate_player(type_str) + if node == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate audio player") + if not node_name.is_empty(): + node.name = node_name + + _undo_redo.create_action("MCP: Create %s '%s'" % [_VALID_TYPES[type_str], node.name]) + _undo_redo.add_do_method(parent, "add_child", node, true) + _undo_redo.add_do_method(node, "set_owner", scene_root) + _undo_redo.add_do_reference(node) + _undo_redo.add_undo_method(parent, "remove_child", node) + _undo_redo.commit_action() + + return { + "data": { + "path": McpScenePath.from_node(node, scene_root), + "parent_path": McpScenePath.from_node(parent, scene_root), + "name": String(node.name), + "type": type_str, + "class": _VALID_TYPES[type_str], + "undoable": true, + } + } + + +# ============================================================================ +# audio_player_set_stream +# ============================================================================ + +func set_stream(params: Dictionary) -> Dictionary: + var player_path: String = params.get("player_path", "") + var stream_path: String = params.get("stream_path", "") + + if player_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") + if stream_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: stream_path") + + var stream_path_err = McpPathValidator.loadable_error(stream_path, "stream_path") + if stream_path_err != null: + return stream_path_err + + var resolved := _resolve_player(player_path) + if resolved.has("error"): + return resolved + var player: Node = resolved.player + + if not ResourceLoader.exists(stream_path): + return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "AudioStream not found: %s" % stream_path) + var loaded := ResourceLoader.load(stream_path) + if loaded == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load AudioStream: %s" % stream_path) + if not (loaded is AudioStream): + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Resource at %s is not an AudioStream (got %s)" % [stream_path, loaded.get_class()] + ) + + var old_stream: AudioStream = player.stream + + _undo_redo.create_action("MCP: Set audio stream on %s" % player.name) + _undo_redo.add_do_property(player, "stream", loaded) + _undo_redo.add_undo_property(player, "stream", old_stream) + _undo_redo.commit_action() + + return { + "data": { + "player_path": player_path, + "stream_path": stream_path, + "stream_class": loaded.get_class(), + "duration_seconds": float(loaded.get_length()), + "undoable": true, + } + } + + +# ============================================================================ +# audio_player_set_playback +# ============================================================================ + +func set_playback(params: Dictionary) -> Dictionary: + var player_path: String = params.get("player_path", "") + if player_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") + + var resolved := _resolve_player(player_path) + if resolved.has("error"): + return resolved + var player: Node = resolved.player + + var updates: Dictionary = {} + for key in _PLAYBACK_KEYS: + if params.has(key): + var expected_type: int = _PLAYBACK_KEYS[key] + var value = params.get(key) + var coerced = _coerce_playback_value(value, expected_type) + if coerced == null: + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "Invalid value for %s: expected %s, got %s" % [ + key, type_string(expected_type), type_string(typeof(value)) + ] + ) + updates[key] = coerced + + if updates.is_empty(): + return ErrorCodes.make( + ErrorCodes.MISSING_REQUIRED_PARAM, + "At least one of %s is required" % ", ".join(_PLAYBACK_KEYS.keys()) + ) + + var old_values: Dictionary = {} + for key in updates: + old_values[key] = player.get(key) + + _undo_redo.create_action("MCP: Update playback on %s" % player.name) + for key in updates: + _undo_redo.add_do_property(player, key, updates[key]) + _undo_redo.add_undo_property(player, key, old_values[key]) + _undo_redo.commit_action() + + return { + "data": { + "player_path": player_path, + "applied": updates.keys(), + "values": updates, + "undoable": true, + } + } + + +# ============================================================================ +# audio_play (runtime preview — not saved with scene) +# ============================================================================ + +func play(params: Dictionary) -> Dictionary: + var player_path: String = params.get("player_path", "") + var from_position: float = float(params.get("from_position", 0.0)) + + if player_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") + + var resolved := _resolve_player(player_path) + if resolved.has("error"): + return resolved + var player: Node = resolved.player + + if player.stream == null: + return ErrorCodes.make( + ErrorCodes.MISSING_REQUIRED_PARAM, + "Player has no stream assigned — call audio_player_set_stream first" + ) + + player.play(from_position) + + return { + "data": { + "player_path": player_path, + "from_position": from_position, + "playing": bool(player.playing), + "undoable": false, + "reason": "Runtime playback state — not saved with scene", + } + } + + +# ============================================================================ +# audio_stop (runtime preview — not saved with scene) +# ============================================================================ + +func stop(params: Dictionary) -> Dictionary: + var player_path: String = params.get("player_path", "") + if player_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") + + var resolved := _resolve_player(player_path) + if resolved.has("error"): + return resolved + var player: Node = resolved.player + + player.stop() + + return { + "data": { + "player_path": player_path, + "playing": bool(player.playing), + "undoable": false, + "reason": "Runtime playback state — not saved with scene", + } + } + + +# ============================================================================ +# audio_list (read — scan project for AudioStream resources) +# ============================================================================ + +func list_streams(params: Dictionary) -> Dictionary: + var root: String = params.get("root", "res://") + var include_duration: bool = bool(params.get("include_duration", true)) + + var root_err = McpPathValidator.path_error(root, "root") + if root_err != null: + return root_err + + var efs := EditorInterface.get_resource_filesystem() + if efs == null: + return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "EditorFileSystem not available") + + var results: Array[Dictionary] = [] + var start_dir := efs.get_filesystem_path(root) + if start_dir == null: + start_dir = efs.get_filesystem() + _scan_audio(start_dir, root, include_duration, results) + return { + "data": { + "root": root, + "streams": results, + "count": results.size(), + } + } + + +func _scan_audio(dir: EditorFileSystemDirectory, root: String, include_duration: bool, out: Array[Dictionary]) -> void: + if dir == null: + return + for i in dir.get_file_count(): + var file_path := dir.get_file_path(i) + if not file_path.begins_with(root): + continue + var file_type := dir.get_file_type(i) + var is_audio := file_type == "AudioStream" or ClassDB.is_parent_class(file_type, "AudioStream") + if not is_audio: + continue + var entry: Dictionary = { + "path": file_path, + "class": file_type, + } + if include_duration: + var res := ResourceLoader.load(file_path) + if res is AudioStream: + entry["duration_seconds"] = float((res as AudioStream).get_length()) + else: + entry["duration_seconds"] = 0.0 + out.append(entry) + for i in dir.get_subdir_count(): + _scan_audio(dir.get_subdir(i), root, include_duration, out) + + +# ============================================================================ +# Helpers +# ============================================================================ + +static func _instantiate_player(type_str: String) -> Node: + match type_str: + "1d": + return AudioStreamPlayer.new() + "2d": + return AudioStreamPlayer2D.new() + "3d": + return AudioStreamPlayer3D.new() + return null + + +func _resolve_player(player_path: String) -> Dictionary: + var resolved := McpNodeValidator.resolve_or_error(player_path, "player_path") + if resolved.has("error"): + return resolved + var node: Node = resolved.node + var is_player := node is AudioStreamPlayer \ + or node is AudioStreamPlayer2D \ + or node is AudioStreamPlayer3D + if not is_player: + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Node at %s is not an AudioStreamPlayer/2D/3D (got %s)" % [player_path, node.get_class()] + ) + return {"player": node} + + +## Coerce a playback param value to the expected type. int→float is allowed +## so JSON integers pass through; everything else requires the exact type. +## Returns the coerced value, or null on type mismatch. +static func _coerce_playback_value(value: Variant, expected_type: int) -> Variant: + match expected_type: + TYPE_FLOAT: + if value is float or value is int: + return float(value) + TYPE_BOOL: + if value is bool: + return value + TYPE_STRING: + if value is String: + return value + return null diff --git a/addons/godot_ai/handlers/audio_handler.gd.uid b/addons/godot_ai/handlers/audio_handler.gd.uid new file mode 100644 index 0000000..2510ee7 --- /dev/null +++ b/addons/godot_ai/handlers/audio_handler.gd.uid @@ -0,0 +1 @@ +uid://cjtvod52xxocs diff --git a/addons/godot_ai/handlers/autoload_handler.gd b/addons/godot_ai/handlers/autoload_handler.gd new file mode 100644 index 0000000..22e7764 --- /dev/null +++ b/addons/godot_ai/handlers/autoload_handler.gd @@ -0,0 +1,91 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Handles autoload listing, adding, and removing via ProjectSettings. + + +func list_autoloads(_params: Dictionary) -> Dictionary: + var autoloads: Array[Dictionary] = [] + for prop in ProjectSettings.get_property_list(): + var key: String = prop.get("name", "") + if not key.begins_with("autoload/"): + continue + var name := key.substr("autoload/".length()) + var raw_value: String = ProjectSettings.get_setting(key, "") + var is_singleton := raw_value.begins_with("*") + var path := raw_value.substr(1) if is_singleton else raw_value + autoloads.append({ + "name": name, + "path": path, + "singleton": is_singleton, + }) + return {"data": {"autoloads": autoloads, "count": autoloads.size()}} + + +func add_autoload(params: Dictionary) -> Dictionary: + var name: String = params.get("name", "") + var path: String = params.get("path", "") + var singleton: bool = params.get("singleton", true) + + if name.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name") + if path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path") + var path_err = McpPathValidator.path_error(path, "path") + if path_err != null: + return path_err + if not FileAccess.file_exists(path): + return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "File not found: %s" % path) + + var key := "autoload/%s" % name + if ProjectSettings.has_setting(key): + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Autoload '%s' already exists" % name) + + var value := ("*" if singleton else "") + path + ProjectSettings.set_setting(key, value) + ProjectSettings.set_initial_value(key, "") + ProjectSettings.set_as_basic(key, true) + var err := ProjectSettings.save() + if err != OK: + ProjectSettings.clear(key) + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, + "Failed to save project settings while adding autoload '%s': %s (error %d)" % [name, error_string(err), err]) + + return { + "data": { + "name": name, + "path": path, + "singleton": singleton, + "undoable": false, + "reason": "Autoload changes are saved to project.godot", + } + } + + +func remove_autoload(params: Dictionary) -> Dictionary: + var name: String = params.get("name", "") + if name.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name") + + var key := "autoload/%s" % name + if not ProjectSettings.has_setting(key): + return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, "Autoload '%s' not found" % name) + + var old_value: String = ProjectSettings.get_setting(key, "") + ProjectSettings.clear(key) + var err := ProjectSettings.save() + if err != OK: + ProjectSettings.set_setting(key, old_value) + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, + "Failed to save project settings while removing autoload '%s': %s (error %d)" % [name, error_string(err), err]) + + return { + "data": { + "name": name, + "removed": true, + "undoable": false, + "reason": "Autoload changes are saved to project.godot", + } + } diff --git a/addons/godot_ai/handlers/autoload_handler.gd.uid b/addons/godot_ai/handlers/autoload_handler.gd.uid new file mode 100644 index 0000000..921ed4e --- /dev/null +++ b/addons/godot_ai/handlers/autoload_handler.gd.uid @@ -0,0 +1 @@ +uid://bb0inov044jn6 diff --git a/addons/godot_ai/handlers/batch_handler.gd b/addons/godot_ai/handlers/batch_handler.gd new file mode 100644 index 0000000..2525aa2 --- /dev/null +++ b/addons/godot_ai/handlers/batch_handler.gd @@ -0,0 +1,131 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Executes a list of sub-commands through the dispatcher with stop-on-first-error +## semantics. When undo=true (default), any successful sub-commands are rolled +## back via the scene's UndoRedo history if a later sub-command fails. + +const FORBIDDEN_SUBCOMMANDS := ["batch_execute"] + +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") + + 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 cmd_name in FORBIDDEN_SUBCOMMANDS: + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "commands[%d]: '%s' is not allowed as a sub-command" % [idx, cmd_name]) + if not _dispatcher.has_command(cmd_name): + return _unknown_command_error(idx, cmd_name) + + var results: Array = [] + var succeeded := 0 + var stopped_at = null + var all_undoable := true + # Captured after the first successful commit — get_history_undo_redo() + # errors if called before any action exists in the history_map. + var histories: Array = [] + + for idx in range(commands.size()): + var item: Dictionary = commands[idx] + var cmd_name: String = item["command"] + var sub_params: Dictionary = item.get("params", {}) + + var raw_result: Dictionary = _dispatcher.dispatch_direct(cmd_name, sub_params) + var status: String = raw_result.get("status", "ok") + + var result_entry: Dictionary = {"command": cmd_name, "status": status} + if status == "error": + result_entry["error"] = raw_result.get("error", {}) + results.append(result_entry) + stopped_at = idx + break + else: + var data: Dictionary = raw_result.get("data", raw_result) + result_entry["data"] = data + if typeof(data) == TYPE_DICTIONARY and data.get("undoable", false) != true: + all_undoable = false + results.append(result_entry) + succeeded += 1 + _capture_histories(histories) + + var rolled_back := false + if stopped_at != null and undo and succeeded > 0: + rolled_back = _rollback(succeeded, histories) + + var response_data: Dictionary = { + "succeeded": succeeded, + "stopped_at": stopped_at, + "results": results, + "undo": undo, + "rolled_back": rolled_back, + "undoable": stopped_at == null and all_undoable and not rolled_back, + } + if stopped_at != null: + response_data["error"] = results[-1]["error"] + return {"data": response_data} + + +## Capture the scene's UndoRedo reference for batch rollback. Safe to call +## multiple times; appends only the new reference. MCP write handlers all pin +## their actions to the scene history, so the scene UndoRedo is the only one +## rollback needs. Must be called only after at least one action has been +## committed to the scene history. +func _capture_histories(histories: Array) -> void: + var scene_root := EditorInterface.get_edited_scene_root() + if scene_root == null: + return + var scene_id := _undo_redo.get_object_history_id(scene_root) + var scene_ur := _undo_redo.get_history_undo_redo(scene_id) + if scene_ur != null and not scene_ur in histories: + histories.append(scene_ur) + + +## Build the unknown-command error for a sub-command. Clarifies that +## batch_execute expects plugin command names (not MCP tool names) and +## surfaces fuzzy suggestions in both the message and structured data. +func _unknown_command_error(idx: int, cmd_name: String) -> Dictionary: + var suggestions := _dispatcher.suggest_similar(cmd_name) + var msg := "commands[%d]: unknown plugin command '%s'. batch_execute expects plugin command names (e.g. 'create_node'), not MCP tool names (e.g. 'node_create')." % [idx, cmd_name] + if not suggestions.is_empty(): + msg += " Did you mean: %s?" % ", ".join(suggestions) + var err := ErrorCodes.make(ErrorCodes.UNKNOWN_COMMAND, msg) + err["error"]["data"] = {"suggestions": suggestions} + return err + + +## Undo `count` actions by calling undo() on captured histories in LIFO order. +## Returns true iff all undo calls succeeded. +func _rollback(count: int, histories: Array) -> bool: + if histories.is_empty(): + return false + for _i in range(count): + var undone := false + for ur in histories: + if ur.undo(): + undone = true + break + if not undone: + return false + return true diff --git a/addons/godot_ai/handlers/batch_handler.gd.uid b/addons/godot_ai/handlers/batch_handler.gd.uid new file mode 100644 index 0000000..630a596 --- /dev/null +++ b/addons/godot_ai/handlers/batch_handler.gd.uid @@ -0,0 +1 @@ +uid://dt7um75oofdrh diff --git a/addons/godot_ai/handlers/camera_handler.gd b/addons/godot_ai/handlers/camera_handler.gd new file mode 100644 index 0000000..98a26cc --- /dev/null +++ b/addons/godot_ai/handlers/camera_handler.gd @@ -0,0 +1,1151 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Handles Camera2D / Camera3D authoring — create, configure, bounds, damping, +## node-parent-based follow, presets. +## +## All writes are bundled into a single EditorUndoRedoManager action. +## Setting current=true auto-unmarks previously-current cameras of the same +## class in the same action so one Ctrl-Z reverts the switch. + +const CameraValues := preload("res://addons/godot_ai/handlers/camera_values.gd") +const CameraPresets := preload("res://addons/godot_ai/handlers/camera_presets.gd") + +const _VALID_TYPES := { + "2d": "Camera2D", + "3d": "Camera3D", +} + +const _KEYS_2D := [ + "zoom", + "offset", + "anchor_mode", + "ignore_rotation", + "enabled", + "current", + "process_callback", + "position_smoothing_enabled", + "position_smoothing_speed", + "rotation_smoothing_enabled", + "rotation_smoothing_speed", + "drag_horizontal_enabled", + "drag_vertical_enabled", + "drag_horizontal_offset", + "drag_vertical_offset", + "drag_left_margin", + "drag_top_margin", + "drag_right_margin", + "drag_bottom_margin", + "limit_left", + "limit_right", + "limit_top", + "limit_bottom", + "limit_smoothed", +] + +const _KEYS_3D := [ + "fov", + "near", + "far", + "size", + "projection", + "keep_aspect", + "cull_mask", + "doppler_tracking", + "h_offset", + "v_offset", + "current", +] + +# Transform-shaped keys live on Node2D / Node3D, not in the camera-specific +# schema — rejecting them without a hint sends agents searching for the wrong +# tool. +const _NODE_TRANSFORM_KEYS := [ + "position", "rotation", "scale", "transform", + "global_position", "global_rotation", "global_scale", "global_transform", +] + +const _DAMPING_MARGIN_KEYS := ["left", "top", "right", "bottom"] +const _CURRENT_SETTLE_ATTEMPTS := 8 +const _CURRENT_SETTLE_DELAY_MSEC := 10 + + +var _undo_redo: EditorUndoRedoManager + +# Per-scene logical-current bookkeeping. Keys are scene-root InstanceIDs; +# values are { "2d": NodePath-as-String, "3d": NodePath-as-String } with +# missing keys meaning "no logical current for that class." +# +# Stored on the handler instance (NOT as Node metadata on the scene root) +# because set_meta() persists into the .tscn on save, contaminating user +# scene files with MCP-internal sidecar state that lingers across reloads +# and travels in commits. +var _logical_current: Dictionary = {} + + +func _init(undo_redo: EditorUndoRedoManager) -> void: + _undo_redo = undo_redo + + +# Camera2D doesn't expose `current` as a settable property in Godot 4 — +# only is_current() / make_current() / clear_current(). Camera3D exposes +# both, but using methods uniformly avoids per-class branching. +static func _is_current(cam: Node) -> bool: + if cam == null: + return false + return bool(cam.is_current()) + + +static func _viewport_current_camera(scene_root: Node) -> Node: + if scene_root == null: + return null + var viewport := scene_root.get_viewport() + if viewport == null: + return null + var current_2d := viewport.get_camera_2d() + if current_2d != null and scene_root.is_ancestor_of(current_2d): + return current_2d + var current_3d := viewport.get_camera_3d() + if current_3d != null and scene_root.is_ancestor_of(current_3d): + return current_3d + return null + + +static func _is_effective_current(cam: Node) -> bool: + if _is_current(cam): + return true + if cam is Camera2D: + var viewport_2d := cam.get_viewport() + return viewport_2d != null and viewport_2d.get_camera_2d() == cam + if cam is Camera3D: + var viewport_3d := cam.get_viewport() + return viewport_3d != null and viewport_3d.get_camera_3d() == cam + return false + + +# Logical-current bookkeeping. Updated from inside _apply_make_current / +# _apply_clear_current so DO and UNDO callables stamp the same logical +# slot they touch in the viewport. Reads consult the logical slot first +# and treat it as authoritative when set — the viewport read is the +# fallback for "MCP never touched this scene's cameras." + +func _set_logical_current(cam: Node) -> void: + if cam == null or not is_instance_valid(cam) or not cam.is_inside_tree(): + return + var type_str := _camera_type_str(cam) + if type_str.is_empty(): + return + var scene_root := EditorInterface.get_edited_scene_root() + if scene_root == null or not scene_root.is_ancestor_of(cam): + return + var slot: Dictionary = _logical_current.get(scene_root.get_instance_id(), {}) + slot[type_str] = McpScenePath.from_node(cam, scene_root) + _logical_current[scene_root.get_instance_id()] = slot + + +func _clear_logical_current(cam: Node) -> void: + if cam == null: + return + var type_str := _camera_type_str(cam) + if type_str.is_empty(): + return + var scene_root := EditorInterface.get_edited_scene_root() + if scene_root == null: + return + var key := scene_root.get_instance_id() + if not _logical_current.has(key): + return + var slot: Dictionary = _logical_current[key] + if not slot.has(type_str): + return + # Only clear if the logical slot still points at this camera; otherwise + # a later make_current already took the slot and we'd stomp it. + var current_path := "" + if is_instance_valid(cam) and cam.is_inside_tree() and scene_root.is_ancestor_of(cam): + current_path = McpScenePath.from_node(cam, scene_root) + if String(slot[type_str]) == current_path: + slot.erase(type_str) + if slot.is_empty(): + _logical_current.erase(key) + else: + _logical_current[key] = slot + + +func _logical_current_camera(scene_root: Node, type_str: String = "") -> Node: + if scene_root == null: + return null + var key := scene_root.get_instance_id() + if not _logical_current.has(key): + return null + var slot: Dictionary = _logical_current[key] + var types: Array[String] = [] + if type_str == "2d" or type_str == "3d": + types = [type_str] + else: + types = ["2d", "3d"] + for t in types: + if not slot.has(t): + continue + var path := String(slot[t]) + if path.is_empty(): + slot.erase(t) + continue + var node := McpScenePath.resolve(path, scene_root) + if node == null or not _is_camera(node) or _camera_type_str(node) != t: + slot.erase(t) + continue + return node + if slot.is_empty(): + _logical_current.erase(key) + else: + _logical_current[key] = slot + return null + + +func _is_logical_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)) + return logical != null and logical == cam + + +# Public introspection for tests that need to distinguish "handler has a +# logical marker" from "handler is falling back to engine state". `get_camera` +# / `list_cameras` both use `_resolve_current` which falls through to +# `_is_effective_current` when no marker is set — that's correct for callers +# but masks the marker presence from anyone trying to gate on +# "did the handler actually record this state?". Returns the logical-current +# Camera2D / Camera3D for the given type ("2d" / "3d" / "" for either), or +# null when no marker is set. See #316 PR #372 review feedback. +func peek_logical_current(scene_root: Node, type_str: String = "") -> Node: + return _logical_current_camera(scene_root, type_str) + + +# Authoritative answer for "is `cam` the current camera of its class?" +# +# When a logical marker exists for the camera's class, it is the single +# source of truth — only the marker's referenced camera reports current, +# every other camera of that class reports false even if the viewport +# slot still points at one of them (the headless-CI lag in #140 / #278 / +# #301). Without a logical marker, fall through to the viewport read so +# scenes MCP never touched still answer correctly. +func _resolve_current(scene_root: Node, cam: Node) -> bool: + if scene_root == null or cam == null: + return false + var logical := _logical_current_camera(scene_root, _camera_type_str(cam)) + if logical != null: + return logical == cam + return _is_effective_current(cam) + + +# list_cameras pre-fetches the per-class logical pointers once; this +# variant takes those pointers to avoid an O(n²) walk over the meta +# bookkeeping for each camera in the scene. +func _resolve_current_with_logicals(cam: Node, logical_2d: Node, logical_3d: Node) -> bool: + if cam == null: + return false + if cam is Camera2D: + if logical_2d != null: + return logical_2d == cam + elif cam is Camera3D: + if logical_3d != null: + return logical_3d == cam + return _is_effective_current(cam) + + +# Register a current=true switch on `node` in the open undo action, +# unmarking previously-current siblings of the same class so a single +# Ctrl-Z reverts the whole switch. +# +# Both DO and UNDO route through `_apply_make_current` / `_apply_clear_current` +# on the handler itself rather than calling Camera.make_current() directly. +# The helpers do the make_current (or clear_current) call plus bounded sync +# settling when the viewport hasn't yet reflected the change — headless CI +# occasionally reports `is_current() == false` immediately after a committed +# make_current (observed CI run 24682342469) and symmetrically still reports +# the displaced camera as current immediately after an undo (observed CI runs +# 24682342469, 24692250322, 24696571517, 25079965242 — tracked in #140). +# Later #278 runs broadened the same current-camera timing flake across more +# platforms and assertions, so the settle budget is deliberately above one +# fast local frame. +# +# Because those callables bind to `self` (a RefCounted handler, not a scene +# node), every action that calls this helper must pin its history via +# `create_action(name, MERGE_DISABLE, scene_root)` — otherwise the +# handler-bound ops land in GLOBAL_HISTORY while the scene-node ops land in +# the scene's history, and a single editor_undo reverts only half the action. +# +# Both DO and UNDO use a single make_current() call — never a +# clear_current() + make_current() pair. make_current() takes over the +# viewport slot atomically (Godot enforces one current camera per class +# per viewport), so the displaced camera naturally returns +# is_current() == false without an explicit clear. The two-step approach +# leaves the viewport temporarily with no current camera between the +# clear and the make, which races with editor cleanup on macOS headless +# (observed flaking CI runs 24674252085, 24675424785). +func _add_make_current_to_action(node: Node, type_str: String, scene_root: Node) -> void: + var prev_current: Node = null + for cam in _list_cameras_in_scene(scene_root, type_str): + if cam == node: + continue + if _resolve_current(scene_root, cam): + prev_current = cam + break + _undo_redo.add_do_method(self, "_apply_make_current", node) + if prev_current != null: + _undo_redo.add_undo_method(self, "_apply_make_current", prev_current) + else: + _undo_redo.add_undo_method(self, "_apply_clear_current", node) + + +# Apply make_current on `cam` with bounded synchronous settling. Registered as the +# do/undo callable by `_add_make_current_to_action`. See that function's +# comment for why the undo path needs the retry inside the action itself. +# Safe against a freed camera node — short-circuits if the node is gone +# or not in the tree. +func _apply_make_current(cam: Node) -> void: + if cam == null or not is_instance_valid(cam) or not cam.is_inside_tree(): + return + _set_logical_current(cam) + var scene_root := EditorInterface.get_edited_scene_root() + var type_str := _camera_type_str(cam) + for attempt in range(_CURRENT_SETTLE_ATTEMPTS): + cam.make_current() + _force_camera_refresh(cam) + # Godot's make_current is supposed to atomically displace siblings, + # but on macOS headless the displaced camera occasionally still + # answers is_current() == true after this returns (#140 / #278 / #301). + # Sweep same-class siblings and clear any that lag. + _force_clear_other_currents(cam, type_str, scene_root) + if not _is_current_settled(cam): + _displace_stale_camera_2d(cam) + _force_clear_other_currents(cam, type_str, scene_root) + var waited_this_attempt := false + if _is_current_settled(cam): + if not (cam is Camera2D): + return + OS.delay_msec(_CURRENT_SETTLE_DELAY_MSEC) + waited_this_attempt = true + _force_camera_refresh(cam) + _force_clear_other_currents(cam, type_str, scene_root) + if _is_current_settled(cam): + return + if attempt < _CURRENT_SETTLE_ATTEMPTS - 1 and not waited_this_attempt: + OS.delay_msec(_CURRENT_SETTLE_DELAY_MSEC) + + +# Walk same-class siblings and force-clear any that still report is_current(). +# Best-effort: clear_current errors when called on a non-current camera, so +# guard. Camera2D's clear_current path also flushes the viewport slot, which +# is the one we actually care about settling for #301. +func _force_clear_other_currents(target: Node, type_str: String, scene_root: Node) -> void: + if scene_root == null or type_str.is_empty(): + return + for sibling in _list_cameras_in_scene(scene_root, type_str): + if sibling == target: + continue + if not is_instance_valid(sibling) or not sibling.is_inside_tree(): + continue + if not _is_current(sibling): + # Even if is_current() reports false, the viewport slot can + # still point at this sibling on macOS — re-make target to + # take it back. Cheap (idempotent) when the slot is fine. + if sibling is Camera2D: + var vp_other: Viewport = (sibling as Camera2D).get_viewport() + if vp_other != null and vp_other.get_camera_2d() == sibling: + target.make_current() + _force_camera_refresh(target) + continue + sibling.clear_current() + if sibling is Camera2D: + (sibling as Camera2D).force_update_scroll() + + +# Call after commit_action() whenever the action registered a make_current DO. +# The undo path cannot use a post-undo hook, so it relies on `_apply_make_current` +# directly; create/configure/apply_preset get this extra post-commit verifier. +func _verify_current_after_commit(node: Node) -> void: + _apply_make_current(node) + + +func _force_camera_refresh(cam: Node) -> void: + if cam is Camera2D: + (cam as Camera2D).force_update_scroll() + + +func _is_current_settled(cam: Node) -> bool: + if not _is_current(cam): + return false + if cam is Camera2D: + var viewport := cam.get_viewport() + if viewport != null and viewport.get_camera_2d() != cam: + return false + return true + + +func _displace_stale_camera_2d(target: Node) -> void: + if not (target is Camera2D): + return + var viewport := target.get_viewport() + if viewport == null: + return + var stale := viewport.get_camera_2d() + if stale == null or stale == target or not is_instance_valid(stale): + _nudge_camera_2d_current(target) + return + var was_enabled := stale.enabled + if was_enabled: + stale.enabled = false + target.make_current() + _force_camera_refresh(target) + if was_enabled: + stale.enabled = true + target.make_current() + _force_camera_refresh(target) + + +func _nudge_camera_2d_current(target: Node) -> void: + if not (target is Camera2D): + return + var cam := target as Camera2D + if not cam.enabled: + return + cam.enabled = false + _force_camera_refresh(cam) + cam.enabled = true + cam.make_current() + _force_camera_refresh(cam) + + +# Symmetric counterpart to `_apply_make_current` for the "no previous +# current camera" branch (create_camera with make_current=true and no +# sibling was current). clear_current errors in Godot if called on a +# non-current camera, so guard on is_current first. +func _apply_clear_current(cam: Node) -> void: + if cam == null or not is_instance_valid(cam) or not cam.is_inside_tree(): + return + _clear_logical_current(cam) + for attempt in range(_CURRENT_SETTLE_ATTEMPTS): + if _is_clear_settled(cam): + return + if _is_current(cam): + cam.clear_current() + _force_camera_refresh(cam) + # Camera2D-only: is_current() may answer false while the viewport + # slot still points at cam. Toggle enabled to force the viewport + # to release, then restore. + if cam is Camera2D: + var vp := cam.get_viewport() + if vp != null and vp.get_camera_2d() == cam: + var was_enabled := (cam as Camera2D).enabled + if was_enabled: + (cam as Camera2D).enabled = false + _force_camera_refresh(cam) + if was_enabled: + (cam as Camera2D).enabled = true + if _is_clear_settled(cam): + return + if attempt < _CURRENT_SETTLE_ATTEMPTS - 1: + OS.delay_msec(_CURRENT_SETTLE_DELAY_MSEC) + + +func _is_clear_settled(cam: Node) -> bool: + if cam == null: + return true + if _is_current(cam): + return false + if cam is Camera2D: + var vp := cam.get_viewport() + if vp != null and vp.get_camera_2d() == cam: + return false + return true + + +# ============================================================================ +# camera_create +# ============================================================================ + +func create_camera(params: Dictionary) -> Dictionary: + var parent_path: String = params.get("parent_path", "") + var node_name: String = params.get("name", "Camera") + var type_str: String = params.get("type", "2d") + var make_current: bool = bool(params.get("make_current", false)) + + if not _VALID_TYPES.has(type_str): + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "Invalid camera type '%s'. Valid: %s" % [type_str, ", ".join(_VALID_TYPES.keys())] + ) + + var _scene_check := McpNodeValidator.require_scene_or_error() + if _scene_check.has("error"): + return _scene_check + var scene_root: Node = _scene_check.scene_root + + var parent: Node = scene_root + if not parent_path.is_empty(): + parent = McpScenePath.resolve(parent_path, scene_root) + if parent == null: + return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root)) + + var node := _instantiate_camera(type_str) + if node == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate camera") + if not node_name.is_empty(): + node.name = node_name + + _undo_redo.create_action( + "MCP: Create %s '%s'" % [_VALID_TYPES[type_str], node.name], + UndoRedo.MERGE_DISABLE, scene_root + ) + _undo_redo.add_do_method(parent, "add_child", node, true) + _undo_redo.add_do_method(node, "set_owner", scene_root) + _undo_redo.add_do_reference(node) + if make_current: + # Must land AFTER add_child: making current before the node is in the + # tree is a silent no-op on the viewport. + _add_make_current_to_action(node, type_str, scene_root) + _undo_redo.add_undo_method(parent, "remove_child", node) + _undo_redo.commit_action() + if make_current: + _verify_current_after_commit(node) + + return { + "data": { + "path": McpScenePath.from_node(node, scene_root), + "parent_path": McpScenePath.from_node(parent, scene_root), + "name": String(node.name), + "type": type_str, + "class": _VALID_TYPES[type_str], + "current": bool(make_current), + "undoable": true, + } + } + + +# ============================================================================ +# camera_configure +# ============================================================================ + +func configure(params: Dictionary) -> Dictionary: + var resolved := _resolve_camera(params) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + var node_path: String = resolved.path + var type_str: String = resolved.type + var scene_root: Node = resolved.scene_root + + var properties: Dictionary = params.get("properties", {}) + if properties.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "properties dict is empty") + + var valid_keys: Array = _KEYS_2D if type_str == "2d" else _KEYS_3D + var prop_types := _property_type_map(node) + var coerced: Dictionary = {} + var old_values: Dictionary = {} + # `current` is special-cased via methods (Camera2D doesn't expose it as a property). + var current_request: Variant = null + + for property in properties: + var prop_name: String = String(property) + if not (prop_name in valid_keys): + var msg := "Property '%s' not valid for %s. Valid: %s" % [ + prop_name, _VALID_TYPES[type_str], ", ".join(valid_keys) + ] + if prop_name in _NODE_TRANSFORM_KEYS: + msg += ( + ". Transforms live on the Node, not on the camera config — " + + "use node_set_property(path=%s, property=\"%s\", value=...)" % [node_path, prop_name] + ) + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, msg) + if prop_name == "current": + current_request = bool(properties[prop_name]) + continue + var prop_type: int = prop_types.get(prop_name, TYPE_NIL) + if prop_type == TYPE_NIL: + return ErrorCodes.make( + ErrorCodes.PROPERTY_NOT_ON_CLASS, + "Property '%s' not present on %s" % [prop_name, node.get_class()] + ) + var coerce_result := CameraValues.coerce(prop_name, properties[prop_name], prop_type) + if not coerce_result.ok: + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerce_result.error)) + coerced[prop_name] = coerce_result.value + old_values[prop_name] = node.get(prop_name) + + _undo_redo.create_action( + "MCP: Configure camera %s" % node.name, + UndoRedo.MERGE_DISABLE, scene_root + ) + for prop_name in coerced: + _undo_redo.add_do_property(node, prop_name, coerced[prop_name]) + _undo_redo.add_undo_property(node, prop_name, old_values[prop_name]) + var verify_current_after := false + if current_request != null: + var want_on: bool = bool(current_request) + var was_on: bool = _resolve_current(scene_root, node) + if want_on and not was_on: + _add_make_current_to_action(node, type_str, scene_root) + verify_current_after = true + elif not want_on and was_on: + _undo_redo.add_do_method(self, "_apply_clear_current", node) + _undo_redo.add_undo_method(self, "_apply_make_current", node) + _undo_redo.commit_action() + if verify_current_after: + _verify_current_after_commit(node) + + var applied: Array[String] = [] + var serialized: Dictionary = {} + for prop_name in coerced: + applied.append(prop_name) + serialized[prop_name] = CameraValues.serialize(coerced[prop_name]) + if current_request != null: + applied.append("current") + serialized["current"] = bool(current_request) + + return { + "data": { + "path": node_path, + "type": type_str, + "class": node.get_class(), + "applied": applied, + "values": serialized, + "undoable": true, + } + } + + +# ============================================================================ +# camera_set_limits_2d +# ============================================================================ + +func set_limits_2d(params: Dictionary) -> Dictionary: + var resolved := _resolve_camera(params) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + var node_path: String = resolved.path + var type_str: String = resolved.type + + if type_str != "2d": + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "camera_set_limits_2d requires a Camera2D (got %s)" % node.get_class() + ) + + var applied: Dictionary = {} + var old_values: Dictionary = {} + var edges := { + "left": "limit_left", + "right": "limit_right", + "top": "limit_top", + "bottom": "limit_bottom", + } + for edge in edges: + var v = params.get(edge) + if v != null: + var prop_name: String = edges[edge] + applied[prop_name] = int(v) + old_values[prop_name] = node.get(prop_name) + + var smoothed = params.get("smoothed") + if smoothed != null: + applied["limit_smoothed"] = bool(smoothed) + old_values["limit_smoothed"] = node.get("limit_smoothed") + + if applied.is_empty(): + return ErrorCodes.make( + ErrorCodes.MISSING_REQUIRED_PARAM, + "No limits specified; provide at least one of left, right, top, bottom, smoothed" + ) + + _undo_redo.create_action("MCP: Set camera limits on %s" % node.name) + for prop_name in applied: + _undo_redo.add_do_property(node, prop_name, applied[prop_name]) + _undo_redo.add_undo_property(node, prop_name, old_values[prop_name]) + _undo_redo.commit_action() + + var values: Dictionary = {} + for prop_name in applied: + values[prop_name] = applied[prop_name] + + return { + "data": { + "path": node_path, + "applied": applied.keys(), + "values": values, + "undoable": true, + } + } + + +# ============================================================================ +# camera_set_damping_2d +# ============================================================================ + +func set_damping_2d(params: Dictionary) -> Dictionary: + var resolved := _resolve_camera(params) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + var node_path: String = resolved.path + var type_str: String = resolved.type + + if type_str != "2d": + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "camera_set_damping_2d requires a Camera2D (got %s)" % node.get_class() + ) + + var applied: Dictionary = {} + var old_values: Dictionary = {} + + # position_speed: set position_smoothing_speed AND toggle position_smoothing_enabled. + var pos_v = params.get("position_speed") + if pos_v != null: + var pos_speed := float(pos_v) + var pos_enable := pos_speed > 0.0 + applied["position_smoothing_enabled"] = pos_enable + old_values["position_smoothing_enabled"] = node.get("position_smoothing_enabled") + if pos_enable: + applied["position_smoothing_speed"] = pos_speed + old_values["position_smoothing_speed"] = node.get("position_smoothing_speed") + + # rotation_speed: same pattern for rotation_smoothing_*. + var rot_v = params.get("rotation_speed") + if rot_v != null: + var rot_speed := float(rot_v) + var rot_enable := rot_speed > 0.0 + applied["rotation_smoothing_enabled"] = rot_enable + old_values["rotation_smoothing_enabled"] = node.get("rotation_smoothing_enabled") + if rot_enable: + applied["rotation_smoothing_speed"] = rot_speed + old_values["rotation_smoothing_speed"] = node.get("rotation_smoothing_speed") + + for flag in ["drag_horizontal_enabled", "drag_vertical_enabled"]: + var flag_v = params.get(flag) + if flag_v != null: + applied[flag] = bool(flag_v) + old_values[flag] = node.get(flag) + + # drag_margins: dict {left, top, right, bottom} floats in [0,1]; null/missing keys untouched. + var margins_v = params.get("drag_margins") + if margins_v != null: + if not (margins_v is Dictionary): + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "drag_margins must be a dict with optional keys left/top/right/bottom" + ) + var margins: Dictionary = margins_v + for edge in _DAMPING_MARGIN_KEYS: + var margin_v = margins.get(edge) + if margin_v == null: + continue + var v := float(margin_v) + if v < 0.0 or v > 1.0: + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "drag_margins.%s must be in [0, 1] (got %s)" % [edge, v] + ) + var prop_name: String = "drag_%s_margin" % edge + applied[prop_name] = v + old_values[prop_name] = node.get(prop_name) + + if applied.is_empty(): + return ErrorCodes.make( + ErrorCodes.MISSING_REQUIRED_PARAM, + "No damping params specified; provide at least one of position_speed, rotation_speed, drag_margins, drag_horizontal_enabled, drag_vertical_enabled" + ) + + _undo_redo.create_action("MCP: Set camera damping on %s" % node.name) + for prop_name in applied: + _undo_redo.add_do_property(node, prop_name, applied[prop_name]) + _undo_redo.add_undo_property(node, prop_name, old_values[prop_name]) + _undo_redo.commit_action() + + return { + "data": { + "path": node_path, + "applied": applied.keys(), + "values": applied, + "undoable": true, + } + } + + +# ============================================================================ +# camera_follow_2d +# ============================================================================ + +func follow_2d(params: Dictionary) -> Dictionary: + var resolved := _resolve_camera(params) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + var node_path: String = resolved.path + var type_str: String = resolved.type + var scene_root: Node = resolved.scene_root + + if type_str != "2d": + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "camera_follow_2d requires a Camera2D (got %s)" % node.get_class() + ) + + var target_path: String = params.get("target_path", "") + if target_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_path") + var target := McpScenePath.resolve(target_path, scene_root) + if target == null: + return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, "Target not found: %s" % target_path) + 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..7d68019 --- /dev/null +++ b/addons/godot_ai/handlers/camera_values.gd @@ -0,0 +1,143 @@ +@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: + if value is Vector2: + return value + if value is Dictionary: + var d: Dictionary = value + return Vector2(float(d.get("x", 0)), float(d.get("y", 0))) + if value is Array and value.size() >= 2: + return Vector2(float(value[0]), float(value[1])) + if value is int or value is float: + return Vector2(float(value), float(value)) + return null + + +static func parse_vector3(value: Variant) -> Variant: + if value is Vector3: + return value + if value is Dictionary: + var d: Dictionary = value + return Vector3(float(d.get("x", 0)), float(d.get("y", 0)), float(d.get("z", 0))) + if value is Array and value.size() >= 3: + return Vector3(float(value[0]), float(value[1]), float(value[2])) + return null + + +## Coerce a JSON-shaped value for a camera property against the declared type. +## Returns {ok: true, value: ...} or {ok: false, error: "..."}. +static func coerce(property: String, value: Variant, target_type: int) -> Dictionary: + # Enum-by-name: must match before generic TYPE_INT coercion. + if _ENUM_TABLES.has(property): + if value is String: + var enum_val = resolve_enum(property, value) + if enum_val == null: + return { + "ok": false, + "error": "Invalid %s value: '%s'. Valid: %s" % [ + property, value, ", ".join(enum_keys(property)) + ], + } + return {"ok": true, "value": int(enum_val)} + if value is int or value is float: + return {"ok": true, "value": int(value)} + + match target_type: + TYPE_VECTOR2: + var v2 = parse_vector2(value) + if v2 == null: + return {"ok": false, "error": "Invalid vector2 for %s: %s" % [property, value]} + return {"ok": true, "value": v2} + TYPE_VECTOR3: + var v3 = parse_vector3(value) + if v3 == null: + return {"ok": false, "error": "Invalid vector3 for %s: %s" % [property, value]} + return {"ok": true, "value": v3} + TYPE_BOOL: + if value is bool: + return {"ok": true, "value": value} + if value is int or value is float: + return {"ok": true, "value": bool(value)} + return {"ok": false, "error": "Expected bool for %s" % property} + TYPE_INT: + if value is int: + return {"ok": true, "value": value} + if value is float: + return {"ok": true, "value": int(value)} + return {"ok": false, "error": "Expected int for %s" % property} + TYPE_FLOAT: + if value is float: + return {"ok": true, "value": value} + if value is int: + return {"ok": true, "value": float(value)} + return {"ok": false, "error": "Expected number for %s" % property} + TYPE_STRING: + return {"ok": true, "value": String(value)} + + return {"ok": true, "value": value} + + +## Serialize a Variant into a JSON-friendly shape for responses. +static func serialize(value: Variant) -> Variant: + if value == null: + return null + if value is Vector2: + return {"x": value.x, "y": value.y} + if value is Vector3: + return {"x": value.x, "y": value.y, "z": value.z} + return value diff --git a/addons/godot_ai/handlers/camera_values.gd.uid b/addons/godot_ai/handlers/camera_values.gd.uid new file mode 100644 index 0000000..45e3c09 --- /dev/null +++ b/addons/godot_ai/handlers/camera_values.gd.uid @@ -0,0 +1 @@ +uid://bgjnubgnv6ses diff --git a/addons/godot_ai/handlers/client_handler.gd b/addons/godot_ai/handlers/client_handler.gd new file mode 100644 index 0000000..27841be --- /dev/null +++ b/addons/godot_ai/handlers/client_handler.gd @@ -0,0 +1,43 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Handles MCP client configuration commands. + + +func configure_client(params: Dictionary) -> Dictionary: + var client_id: String = params.get("client", "") + if not McpClientConfigurator.has_client(client_id): + var valid := ", ".join(McpClientConfigurator.client_ids()) + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown client: %s. Use one of: %s" % [client_id, valid]) + var result := McpClientConfigurator.configure(client_id) + if result.get("status") == "error": + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, + result.get("message", "Configuration failed for '%s'" % client_id)) + return {"data": result} + + +func remove_client(params: Dictionary) -> Dictionary: + var client_id: String = params.get("client", "") + if not McpClientConfigurator.has_client(client_id): + var valid := ", ".join(McpClientConfigurator.client_ids()) + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown client: %s. Use one of: %s" % [client_id, valid]) + var result := McpClientConfigurator.remove(client_id) + if result.get("status") == "error": + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, + result.get("message", "Removal failed for '%s'" % client_id)) + return {"data": result} + + +func check_client_status(_params: Dictionary) -> Dictionary: + var clients := [] + for client_id in McpClientConfigurator.client_ids(): + var status := McpClientConfigurator.check_status(client_id) + clients.append({ + "id": client_id, + "display_name": McpClientConfigurator.client_display_name(client_id), + "status": McpClient.status_label(status), + "installed": McpClientConfigurator.is_installed(client_id), + }) + return {"data": {"clients": clients}} diff --git a/addons/godot_ai/handlers/client_handler.gd.uid b/addons/godot_ai/handlers/client_handler.gd.uid new file mode 100644 index 0000000..25687f5 --- /dev/null +++ b/addons/godot_ai/handlers/client_handler.gd.uid @@ -0,0 +1 @@ +uid://bmo4foc5fq75c diff --git a/addons/godot_ai/handlers/control_draw_recipe_handler.gd b/addons/godot_ai/handlers/control_draw_recipe_handler.gd new file mode 100644 index 0000000..e480a9d --- /dev/null +++ b/addons/godot_ai/handlers/control_draw_recipe_handler.gd @@ -0,0 +1,325 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Handles the control_draw_recipe MCP command. Attaches a shared DrawRecipe +## script to a Control and stores the caller's ordered draw ops in node +## metadata under "_ops". The DrawRecipe script dispatches each op to a +## CanvasItem draw_* call in _draw(). One Ctrl+Z reverts script + meta as a +## single undo step. + +const DRAW_RECIPE_SCRIPT := preload("res://addons/godot_ai/runtime/draw_recipe.gd") +const UiHandler := preload("res://addons/godot_ai/handlers/ui_handler.gd") + +var _undo_redo: EditorUndoRedoManager + + +func _init(undo_redo: EditorUndoRedoManager) -> void: + _undo_redo = undo_redo + + +func control_draw_recipe(params: Dictionary) -> Dictionary: + var path: String = params.get("path", "") + var ops_raw: Variant = params.get("ops", null) + var clear_existing: bool = bool(params.get("clear_existing", true)) + + if path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path") + if typeof(ops_raw) != TYPE_ARRAY: + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "ops must be an Array") + + var _resolved := McpNodeValidator.resolve_or_error(path, "path") + if _resolved.has("error"): + return _resolved + var node: Node = _resolved.node + var scene_root: Node = _resolved.scene_root + if not node is Control: + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "control_draw_recipe requires a Control node, got %s" % node.get_class() + ) + + var coerced := _coerce_ops(ops_raw) + if coerced.has("error"): + return coerced + var coerced_ops: Array = coerced.ops + + var old_script: Variant = node.get_script() + if old_script != null and old_script != DRAW_RECIPE_SCRIPT: + if not clear_existing: + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + ( + "Node %s already has a script. Pass clear_existing=true to replace." + % path + ) + ) + + var had_meta := node.has_meta("_ops") + var old_ops: Variant = node.get_meta("_ops") if had_meta else null + + _undo_redo.create_action("MCP: Draw recipe on %s" % node.name) + _undo_redo.add_do_method(node, "set_script", DRAW_RECIPE_SCRIPT) + _undo_redo.add_do_method(node, "set_meta", "_ops", coerced_ops) + _undo_redo.add_do_method(node, "queue_redraw") + _undo_redo.add_undo_method(node, "set_script", old_script) + if had_meta: + _undo_redo.add_undo_method(node, "set_meta", "_ops", old_ops) + else: + _undo_redo.add_undo_method(node, "remove_meta", "_ops") + _undo_redo.add_undo_method(node, "queue_redraw") + _undo_redo.commit_action() + + return { + "data": + { + "path": McpScenePath.from_node(node, scene_root), + "ops_count": coerced_ops.size(), + "script_attached": old_script == null, + "script_replaced": old_script != null and old_script != DRAW_RECIPE_SCRIPT, + "undoable": true, + } + } + + +## Populate a freshly-instantiated Control with the draw recipe in memory +## (no undo action). Used by PR2's pattern_corner_brackets, which wraps the +## node-add + set_script/set_meta in its own create_action. +static func attach_recipe_to(node: Control, coerced_ops: Array) -> void: + node.set_script(DRAW_RECIPE_SCRIPT) + node.set_meta("_ops", coerced_ops) + + +## 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/curve_handler.gd b/addons/godot_ai/handlers/curve_handler.gd new file mode 100644 index 0000000..b4b71b8 --- /dev/null +++ b/addons/godot_ai/handlers/curve_handler.gd @@ -0,0 +1,243 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Replaces all points on a Curve / Curve2D / Curve3D resource. The point +## list shape depends on resource type (see `set_points` for the schemas). +## +## Dedicated tool rather than a property set because Curve2D/Curve3D.add_point +## is a method call, not a property — resource_create's `properties` dict can't +## reach it. + +const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd") + +var _undo_redo: EditorUndoRedoManager +var _connection: McpConnection + + +func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void: + _undo_redo = undo_redo + _connection = connection + + +func set_points(params: Dictionary) -> Dictionary: + var node_path: String = params.get("path", "") + var property: String = params.get("property", "") + var resource_path: String = params.get("resource_path", "") + var new_points: Array = params.get("points", []) + + var home_err := McpResourceIO.validate_home(params) + if home_err != null: + return home_err + var has_file_target := not resource_path.is_empty() + if not (new_points is Array): + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "points must be an array") + + var curve: Resource + var node: Node = null + var curve_created := false + if has_file_target: + var rpath_err = McpPathValidator.loadable_error(resource_path, "resource_path") + if rpath_err != null: + return rpath_err + if not ResourceLoader.exists(resource_path): + return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % resource_path) + # ResourceLoader.load() returns Godot's cached Resource. Duplicate + # before mutating so: (a) open scenes holding a reference to this + # .tres don't silently see the new points outside any undo action, + # and (b) if ResourceSaver.save() fails we haven't corrupted the + # in-memory cache (cache/disk divergence). Also guard against + # ResourceLoader.exists() succeeding but load() returning null + # (corrupt .tres, unregistered class) — otherwise curve.get_class() + # on the response line below would crash the plugin. + var loaded_curve: Resource = ResourceLoader.load(resource_path) + if loaded_curve == null: + return ErrorCodes.make( + ErrorCodes.INTERNAL_ERROR, + "Failed to load curve from %s (file exists but load returned null — may be corrupt)" % resource_path + ) + curve = loaded_curve.duplicate() + else: + var _scene_check := McpNodeValidator.require_scene_or_error() + if _scene_check.has("error"): + return _scene_check + var scene_root: Node = _scene_check.scene_root + node = McpScenePath.resolve(node_path, scene_root) + if node == null: + return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_node_error(node_path, scene_root)) + if not (property in node): + return ErrorCodes.make( + ErrorCodes.PROPERTY_NOT_ON_CLASS, + "Property '%s' not found on %s" % [property, node.get_class()] + ) + 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..5da340c --- /dev/null +++ b/addons/godot_ai/handlers/editor_handler.gd @@ -0,0 +1,1098 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") +const Telemetry := preload("res://addons/godot_ai/telemetry.gd") + +## Handles editor state, selection, log, screenshot, and performance commands. + +const UpdateMixedState := preload("res://addons/godot_ai/utils/update_mixed_state.gd") + +var _log_buffer: McpLogBuffer +var _connection: McpConnection +var _debugger_plugin: McpDebuggerPlugin +var _game_log_buffer: McpGameLogBuffer +var _editor_log_buffer: McpEditorLogBuffer +var _debugger_errors_root: Node +var _debugger_search_root_cache: Node + + +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) -> 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 + + +func get_editor_state(_params: Dictionary) -> Dictionary: + var scene_root := EditorInterface.get_edited_scene_root() + 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(), + } + ## Half-installed addon tree from a failed self-update rollback. When + ## non-empty, the agent / dock paint the operator-facing recovery copy + ## from `update_mixed_state.gd::diagnose`. Field omitted when the + ## addons tree is clean so editor_state's normal payload stays small. + ## See issue #354 / audit-v2 #10. + var mixed_state := UpdateMixedState.diagnose() + if not mixed_state.is_empty(): + data["mixed_state"] = mixed_state + return {"data": data} + + +func get_selection(_params: Dictionary) -> Dictionary: + var scene_root := EditorInterface.get_edited_scene_root() + var selected := EditorInterface.get_selection().get_selected_nodes() + var paths: Array[String] = [] + for node in selected: + paths.append(McpScenePath.from_node(node, scene_root)) + return {"data": {"selected_paths": paths, "count": paths.size()}} + + +const VALID_LOG_SOURCES := ["plugin", "game", "editor", "all"] + + +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)) + 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) + "editor": + return _get_editor_logs(count, offset, include_details) + "all": + return _get_all_logs(count, offset, include_details) + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Unreachable") + + +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) -> Dictionary: + if _game_log_buffer == null: + return { + "data": { + "source": "game", + "lines": [], + "total_count": 0, + "returned_count": 0, + "offset": offset, + "run_id": "", + "is_running": false, + "dropped_count": 0, + } + } + var page := _entries_for_response(_game_log_buffer.get_range(offset, count), include_details) + return { + "data": { + "source": "game", + "lines": page, + "total_count": _game_log_buffer.total_count(), + "returned_count": page.size(), + "offset": offset, + "run_id": _game_log_buffer.run_id(), + "is_running": EditorInterface.is_playing_scene(), + "dropped_count": _game_log_buffer.dropped_count(), + } + } + + +func _get_editor_logs(count: int, offset: int, include_details: bool) -> Dictionary: + ## Editor-process script errors (parse errors, @tool runtime errors, + ## EditorPlugin errors, push_error/push_warning). Captured by + ## editor_logger.gd via OS.add_logger and gated on Godot 4.5+; on older + ## engines the buffer can be null. Godot also sends GDScript reload + ## warnings/errors straight to the Debugger dock's Errors tab; those do + ## not flow through OS.add_logger, so merge the visible tree rows here. + var all_entries := _collect_editor_log_entries() + var page := _entries_for_response(_slice_entries(all_entries, offset, count), include_details) + 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, + } + } + + +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) + if _game_log_buffer != null: + for entry in _game_log_buffer.get_range(0, _game_log_buffer.total_count()): + 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) + var run_id := "" + var dropped := 0 + if _game_log_buffer != null: + run_id = _game_log_buffer.run_id() + dropped = _game_log_buffer.dropped_count() + if _editor_log_buffer != null: + dropped += _editor_log_buffer.dropped_count() + return { + "data": { + "source": "all", + "lines": page, + "total_count": combined.size(), + "returned_count": page.size(), + "offset": offset, + "run_id": run_id, + "is_running": EditorInterface.is_playing_scene(), + "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]: + var entries: Array[Dictionary] = [] + if _editor_log_buffer != null: + for entry in _editor_log_buffer.get_range(0, _editor_log_buffer.total_count()): + entries.append(entry) + for entry in _read_debugger_error_entries(): + if not _has_equivalent_log_entry(entries, entry): + entries.append(entry) + return entries + + +func _read_debugger_error_entries() -> Array[Dictionary]: + var entries: Array[Dictionary] = [] + for tree in _locate_debugger_error_trees(): + for entry in _entries_from_debugger_error_tree(tree): + if not _has_equivalent_log_entry(entries, entry): + entries.append(entry) + return entries + + +func _locate_debugger_error_trees() -> Array[Tree]: + var trees: Array[Tree] = [] + if _debugger_plugin == null and _debugger_errors_root == null: + return trees + var root: Node = _debugger_errors_root + if root == null: + root = _debugger_search_root() + if root == null: + return trees + _collect_debugger_error_trees(root, trees) + return trees + + +func _debugger_search_root() -> Node: + ## logs_read is a polling tool, so per-call discovery must not recurse the + ## entire editor UI. EditorDebuggerNode is the bottom-panel container that + ## owns every ScriptEditorDebugger session tab and lives for the editor's + ## lifetime — find it once from the base control, then scan only its + ## subtree on later calls. The error Trees themselves can't be cached: + ## they are identified by their content, and an emptied tree is + ## indistinguishable from any other Tree. + 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 _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") + + +## ScriptEditorDebugger lays out an error item's children flat, in order: an +## optional "" row, one "" row, then one row per stack +## frame. Only frame 0 carries the "" label (TTR-translated); +## later frames have an empty label. Every frame row carries [path, line] +## metadata, but so can the Error/Source rows, so metadata alone can't +## identify frames — the frame run has to be found first. +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: + ## Non-English editor locale: the "" label is translated. + ## Frames past the first are the only rows with an empty label and a + ## real location; back up one row to recover the labeled first frame + ## (rows before the frame run always have a non-empty label). + 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 + + +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 + + +static func _has_equivalent_log_entry(entries: Array[Dictionary], candidate: Dictionary) -> bool: + var key := _log_entry_key(candidate) + for entry in entries: + if _log_entry_key(entry) == key: + return true + return false + + +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)), + ] + + +## 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: + 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(ErrorCodes.EDITOR_NOT_READY, "No 3D viewport available") + ## 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(): + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Game is not running — use source='viewport' or start the project first") + ## 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) + _: + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid source '%s' — use 'viewport', '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(ErrorCodes.EDITOR_NOT_READY, "No camera in 3D viewport") + + ## 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 + + 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: + return _make_viewport_not_3d_error( + "", + "The editor 3D viewport is empty because no scene is open. Open a scene with `scene_open` first." + ) + ## 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 + if scene_root is Node2D or scene_root is Control: + 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) 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 + return _make_viewport_not_3d_error(root_type, hint) + + +## 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(ErrorCodes.EDITOR_NOT_READY, hint) + err["error"]["data"] = { + "editor_state": "viewport_not_3d", + "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: + var err := ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, hint) + err["error"]["data"] = { + "editor_state": "viewport_empty", + "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: + 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) + + var img_bytes := image.save_png_to_buffer() + var base64_str := Marshalls.raw_to_base64(img_bytes) + + return { + "data": { + "source": source, + "width": image.get_width(), + "height": image.get_height(), + "original_width": original_width, + "original_height": original_height, + "format": "png", + "image_base64": base64_str, + } + } + + +## 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: + 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): + ## No Clear button near this tree (synthetic roots in tests). + ## A raw clear is acceptable there; the real panel always routes + ## through the button below. + tree.clear() + return cleared + + +## Clear via ScriptEditorDebugger's own Clear button so the engine runs +## _clear_errors_list() — clearing the Tree directly leaves error_count/ +## warning_count, the "Errors (N)" tab badge, the errors_cleared signal, and +## the toolbar button states out of sync with the emptied tree. The button is +## identified by its pressed-connection target, not its (translated) label. +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 + + +func reload_plugin(_params: Dictionary) -> Dictionary: + _log_buffer.log("reload_plugin requested, reloading next frame") + ## Persist a pending plugin_reload telemetry event *before* the + ## disable kills the live WebSocket. The re-enabled plugin's + ## _enter_tree flushes via `_telemetry.flush_pending_plugin_reload()`. + Telemetry.record_pending_plugin_reload("mcp_tool") + _do_reload_plugin.call_deferred() + return {"data": {"status": "reloading", "message": "Plugin reload initiated"}} + + +## Force a filesystem rescan before toggling the plugin, so Godot's +## class-name registry picks up any .gd files added since the last scan +## (e.g. via git pull or an agent-driven sync). Without this, re-enable can +## fail with "Could not find type X" when new class_name scripts are on disk +## but not yet registered, leaving the plugin disabled with no recovery path +## short of killing the editor. See issue #83. +func _do_reload_plugin() -> void: + var fs := EditorInterface.get_resource_filesystem() + fs.scan() + var tree := Engine.get_main_loop() as SceneTree + # Cap the wait so a long scan (huge project) doesn't hang reload. + var deadline_ms := Time.get_ticks_msec() + 5000 + while fs.is_scanning() and Time.get_ticks_msec() < deadline_ms: + await tree.process_frame + EditorInterface.set_plugin_enabled("res://addons/godot_ai/plugin.cfg", false) + EditorInterface.set_plugin_enabled("res://addons/godot_ai/plugin.cfg", true) + + +func quit_editor(_params: Dictionary) -> Dictionary: + _log_buffer.log("quit_editor requested, quitting next frame") + ## Defer the quit so the response is sent back before the editor exits. + EditorInterface.get_base_control().get_tree().call_deferred("quit") + return {"data": {"status": "quitting", "message": "Editor quit initiated"}} + + +func game_eval(params: Dictionary) -> Dictionary: + var code: String = params.get("code", "") + if code.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "code is required") + + if _debugger_plugin == null or _connection == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, + "Debugger bridge unavailable — plugin may not be fully initialised") + + if not EditorInterface.is_playing_scene(): + return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, + "Game is not running — start the project first") + + var request_id: String = params.get("_request_id", "") + if request_id.is_empty(): + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, + "Missing request_id — cannot correlate deferred response") + + _debugger_plugin.request_game_eval(code, request_id, _connection) + return McpDispatcher.DEFERRED_RESPONSE + + +func game_command(params: Dictionary) -> Dictionary: + var op: String = str(params.get("op", "")) + if op.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "op is required") + + if _debugger_plugin == null or _connection == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, + "Debugger bridge unavailable — plugin may not be fully initialised") + + if not EditorInterface.is_playing_scene(): + return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, + "Game is not running — start the project first") + + 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", {}) + _debugger_plugin.request_game_command(op, command_params, request_id, _connection) + return McpDispatcher.DEFERRED_RESPONSE diff --git a/addons/godot_ai/handlers/editor_handler.gd.uid b/addons/godot_ai/handlers/editor_handler.gd.uid new file mode 100644 index 0000000..16d785f --- /dev/null +++ b/addons/godot_ai/handlers/editor_handler.gd.uid @@ -0,0 +1 @@ +uid://dcro7yc8bor6v diff --git a/addons/godot_ai/handlers/environment_handler.gd b/addons/godot_ai/handlers/environment_handler.gd new file mode 100644 index 0000000..6729f1e --- /dev/null +++ b/addons/godot_ai/handlers/environment_handler.gd @@ -0,0 +1,181 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Creates an Environment (+ optional Sky + ProceduralSkyMaterial) chain and +## either assigns it to a WorldEnvironment node or saves it to a .tres file. +## Bundles sub-resource creation + assignment in a single undo action. + +const ResourceHandler := preload("res://addons/godot_ai/handlers/resource_handler.gd") + +var _undo_redo: EditorUndoRedoManager +var _connection: McpConnection + + +func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void: + _undo_redo = undo_redo + _connection = connection + + +const _PRESETS := { + "default": {"sky": true, "fog": false}, + "clear": {"sky": true, "fog": false}, + "sunset": {"sky": true, "fog": false}, + "night": {"sky": true, "fog": false}, + "fog": {"sky": true, "fog": true}, +} + + +func create_environment(params: Dictionary) -> Dictionary: + var node_path: String = params.get("path", "") + var resource_path: String = params.get("resource_path", "") + var overwrite: bool = params.get("overwrite", false) + var preset: String = params.get("preset", "default") + var properties: Dictionary = params.get("properties", {}) + var sky_param = params.get("sky", null) # nullable — falls back to preset default + + # environment_create targets the whole WorldEnvironment node (no separate + # `property` param) — pass require_property=false. + var home_err := McpResourceIO.validate_home(params, false) + if home_err != null: + return home_err + + if not _PRESETS.has(preset): + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "Invalid preset '%s'. Valid: %s" % [preset, ", ".join(_PRESETS.keys())] + ) + + var preset_config: Dictionary = _PRESETS[preset] + var want_sky: bool = preset_config.sky + var sky_properties: Dictionary = {} + if sky_param != null: + if sky_param is bool: + want_sky = sky_param + elif sky_param is Dictionary: + var sky_config: Dictionary = (sky_param as Dictionary).duplicate() + var material_type: String = String(sky_config.get("sky_material", "procedural")).to_lower() + if material_type != "procedural": + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "sky.sky_material must be 'procedural' when sky is a dictionary" + ) + sky_config.erase("sky_material") + sky_properties = sky_config + want_sky = true + else: + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "sky must be a bool, null, or dictionary of ProceduralSkyMaterial properties" + ) + + var env := Environment.new() + var sky: Sky = null + var sky_material: ProceduralSkyMaterial = null + if want_sky: + sky_material = ProceduralSkyMaterial.new() + sky = Sky.new() + sky.sky_material = sky_material + env.background_mode = Environment.BG_SKY + env.sky = sky + else: + env.background_mode = Environment.BG_CLEAR_COLOR + + _apply_preset(env, sky_material, preset) + if not sky_properties.is_empty(): + var sky_apply_err := ResourceHandler._apply_resource_properties(sky_material, sky_properties) + if sky_apply_err != null: + return sky_apply_err + if preset_config.fog: + env.volumetric_fog_enabled = true + env.volumetric_fog_density = 0.03 + + if not properties.is_empty(): + var apply_err := ResourceHandler._apply_resource_properties(env, properties) + if apply_err != null: + return apply_err + + if not resource_path.is_empty(): + return _save_environment(env, sky, sky_material, resource_path, overwrite, preset) + return _assign_environment(env, sky, sky_material, node_path, preset) + + +static func _apply_preset(env: Environment, sky_material: ProceduralSkyMaterial, preset: String) -> void: + match preset: + "default", "clear": + if sky_material != null: + sky_material.sky_top_color = Color(0.38, 0.45, 0.55) + sky_material.sky_horizon_color = Color(0.65, 0.67, 0.7) + sky_material.ground_horizon_color = Color(0.65, 0.67, 0.7) + sky_material.ground_bottom_color = Color(0.2, 0.17, 0.13) + sky_material.sun_angle_max = 30.0 + env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY + env.ambient_light_energy = 1.0 + "sunset": + if sky_material != null: + sky_material.sky_top_color = Color(0.25, 0.3, 0.55) + sky_material.sky_horizon_color = Color(1.0, 0.55, 0.3) + sky_material.ground_horizon_color = Color(0.85, 0.4, 0.25) + sky_material.ground_bottom_color = Color(0.2, 0.12, 0.1) + env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY + env.ambient_light_color = Color(1.0, 0.75, 0.55) + env.ambient_light_energy = 0.8 + "night": + if sky_material != null: + sky_material.sky_top_color = Color(0.02, 0.02, 0.07) + sky_material.sky_horizon_color = Color(0.05, 0.07, 0.15) + sky_material.ground_horizon_color = Color(0.04, 0.05, 0.1) + sky_material.ground_bottom_color = Color(0.0, 0.0, 0.02) + env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + env.ambient_light_color = Color(0.2, 0.22, 0.35) + env.ambient_light_energy = 0.4 + "fog": + if sky_material != null: + sky_material.sky_top_color = Color(0.65, 0.65, 0.7) + sky_material.sky_horizon_color = Color(0.8, 0.8, 0.82) + sky_material.ground_horizon_color = Color(0.7, 0.7, 0.72) + sky_material.ground_bottom_color = Color(0.3, 0.3, 0.32) + env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY + env.ambient_light_energy = 0.7 + + +func _assign_environment(env: Environment, sky: Sky, sky_material: ProceduralSkyMaterial, node_path: String, preset: String) -> Dictionary: + var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path") + if _resolved.has("error"): + return _resolved + var node: Node = _resolved.node + var _scene_root: Node = _resolved.scene_root + if not (node is WorldEnvironment): + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Node at %s is %s — must be WorldEnvironment" % [node_path, node.get_class()] + ) + + var old_env = (node as WorldEnvironment).environment + + _undo_redo.create_action("MCP: Create Environment (%s) for %s" % [preset, node.name]) + _undo_redo.add_do_property(node, "environment", env) + _undo_redo.add_undo_property(node, "environment", old_env) + _undo_redo.add_do_reference(env) + if sky != null: + _undo_redo.add_do_reference(sky) + if sky_material != null: + _undo_redo.add_do_reference(sky_material) + _undo_redo.commit_action() + + return { + "data": { + "path": node_path, + "preset": preset, + "sky_created": sky != null, + "sky_material_class": sky_material.get_class() if sky_material != null else "", + "undoable": true, + } + } + + +func _save_environment(env: Environment, _sky: Sky, _sky_material: ProceduralSkyMaterial, resource_path: String, overwrite: bool, preset: String) -> Dictionary: + return McpResourceIO.save_to_disk(env, resource_path, overwrite, "Environment", { + "preset": preset, + }, _connection) diff --git a/addons/godot_ai/handlers/environment_handler.gd.uid b/addons/godot_ai/handlers/environment_handler.gd.uid new file mode 100644 index 0000000..f495f8f --- /dev/null +++ b/addons/godot_ai/handlers/environment_handler.gd.uid @@ -0,0 +1 @@ +uid://b1k7jldwjp5jt diff --git a/addons/godot_ai/handlers/filesystem_handler.gd b/addons/godot_ai/handlers/filesystem_handler.gd new file mode 100644 index 0000000..483798b --- /dev/null +++ b/addons/godot_ai/handlers/filesystem_handler.gd @@ -0,0 +1,112 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Handles file read/write operations and reimport within the Godot project. + + +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 + + # 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 existed_before := FileAccess.file_exists(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.close() + + # 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", + } + McpResourceIO.attach_cleanup_hint(data, existed_before, [path]) + 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(ErrorCodes.EDITOR_NOT_READY, "EditorFileSystem not available") + + var reimported: 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) + reimported.append(path) + + return { + "data": { + "reimported": reimported, + "not_found": not_found, + "reimported_count": reimported.size(), + "not_found_count": not_found.size(), + "undoable": false, + "reason": "Reimport is a file system 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/input_handler.gd b/addons/godot_ai/handlers/input_handler.gd new file mode 100644 index 0000000..196f0db --- /dev/null +++ b/addons/godot_ai/handlers/input_handler.gd @@ -0,0 +1,278 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Handles input action listing, creation, removal, and event binding. +## Actions are persisted via ProjectSettings so they survive editor restarts. + + +func list_actions(params: Dictionary) -> Dictionary: + var include_builtin: bool = params.get("include_builtin", false) + ## Authoritative source for user-authored actions is the ``[input]`` + ## section of ``project.godot``. ``ProjectSettings.has_setting`` is not + ## reliable here because Godot registers ``ui_*`` defaults via + ## ``GLOBAL_DEF_BASIC``, which makes ``has_setting`` return true for + ## them. Reading the file via ``ConfigFile`` distinguishes the user's + ## entries from engine-registered defaults regardless of namespace. + ## See #213. + var user_authored := _read_user_authored_actions() + var actions: Array[Dictionary] = [] + 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 + 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, + }) + 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"): + result[key] = true + 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") + + 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) + + 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 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") + + if not InputMap.has_action(action): + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Action '%s' not found" % action) + + var key := "input/%s" % action + var old_setting = ProjectSettings.get_setting(key) if ProjectSettings.has_setting(key) else null + InputMap.erase_action(action) + + if old_setting != null: + ProjectSettings.clear(key) + var err := ProjectSettings.save() + if err != OK: + 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, + "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", + } + } + + +## 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) + 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 + 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 _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) + 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..3086f09 --- /dev/null +++ b/addons/godot_ai/handlers/material_handler.gd @@ -0,0 +1,788 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Handles Material authoring: creating .tres files, setting BaseMaterial3D +## properties / shader uniforms, assigning to nodes, high-level presets. +## +## File-resource lifecycle mirrors ThemeHandler (create/load/mutate/save). +## Undo pattern mirrors AnimationHandler (single create_action bundles +## every dependency spawn). + +const MaterialValues := preload("res://addons/godot_ai/handlers/material_values.gd") +const MaterialPresets := preload("res://addons/godot_ai/handlers/material_presets.gd") + +const _TYPE_TO_CLASS := { + "standard": "StandardMaterial3D", + "orm": "ORMMaterial3D", + "canvas_item": "CanvasItemMaterial", + "shader": "ShaderMaterial", +} + +const _SUPPORTED_SUFFIXES := [".tres", ".material", ".res"] + + +var _undo_redo: EditorUndoRedoManager + + +func _init(undo_redo: EditorUndoRedoManager) -> void: + _undo_redo = undo_redo + + +# ============================================================================ +# 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 := ResourceSaver.save(mat, path) + 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(ErrorCodes.EDITOR_NOT_READY, "EditorFileSystem not available") + + 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 + 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 + 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 := ResourceSaver.save(mat, save_to) + 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 "", + "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. + 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 path_err := _validate_material_path(path, "path", true) + if path_err != null: + return path_err + + 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 := ResourceSaver.save(mat, path) + 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: + return + mat.set(property, value) + ResourceSaver.save(mat, mat_path) + + +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): + return + (mat as ShaderMaterial).set_shader_parameter(param_name, value) + ResourceSaver.save(mat, mat_path) + + +# ============================================================================ +# 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..56a2040 --- /dev/null +++ b/addons/godot_ai/handlers/material_values.gd @@ -0,0 +1,255 @@ +@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", "#rrggbbaa", named (red/blue/...) or dict. +## Returns null if the input cannot be parsed. +static func parse_color(value: Variant) -> Variant: + if value is Color: + return value + if value is String: + var s: String = value + var sentinel_a := Color(0, 0, 0, 0) + var sentinel_b := Color(1, 1, 1, 1) + var a := Color.from_string(s, sentinel_a) + var b := Color.from_string(s, sentinel_b) + if a != b: + return null + return a + if value is Dictionary: + var d: Dictionary = value + if d.has("r") and d.has("g") and d.has("b"): + return Color(float(d.r), float(d.g), float(d.b), float(d.get("a", 1.0))) + if value is Array and value.size() >= 3: + var arr: Array = value + var alpha := float(arr[3]) if arr.size() >= 4 else 1.0 + return Color(float(arr[0]), float(arr[1]), float(arr[2]), alpha) + return null + + +static func parse_vector3(value: Variant) -> Variant: + if value is Vector3: + return value + if value is Dictionary: + var d: Dictionary = value + return Vector3(float(d.get("x", 0)), float(d.get("y", 0)), float(d.get("z", 0))) + if value is Array and value.size() >= 3: + return Vector3(float(value[0]), float(value[1]), float(value[2])) + return null + + +static func parse_vector2(value: Variant) -> Variant: + if value is Vector2: + return value + if value is Dictionary: + var d: Dictionary = value + return Vector2(float(d.get("x", 0)), float(d.get("y", 0))) + if value is Array and value.size() >= 2: + return Vector2(float(value[0]), float(value[1])) + return null + + +## Parse a {stops: [{time, color}]} gradient dict into a Gradient resource. +static func parse_gradient(value: Variant) -> Variant: + if value is Gradient: + return value + 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 = PackedFloat32Array() + var colors: PackedColorArray = PackedColorArray() + for stop in stops_array: + if not (stop is Dictionary): + return null + var t := float(stop.get("time", 0.0)) + var c = parse_color(stop.get("color")) + if c == null: + return null + offsets.append(t) + colors.append(c) + var grad := Gradient.new() + grad.offsets = offsets + grad.colors = colors + return grad + + +## Load a Texture2D from a res:// / uid:// / user:// path (validate_loadable_path). +## Returns null on failure (including a path that fails confinement / traversal). +static func load_texture(path: String) -> Texture2D: + if not McpPathValidator.validate_loadable_path(path).is_empty(): + return null + if not ResourceLoader.exists(path): + return null + var res := ResourceLoader.load(path) + if res is Texture2D: + return res + return null + + +## Coerce a JSON-shaped value for a material property. +## Returns a dict {ok: true, value: ...} on success, or {ok: false, error: "..."} on failure. +## For properties the coercer doesn't have special logic for, falls back to target_type. +static func coerce_material_value(property: String, value: Variant, target_type: int) -> Dictionary: + # Enum-by-name: must match before generic TYPE_INT coercion. + if _ENUM_TABLES.has(property): + if value is String: + var enum_val = resolve_enum(property, value) + if enum_val == null: + return { + "ok": false, + "error": "Invalid %s value: '%s'. Valid: %s" % [ + property, value, ", ".join(_ENUM_TABLES[property].keys()) + ], + } + return {"ok": true, "value": int(enum_val)} + if value is int or value is float: + return {"ok": true, "value": int(value)} + + match target_type: + TYPE_COLOR: + var c = parse_color(value) + if c == null: + return {"ok": false, "error": "Invalid color for %s: %s" % [property, value]} + return {"ok": true, "value": c} + TYPE_VECTOR3: + var v3 = parse_vector3(value) + if v3 == null: + return {"ok": false, "error": "Invalid vector3 for %s: %s" % [property, value]} + return {"ok": true, "value": v3} + TYPE_VECTOR2: + var v2 = parse_vector2(value) + if v2 == null: + return {"ok": false, "error": "Invalid vector2 for %s: %s" % [property, value]} + return {"ok": true, "value": v2} + TYPE_BOOL: + if value is bool: + return {"ok": true, "value": value} + if value is int or value is float: + return {"ok": true, "value": bool(value)} + return {"ok": false, "error": "Expected bool for %s" % property} + TYPE_INT: + if value is int: + return {"ok": true, "value": value} + if value is float: + return {"ok": true, "value": int(value)} + return {"ok": false, "error": "Expected int for %s" % property} + TYPE_FLOAT: + if value is float: + return {"ok": true, "value": value} + if value is int: + return {"ok": true, "value": float(value)} + return {"ok": false, "error": "Expected number for %s" % property} + TYPE_OBJECT: + if value == null: + return {"ok": true, "value": null} + if value is Object: + return {"ok": true, "value": value} + if value is String: + var tex := load_texture(value) + if tex == null: + return {"ok": false, "error": "Resource not found or wrong type: %s" % value} + return {"ok": true, "value": tex} + return {"ok": false, "error": "Expected resource path (string) for %s" % property} + TYPE_STRING: + return {"ok": true, "value": String(value)} + + # Unknown target type — pass through. + return {"ok": true, "value": value} + + +## Serialize a Variant into JSON-friendly shape for responses. +static func serialize_value(value: Variant) -> Variant: + if value == null: + return null + if value is Color: + return {"r": value.r, "g": value.g, "b": value.b, "a": value.a} + if value is Vector3: + return {"x": value.x, "y": value.y, "z": value.z} + if value is Vector2: + return {"x": value.x, "y": value.y} + if value is Resource: + var path := (value as Resource).resource_path + if path.is_empty(): + return {"type": value.get_class(), "path": ""} + return {"type": value.get_class(), "path": path} + return value diff --git a/addons/godot_ai/handlers/material_values.gd.uid b/addons/godot_ai/handlers/material_values.gd.uid new file mode 100644 index 0000000..7f8bbe9 --- /dev/null +++ b/addons/godot_ai/handlers/material_values.gd.uid @@ -0,0 +1 @@ +uid://daqgjkflia8nk diff --git a/addons/godot_ai/handlers/node_handler.gd b/addons/godot_ai/handlers/node_handler.gd new file mode 100644 index 0000000..55dc662 --- /dev/null +++ b/addons/godot_ai/handlers/node_handler.gd @@ -0,0 +1,866 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") +const VariantSerializer := preload("res://addons/godot_ai/utils/variant_serializer.gd") + +## Handles node creation and manipulation with undo/redo support. + +const ResourceHandler := preload("res://addons/godot_ai/handlers/resource_handler.gd") + +var _undo_redo: EditorUndoRedoManager + + +func _init(undo_redo: EditorUndoRedoManager) -> void: + _undo_redo = undo_redo + + +func create_node(params: Dictionary) -> Dictionary: + var node_type: String = params.get("type", "") + var node_name: String = params.get("name", "") + var parent_path: String = params.get("parent_path", "") + var scene_path: String = params.get("scene_path", "") + + var scene_check := McpScenePath.require_edited_scene(params.get("scene_file", "")) + if scene_check.has("error"): + return scene_check + var scene_root: Node = scene_check.node + + var parent: Node = scene_root + if not parent_path.is_empty(): + parent = McpScenePath.resolve(parent_path, scene_root) + if parent == null: + return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root)) + + var new_node: Node + + if not scene_path.is_empty(): + # Scene instancing path — load and instantiate a PackedScene. + # GEN_EDIT_STATE_INSTANCE makes the editor treat the result as a real + # scene instance (foldout icon, the .tscn stores a reference instead of + # an exploded subtree). Descendants remain owned by their sub-scene; + # setting their owner to our scene_root would break the instance link. + var scene_path_err = McpPathValidator.loadable_error(scene_path, "scene_path") + if scene_path_err != null: + return scene_path_err + if not ResourceLoader.exists(scene_path): + return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Scene not found: %s" % scene_path) + var packed_scene = ResourceLoader.load(scene_path) + if packed_scene == null or not packed_scene is PackedScene: + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a PackedScene" % scene_path) + new_node = packed_scene.instantiate(PackedScene.GEN_EDIT_STATE_INSTANCE) + if new_node == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate scene: %s" % scene_path) + else: + # ClassDB path — create by type. + if node_type.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: type (or provide scene_path)") + if not ClassDB.class_exists(node_type): + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown node type: %s" % node_type) + if not ClassDB.is_parent_class(node_type, "Node"): + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Node type" % node_type) + new_node = ClassDB.instantiate(node_type) + if new_node == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % node_type) + + if not node_name.is_empty(): + new_node.name = node_name + + _undo_redo.create_action("MCP: Create %s" % new_node.name) + _undo_redo.add_do_method(parent, "add_child", new_node, true) + _undo_redo.add_do_method(new_node, "set_owner", scene_root) + _undo_redo.add_do_reference(new_node) + _undo_redo.add_undo_method(parent, "remove_child", new_node) + _undo_redo.commit_action() + + var response := { + "name": new_node.name, + "type": new_node.get_class(), + "path": McpScenePath.from_node(new_node, scene_root), + "parent_path": McpScenePath.from_node(parent, scene_root), + "undoable": true, + } + if not scene_path.is_empty(): + response["scene_path"] = scene_path + return {"data": response} + + +func delete_node(params: Dictionary) -> Dictionary: + var resolved := _resolve_node(params) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + var node_path: String = resolved.path + var scene_root: Node = resolved.scene_root + + var root_err := _reject_if_scene_root(node, scene_root, "delete") + if root_err != null: + return root_err + + var parent := node.get_parent() + var idx := node.get_index() + + _undo_redo.create_action("MCP: Delete %s" % node.name) + _undo_redo.add_do_method(parent, "remove_child", node) + _undo_redo.add_undo_method(parent, "add_child", node, true) + _undo_redo.add_undo_method(parent, "move_child", node, idx) + _undo_redo.add_undo_method(node, "set_owner", scene_root) + _undo_redo.add_undo_reference(node) + _undo_redo.commit_action() + + return { + "data": { + "path": node_path, + "undoable": true, + } + } + + +func reparent_node(params: Dictionary) -> Dictionary: + var resolved := _resolve_node(params) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + var node_path: String = resolved.path + var scene_root: Node = resolved.scene_root + + var new_parent_path: String = params.get("new_parent", "") + if new_parent_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: new_parent") + + var new_parent := McpScenePath.resolve(new_parent_path, scene_root) + if new_parent == null: + return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(new_parent_path, scene_root)) + + var root_err := _reject_if_scene_root(node, scene_root, "reparent") + if root_err != null: + return root_err + + # Prevent reparenting a node to itself or to one of its own descendants. + # Godot's `A.is_ancestor_of(B)` returns true iff B is a descendant of A, so + # the direction here matters: we want `node.is_ancestor_of(new_parent)` to + # catch "new_parent is below node in the tree" and thus would create a + # cycle. The previous direction (`new_parent.is_ancestor_of(node)`) asked + # the opposite question — whether we were trying to move a node to one of + # its own ancestors — which is a perfectly valid operation. See issue #121. + if node == new_parent or node.is_ancestor_of(new_parent): + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Cannot reparent a node to itself or its descendant") + + var old_parent := node.get_parent() + var old_idx := node.get_index() + + _undo_redo.create_action("MCP: Reparent %s" % node.name) + _undo_redo.add_do_method(old_parent, "remove_child", node) + _undo_redo.add_do_method(new_parent, "add_child", node, true) + _undo_redo.add_do_method(node, "set_owner", scene_root) + _undo_redo.add_do_reference(node) + _undo_redo.add_undo_method(new_parent, "remove_child", node) + _undo_redo.add_undo_method(old_parent, "add_child", node, true) + _undo_redo.add_undo_method(old_parent, "move_child", node, old_idx) + _undo_redo.add_undo_method(node, "set_owner", scene_root) + _undo_redo.add_undo_reference(node) + _undo_redo.commit_action() + + # Re-set owner for all descendants (reparent can break ownership chain) + _set_owner_recursive(node, scene_root) + + return { + "data": { + "path": McpScenePath.from_node(node, scene_root), + "old_parent": McpScenePath.from_node(old_parent, scene_root), + "new_parent": McpScenePath.from_node(new_parent, scene_root), + "undoable": true, + } + } + + +func set_property(params: Dictionary) -> Dictionary: + var resolved := _resolve_node(params) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + var node_path: String = resolved.path + var scene_root: Node = resolved.scene_root + + var property: String = params.get("property", "") + if property.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: property") + + if not "value" in params: + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: value") + + var value = params.get("value") + + var found := false + var prop_type: int = TYPE_NIL + for prop in node.get_property_list(): + if prop.name == property: + found = true + prop_type = prop.get("type", TYPE_NIL) + break + if not found: + return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, McpPropertyErrors.build_message(node, property)) + + var old_value = node.get(property) + # Prefer declared property type; fall back to runtime type for dynamic props + # (scripted @export vars can report TYPE_NIL in the property list). + var target_type: int = prop_type if prop_type != TYPE_NIL else typeof(old_value) + + var instantiated_resource := false + + # Some MCP clients (Cline) stringify the documented {"__class__": "BoxMesh", ...} + # value before sending. Promote that string back to a Dictionary here so the + # `__class__` branch below handles it, instead of the next branch treating + # the JSON blob as a res:// path and emitting "Resource not found: {...}". + # See #206. + if target_type == TYPE_OBJECT and value is String and value.begins_with("{"): + var json := JSON.new() + if json.parse(value) == OK and json.data is Dictionary and (json.data as Dictionary).has("__class__"): + value = json.data + + var nil_resource_string: bool = target_type == TYPE_NIL and (value == "" or (value is String and value.begins_with("res://"))) + var resource_string_value: bool = value is String and (target_type == TYPE_OBJECT or nil_resource_string) + if resource_string_value: + if value == "": + value = null + else: + var value_path_err = McpPathValidator.loadable_error(value, "value") + if value_path_err != null: + return value_path_err + if not ResourceLoader.exists(value): + return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % value) + var loaded := ResourceLoader.load(value) + if loaded == null: + return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % value) + value = loaded + elif target_type == TYPE_OBJECT and value is Dictionary and value.has("__class__"): + # Shortcut: {"__class__": "BoxMesh", "size": {...}} instantiates a + # fresh Resource subclass and applies the remaining keys as + # properties. Mirrors resource_create's inline-assign path but + # avoids a separate tool call for the common case. + var type_str: String = value.get("__class__", "") + var class_err := ResourceHandler._validate_resource_class(type_str) + if class_err != null: + return class_err + var instance := ClassDB.instantiate(type_str) + if instance == null or not (instance is Resource): + return ErrorCodes.make( + ErrorCodes.INTERNAL_ERROR, + "Failed to instantiate %s as a Resource" % type_str + ) + var res: Resource = instance + 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 + else: + value = _coerce_value(value, target_type) + ## Refuse any value that didn't land as the target compound Variant + ## — wrong-shape dict (#123) or non-dict input like list / JSON string + ## that used to silently default-construct Vector3.ZERO (#191). + var coerce_err := _check_coerced(value, target_type) + if coerce_err != null: + return coerce_err + + _undo_redo.create_action("MCP: Set %s.%s" % [node.name, property]) + _undo_redo.add_do_property(node, property, value) + _undo_redo.add_undo_property(node, property, old_value) + if instantiated_resource: + _undo_redo.add_do_reference(value) + _undo_redo.commit_action() + + return { + "data": { + "path": node_path, + "property": property, + "value": _serialize_value(node.get(property)), + "old_value": _serialize_value(old_value), + "undoable": true, + } + } + + +func rename_node(params: Dictionary) -> Dictionary: + var resolved := _resolve_node(params) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + var node_path: String = resolved.path + var scene_root: Node = resolved.scene_root + + var new_name: String = params.get("new_name", "") + if new_name.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: new_name") + + ## The scene root's name is baked into the .tscn serialization and is + ## referenced by every NodePath that starts with `/` (AnimationPlayer + ## tracks, RemoteTransform3D targets, exported NodePath @vars, etc.). + ## Renaming it silently breaks those references. The MCP tool's docstring + ## has always promised "Cannot rename the scene root" — enforce it. #122 + if node == scene_root: + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Cannot rename the scene root") + + if new_name.validate_node_name() != new_name: + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid characters in name: %s" % new_name) + + var old_name := String(node.name) + if old_name == new_name: + return { + "data": { + "path": node_path, + "name": new_name, + "old_name": old_name, + "unchanged": true, + "undoable": false, + "reason": "Name unchanged", + } + } + + var parent := node.get_parent() + for sibling in parent.get_children(): + if sibling != node and String(sibling.name) == new_name: + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "A sibling already has the name '%s'" % new_name) + + _undo_redo.create_action("MCP: Rename %s to %s" % [old_name, new_name]) + _undo_redo.add_do_property(node, "name", new_name) + _undo_redo.add_undo_property(node, "name", old_name) + _undo_redo.commit_action() + + return { + "data": { + "path": McpScenePath.from_node(node, scene_root), + "old_path": node_path, + "name": String(node.name), + "old_name": old_name, + "undoable": true, + } + } + + +func duplicate_node(params: Dictionary) -> Dictionary: + var resolved := _resolve_node(params) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + var node_path: String = resolved.path + var scene_root: Node = resolved.scene_root + + var root_err := _reject_if_scene_root(node, scene_root, "duplicate") + if root_err != null: + return root_err + + var parent := node.get_parent() + var dup: Node = node.duplicate() + if dup == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to duplicate node") + + # Apply optional name + var new_name: String = params.get("name", "") + if not new_name.is_empty(): + dup.name = new_name + + _undo_redo.create_action("MCP: Duplicate %s" % node.name) + _undo_redo.add_do_method(parent, "add_child", dup, true) + _undo_redo.add_do_method(dup, "set_owner", scene_root) + _undo_redo.add_do_reference(dup) + _undo_redo.add_undo_method(parent, "remove_child", dup) + _undo_redo.commit_action() + + # Set owner for all descendants of the duplicate + _set_owner_recursive(dup, scene_root) + + return { + "data": { + "path": McpScenePath.from_node(dup, scene_root), + "original_path": node_path, + "name": dup.name, + "type": dup.get_class(), + "undoable": true, + } + } + + +func move_node(params: Dictionary) -> Dictionary: + var resolved := _resolve_node(params) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + var node_path: String = resolved.path + var scene_root: Node = resolved.scene_root + + var root_err := _reject_if_scene_root(node, scene_root, "reorder") + if root_err != null: + return root_err + + if not "index" in params: + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: index") + + var new_index: int = params.get("index", 0) + var parent := node.get_parent() + var old_index := node.get_index() + var sibling_count := parent.get_child_count() + + if new_index < 0 or new_index >= sibling_count: + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Index %d out of range (0..%d)" % [new_index, sibling_count - 1]) + + _undo_redo.create_action("MCP: Move %s to index %d" % [node.name, new_index]) + _undo_redo.add_do_method(parent, "move_child", node, new_index) + _undo_redo.add_undo_method(parent, "move_child", node, old_index) + _undo_redo.commit_action() + + return { + "data": { + "path": node_path, + "old_index": old_index, + "new_index": new_index, + "undoable": true, + } + } + + +func add_to_group(params: Dictionary) -> Dictionary: + var resolved := _resolve_node(params) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + var node_path: String = resolved.path + + var group_value: Variant = params.get("group", "") + var type_err := McpParamValidators.require_string("group", group_value) + if type_err != null: + return type_err + var group := String(group_value) + if group.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: group") + + if node.is_in_group(group): + return {"data": {"path": node_path, "group": group, "already_member": true, "undoable": false, "reason": "No change made"}} + + _undo_redo.create_action("MCP: Add %s to group %s" % [node.name, group]) + _undo_redo.add_do_method(node, "add_to_group", group, true) + _undo_redo.add_undo_method(node, "remove_from_group", group) + _undo_redo.commit_action() + + return { + "data": { + "path": node_path, + "group": group, + "undoable": true, + } + } + + +func remove_from_group(params: Dictionary) -> Dictionary: + var resolved := _resolve_node(params) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + var node_path: String = resolved.path + + var group_value: Variant = params.get("group", "") + var type_err := McpParamValidators.require_string("group", group_value) + if type_err != null: + return type_err + var group := String(group_value) + if group.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: group") + + if not node.is_in_group(group): + return {"data": {"path": node_path, "group": group, "not_member": true, "undoable": false, "reason": "Node not in group"}} + + _undo_redo.create_action("MCP: Remove %s from group %s" % [node.name, group]) + _undo_redo.add_do_method(node, "remove_from_group", group) + _undo_redo.add_undo_method(node, "add_to_group", group, true) + _undo_redo.commit_action() + + return { + "data": { + "path": node_path, + "group": group, + "undoable": true, + } + } + + +func set_selection(params: Dictionary) -> Dictionary: + var paths: Array = params.get("paths", []) + var _scene_check := McpNodeValidator.require_scene_or_error() + if _scene_check.has("error"): + return _scene_check + var scene_root: Node = _scene_check.scene_root + + var selection := EditorInterface.get_selection() + selection.clear() + + var selected: Array[String] = [] + var not_found: Array[String] = [] + for path_variant in paths: + var path: String = str(path_variant) + var node := McpScenePath.resolve(path, scene_root) + if node: + selection.add_node(node) + selected.append(path) + else: + not_found.append(path) + + return { + "data": { + "selected": selected, + "not_found": not_found, + "count": selected.size(), + "undoable": false, + "reason": "Selection changes are not tracked in undo history", + } + } + + +func _set_owner_recursive(node: Node, owner: Node) -> void: + for child in node.get_children(): + child.set_owner(owner) + _set_owner_recursive(child, owner) + + +## Canonical dict-key sets for dict→Variant coercion. Alpha on `COLOR_KEYS` +## is optional — the coercer defaults it to 1.0 when absent. +const VECTOR2_KEYS: Array[String] = ["x", "y"] +const VECTOR3_KEYS: Array[String] = ["x", "y", "z"] +const 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_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 + _: + return null + 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_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 "[\"...\", ...]" + 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: + TYPE_VECTOR2: + if value is Dictionary and value.has_all(VECTOR2_KEYS): + return Vector2(value["x"], value["y"]) + TYPE_VECTOR3: + if value is Dictionary and value.has_all(VECTOR3_KEYS): + return Vector3(value["x"], value["y"], value["z"]) + TYPE_COLOR: + if value is Dictionary and value.has_all(COLOR_KEYS): + return Color(value["r"], value["g"], value["b"], value.get("a", 1.0)) + if value is String: + return Color(value) + TYPE_BOOL: + if value is float or value is int: + return bool(value) + TYPE_INT: + if value is float: + return int(value) + TYPE_FLOAT: + if value is int: + return float(value) + TYPE_STRING_NAME: + if value is String: + return StringName(value) + TYPE_NODE_PATH: + if value is String: + return NodePath(value) + if value == null: + return NodePath() + TYPE_OBJECT: + # Resource loading is handled in set_property so we can return a + # typed error; here we only pass through cleared values. + if value == null: + return null + TYPE_ARRAY: + if value is Array: + return value + TYPE_DICTIONARY: + if value is Dictionary: + return value + TYPE_PACKED_VECTOR2_ARRAY: + if value is Array: + var out := PackedVector2Array() + for item in value: + if item is Vector2: + out.append(item) + elif item is Dictionary and item.has_all(VECTOR2_KEYS): + out.append(Vector2(item["x"], item["y"])) + else: + return value # leave for _check_coerced to flag + return out + TYPE_PACKED_VECTOR3_ARRAY: + if value is Array: + var out := PackedVector3Array() + for item in value: + if item is Vector3: + out.append(item) + elif item is Dictionary and item.has_all(VECTOR3_KEYS): + out.append(Vector3(item["x"], item["y"], item["z"])) + else: + return value + return out + TYPE_PACKED_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 + # PackedByteArray intentionally unhandled — needs design decision + # (base64 string vs. raw int list); JSON has no native byte type. + return value + + +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 + + var properties: Array[Dictionary] = [] + for prop in node.get_property_list(): + var usage: int = prop.get("usage", 0) + if not (usage & PROPERTY_USAGE_EDITOR): + continue + # Safe read: custom script getters can error; skip bad properties + # rather than letting one bad read timeout the entire request. + var value = node.get(prop.name) + if value == null and prop.type != TYPE_NIL: + continue + properties.append({ + "name": prop.name, + "type": type_string(prop.type), + "value": _serialize_value(value), + }) + return { + "data": { + "path": node_path, + "node_type": node.get_class(), + "properties": properties, + "count": properties.size(), + } + } + + +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..323a624 --- /dev/null +++ b/addons/godot_ai/handlers/particle_handler.gd @@ -0,0 +1,761 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Handles particle emitter authoring (GPU + CPU, 2D + 3D). +## +## All write operations bundle node creation and sub-resource spawns +## (ParticleProcessMaterial, default QuadMesh) in a single create_action +## so Ctrl-Z rolls back the whole effect atomically. + +const ParticleValues := preload("res://addons/godot_ai/handlers/particle_values.gd") +const ParticlePresets := preload("res://addons/godot_ai/handlers/particle_presets.gd") + +const _VALID_TYPES := { + "gpu_3d": "GPUParticles3D", + "gpu_2d": "GPUParticles2D", + "cpu_3d": "CPUParticles3D", + "cpu_2d": "CPUParticles2D", +} + +const _MAIN_KEYS := [ + "amount", + "lifetime", + "one_shot", + "explosiveness", + "preprocess", + "speed_scale", + "randomness", + "fixed_fps", + "emitting", + "local_coords", + "interp_to_end", +] + + +var _undo_redo: EditorUndoRedoManager + + +func _init(undo_redo: EditorUndoRedoManager) -> void: + _undo_redo = undo_redo + + +# ============================================================================ +# particle_create +# ============================================================================ + +func create_particle(params: Dictionary) -> Dictionary: + var parent_path: String = params.get("parent_path", "") + var node_name: String = params.get("name", "Particles") + var type_str: String = params.get("type", "gpu_3d") + + if not _VALID_TYPES.has(type_str): + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "Invalid particle type '%s'. Valid: %s" % [type_str, ", ".join(_VALID_TYPES.keys())] + ) + + var _scene_check := McpNodeValidator.require_scene_or_error() + if _scene_check.has("error"): + return _scene_check + var scene_root: Node = _scene_check.scene_root + + var parent: Node = scene_root + if not parent_path.is_empty(): + parent = McpScenePath.resolve(parent_path, scene_root) + if parent == null: + return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root)) + + var node := _instantiate_particle(type_str) + if node == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate particle node") + if not node_name.is_empty(): + node.name = node_name + + var process_mat: ParticleProcessMaterial = null + var process_material_created := false + var draw_mesh: Mesh = null + var draw_material: StandardMaterial3D = null + var draw_pass_mesh_created := false + var draw_material_created := false + + if type_str == "gpu_3d" or type_str == "gpu_2d": + process_mat = ParticleProcessMaterial.new() + process_material_created = true + if type_str == "gpu_3d": + draw_mesh = QuadMesh.new() + (draw_mesh as QuadMesh).size = Vector2(0.25, 0.25) + # Without a material, the mesh renders flat white — ignoring + # ParticleProcessMaterial.color_ramp entirely. Give it the standard + # billboard + vertex-color-as-albedo setup so color_ramp works. + draw_material = ParticleValues.build_draw_material({}) + (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 + + 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": + draw_mesh = QuadMesh.new() + (draw_mesh as QuadMesh).size = Vector2(0.25, 0.25) + var draw_config: Dictionary = blueprint.get("draw", {}) + draw_material = ParticleValues.build_draw_material(draw_config) + (draw_mesh as QuadMesh).material = draw_material + draw_pass_mesh_created = true + draw_material_created = true + + # 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: + continue # Silently skip: 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: + 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: + continue # Silently skip: 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: + 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, + "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 +# ============================================================================ + +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..6948f2c --- /dev/null +++ b/addons/godot_ai/handlers/particle_presets.gd @@ -0,0 +1,282 @@ +@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. +## Overrides may include top-level "main" / "process" / "draw" dicts, or bare +## keys that get routed based on which group they belong to. +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", {}) + for key in overrides: + var val = overrides[key] + if key == "main" and val is Dictionary: + for k in val: + main[k] = val[k] + elif key == "process" and val is Dictionary: + for k in val: + process[k] = val[k] + elif key == "draw" and val is Dictionary: + for k in val: + draw[k] = val[k] + elif _MAIN_KEYS.has(key): + main[key] = val + else: + process[key] = val + entry["main"] = main + entry["process"] = process + entry["draw"] = draw + 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..9099a81 --- /dev/null +++ b/addons/godot_ai/handlers/particle_values.gd @@ -0,0 +1,228 @@ +@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()) +static func build_draw_material(config: Dictionary) -> StandardMaterial3D: + 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. + for key in config: + var prop_name := String(key) + var prop_type := _object_property_type(mat, prop_name) + if prop_type == TYPE_NIL: + continue + var coerce_result := MaterialValues.coerce_material_value( + prop_name, config[prop_name], prop_type + ) + if coerce_result.ok: + mat.set(prop_name, coerce_result.value) + return mat + + +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..f7f7785 --- /dev/null +++ b/addons/godot_ai/handlers/physics_shape_handler.gd @@ -0,0 +1,337 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Sizes a CollisionShape2D/CollisionShape3D to match a visual sibling's +## bounds. Auto-creates the concrete Shape subclass when the slot is empty +## or the requested type differs — bundling creation and sizing in a single +## undo action. +## +## Shape type defaults: Box for 3D, Rectangle for 2D. + +var _undo_redo: EditorUndoRedoManager + + +func _init(undo_redo: EditorUndoRedoManager) -> void: + _undo_redo = undo_redo + + +const _SHAPE_3D_CLASSES := { + "box": "BoxShape3D", + "sphere": "SphereShape3D", + "capsule": "CapsuleShape3D", + "cylinder": "CylinderShape3D", +} + +const _SHAPE_2D_CLASSES := { + "rectangle": "RectangleShape2D", + "circle": "CircleShape2D", + "capsule": "CapsuleShape2D", +} + + +func autofit(params: Dictionary) -> Dictionary: + var node_path: String = params.get("path", "") + if node_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path") + + var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path") + if _resolved.has("error"): + return _resolved + var node: Node = _resolved.node + var scene_root: Node = _resolved.scene_root + + var is_3d := node is CollisionShape3D + var is_2d := node is CollisionShape2D + if not (is_3d or is_2d): + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Node at %s is %s — must be CollisionShape3D or CollisionShape2D" % [node_path, node.get_class()] + ) + + var source_path: String = params.get("source_path", "") + var source: Node = null + if source_path.is_empty(): + var search := _find_bounds_visual(node, is_3d, scene_root) + if search.has("error"): + return search.error + source = search.source + else: + source = McpScenePath.resolve(source_path, scene_root) + if source == null: + return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, "Source node not found: %s" % source_path) + + var shape_type: String = params.get("shape_type", "box" if is_3d else "rectangle") + var type_map := _SHAPE_3D_CLASSES if is_3d else _SHAPE_2D_CLASSES + # Accept either the short form ("box") or the matching Godot class name + # ("BoxShape3D") — every other tool in the server takes class names, and + # resource_get_info(type="Shape3D") surfaces concrete_subclasses by class. + if not type_map.has(shape_type): + for short_form in type_map: + if type_map[short_form] == shape_type: + shape_type = short_form + break + if not type_map.has(shape_type): + var valid_pairs: Array[String] = [] + for short_form in type_map: + valid_pairs.append("%s (%s)" % [short_form, type_map[short_form]]) + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "Invalid shape_type '%s' for %s. Valid: %s" % [shape_type, node.get_class(), ", ".join(valid_pairs)] + ) + var shape_class: String = type_map[shape_type] + + # Measure the visual. + var bounds := _measure_bounds(source, is_3d) + if bounds.has("error"): + return bounds.error + + # Reuse the existing shape if it already matches the requested class; + # otherwise create a fresh one of the right type in the same undo action. + var existing_shape: Shape3D = null + var existing_shape_2d: Shape2D = null + if is_3d: + existing_shape = node.shape + else: + existing_shape_2d = node.shape + + var needs_new_shape := false + if is_3d: + needs_new_shape = existing_shape == null or existing_shape.get_class() != shape_class + else: + needs_new_shape = existing_shape_2d == null or existing_shape_2d.get_class() != shape_class + + var target_shape: Resource + if needs_new_shape: + var instance := ClassDB.instantiate(shape_class) + if instance == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % shape_class) + target_shape = instance + else: + target_shape = existing_shape if is_3d else existing_shape_2d + + # Compute and apply size. + var size_info := _apply_shape_size(target_shape, shape_type, bounds, is_3d) + var old_shape = existing_shape if is_3d else existing_shape_2d + + _undo_redo.create_action("MCP: Autofit %s on %s" % [shape_class, node.name]) + if needs_new_shape: + _undo_redo.add_do_property(node, "shape", target_shape) + _undo_redo.add_undo_property(node, "shape", old_shape) + _undo_redo.add_do_reference(target_shape) + else: + # Existing shape stays, but its size changes — snapshot size for undo. + for key in size_info.applied: + var new_val = target_shape.get(key) + var old_val = size_info.previous.get(key) + _undo_redo.add_do_property(target_shape, key, new_val) + _undo_redo.add_undo_property(target_shape, key, old_val) + _undo_redo.commit_action() + + return { + "data": { + "path": node_path, + "source_path": McpScenePath.from_node(source, scene_root) if source_path.is_empty() else source_path, + "shape_type": shape_type, + "shape_class": shape_class, + "shape_created": needs_new_shape, + "size": size_info.size_response, + "undoable": true, + } + } + + +## Returns `{source: Node}` on success, `{error: }` on failure. +## Ambiguous tier-2 matches put candidate scene paths in +## `error.data.candidates` so callers can pick one explicitly. +static func _find_bounds_visual(collision_node: Node, is_3d: bool, scene_root: Node) -> Dictionary: + var parent := collision_node.get_parent() + if parent == null: + return {"error": _no_visual_error(is_3d)} + + # Tier 1: direct siblings of the collision shape. Uses the broad + # VisualInstance3D filter for backwards compatibility — callers who put + # the visual directly next to the collision picked it on purpose. + var siblings := _measurable_visuals(parent.get_children(), collision_node, is_3d, false) + if not siblings.is_empty(): + return {"source": siblings[0]} + + # Tier 2: parent siblings (uncles). Tighten the filter to + # GeometryInstance3D so we don't auto-pick a Light3D / DirectionalLight3D + # as a collision source. Auto-pick only when unambiguous; surface + # multiple candidates so the agent chooses. + var grandparent := parent.get_parent() + if grandparent == null: + return {"error": _no_visual_error(is_3d)} + var uncles := _measurable_visuals(grandparent.get_children(), parent, is_3d, true) + if uncles.size() == 1: + return {"source": uncles[0]} + if uncles.size() > 1: + var paths: Array[String] = [] + for n in uncles: + paths.append(McpScenePath.from_node(n, scene_root)) + var msg := "Multiple visual candidates near %s — pass source_path explicitly. Candidates: %s" % [ + McpScenePath.from_node(collision_node, scene_root), + ", ".join(paths), + ] + var err := ErrorCodes.make(ErrorCodes.INVALID_PARAMS, msg) + err["error"]["data"] = {"candidates": paths} + return {"error": err} + return {"error": _no_visual_error(is_3d)} + + +## Filter `nodes` for ones we can measure as a collision source. When +## `strict` is true (tier 2 / uncles) only GeometryInstance3D counts in 3D — +## avoids picking up lights as accidental sources. 2D filter is already +## narrow enough that strictness doesn't change behavior. +static func _measurable_visuals(nodes: Array, exclude: Node, is_3d: bool, strict: bool) -> Array[Node]: + var out: Array[Node] = [] + for n in nodes: + if n == exclude: + continue + if is_3d: + if strict: + if n is GeometryInstance3D: + out.append(n) + elif n is VisualInstance3D: + out.append(n) + elif n is Sprite2D or n is TextureRect: + out.append(n) + return out + + +static func _no_visual_error(is_3d: bool) -> Dictionary: + var hint := "MeshInstance3D" if is_3d else "Sprite2D" + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "No visual found near collision shape — searched siblings and parent-siblings. Pass source_path explicitly (e.g. a %s)" % hint, + ) + + +## Measure the visual bounds of `source`. Returns {aabb: AABB} for 3D or +## {rect: Rect2} for 2D on success, or {error: ...} on failure. +## Bounds are returned in world-ish size (local extents scaled by the source +## node's own transform scale) so a MeshInstance3D at scale=(2,2,2) gives an +## 8× volume collider, not a unit collider. +static func _measure_bounds(source: Node, is_3d: bool) -> Dictionary: + if is_3d: + if source is VisualInstance3D: + var aabb: AABB = (source as VisualInstance3D).get_aabb() + # get_aabb() is local-space; pre-multiply by the source's scale + # so the collider tracks what you actually see in the viewport. + var scale_3d: Vector3 = (source as Node3D).transform.basis.get_scale() + aabb.position = aabb.position * scale_3d + aabb.size = aabb.size * scale_3d + return {"aabb": aabb} + return {"error": ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Source %s has no measurable 3D bounds (must be VisualInstance3D subclass)" % source.get_class() + )} + # 2D + if source is Sprite2D: + var s: Sprite2D = source + var srect: Rect2 = s.get_rect() + # get_rect() reports the local texture rect and ignores scale. + srect.position = srect.position * s.scale + srect.size = srect.size * s.scale + return {"rect": srect} + if source is TextureRect: + var tr: TextureRect = source + # tr.size is the Control's laid-out size, which is Vector2.ZERO + # before the first layout pass (e.g. just after the node was created + # via MCP). Fall back to the texture's own size when that happens, + # so autofit doesn't silently produce a zero-sized shape. + var tr_size: Vector2 = tr.size + if tr_size.is_zero_approx(): + if tr.texture != null: + tr_size = tr.texture.get_size() * tr.scale + else: + return {"error": ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "TextureRect at %s has zero layout size and no texture to fall back to — autofit would produce a zero-sized shape" % source.name + )} + return {"rect": Rect2(Vector2.ZERO, tr_size)} + return {"error": ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Source %s has no measurable 2D bounds (must be Sprite2D or TextureRect)" % source.get_class() + )} + + +## Apply size to `shape` based on `bounds` and the requested shape_type. +## Returns {applied: [property_names], previous: {name: old_value}, size_response: dict}. +static func _apply_shape_size(shape: Resource, shape_type: String, bounds: Dictionary, is_3d: bool) -> Dictionary: + var applied: Array[String] = [] + var previous := {} + var size_response := {} + + if is_3d: + var aabb: AABB = bounds.aabb + var size_v: Vector3 = aabb.size + match shape_type: + "box": + previous["size"] = shape.get("size") + (shape as BoxShape3D).size = size_v + applied.append("size") + size_response = {"x": size_v.x, "y": size_v.y, "z": size_v.z} + "sphere": + var r := maxf(maxf(size_v.x, size_v.y), size_v.z) * 0.5 + previous["radius"] = shape.get("radius") + (shape as SphereShape3D).radius = r + applied.append("radius") + size_response = {"radius": r} + "capsule": + var cap := shape as CapsuleShape3D + var r2 := maxf(size_v.x, size_v.z) * 0.5 + var h := size_v.y + previous["radius"] = cap.radius + previous["height"] = cap.height + # CapsuleShape3D enforces height >= 2*radius and silently + # clamps setters that would violate it. Read back the + # stored values so the response reflects reality. + cap.radius = r2 + cap.height = h + applied.append("radius") + applied.append("height") + size_response = {"radius": cap.radius, "height": cap.height} + "cylinder": + var cyl := shape as CylinderShape3D + var r3 := maxf(size_v.x, size_v.z) * 0.5 + var ch := size_v.y + previous["radius"] = cyl.radius + previous["height"] = cyl.height + cyl.radius = r3 + cyl.height = ch + applied.append("radius") + applied.append("height") + size_response = {"radius": cyl.radius, "height": cyl.height} + else: + var rect: Rect2 = bounds.rect + var sz: Vector2 = rect.size + match shape_type: + "rectangle": + previous["size"] = shape.get("size") + (shape as RectangleShape2D).size = sz + applied.append("size") + size_response = {"x": sz.x, "y": sz.y} + "circle": + var cr := maxf(sz.x, sz.y) * 0.5 + previous["radius"] = shape.get("radius") + (shape as CircleShape2D).radius = cr + applied.append("radius") + size_response = {"radius": cr} + "capsule": + var cap2 := shape as CapsuleShape2D + var cr2 := sz.x * 0.5 + var ch2 := sz.y + previous["radius"] = cap2.radius + previous["height"] = cap2.height + # CapsuleShape2D has the same height >= 2*radius invariant + # as its 3D counterpart; read back what Godot actually kept. + cap2.radius = cr2 + cap2.height = ch2 + applied.append("radius") + applied.append("height") + size_response = {"radius": cap2.radius, "height": cap2.height} + + return {"applied": applied, "previous": previous, "size_response": size_response} diff --git a/addons/godot_ai/handlers/physics_shape_handler.gd.uid b/addons/godot_ai/handlers/physics_shape_handler.gd.uid new file mode 100644 index 0000000..09acb4a --- /dev/null +++ b/addons/godot_ai/handlers/physics_shape_handler.gd.uid @@ -0,0 +1 @@ +uid://cdg8kthqla1cj diff --git a/addons/godot_ai/handlers/project_handler.gd b/addons/godot_ai/handlers/project_handler.gd new file mode 100644 index 0000000..9511bac --- /dev/null +++ b/addons/godot_ai/handlers/project_handler.gd @@ -0,0 +1,262 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Handles project settings and filesystem search commands. + +const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd") + +var _connection: McpConnection +var _debugger_plugin + + +func _init(connection: McpConnection = null, debugger_plugin = null) -> void: + _connection = connection + _debugger_plugin = debugger_plugin + + +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") + + var value = params.get("value") + var had_setting := ProjectSettings.has_setting(key) + var old_value = ProjectSettings.get_setting(key) if had_setting else null + # JSON has no distinct int type: Godot parses `1920` as float. If the + # existing setting is TYPE_INT, coerce whole-number floats back to int so + # we don't silently flip typed-int settings (viewport_width, etc.) to + # floats on disk. See issue #31. + if had_setting and typeof(old_value) == TYPE_INT and typeof(value) == TYPE_FLOAT and float(int(value)) == value: + value = int(value) + ProjectSettings.set_setting(key, value) + var err := ProjectSettings.save() + if err != OK: + if had_setting: + ProjectSettings.set_setting(key, old_value) + else: + ProjectSettings.clear(key) + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to save project settings (error %d)" % err) + + return { + "data": { + "key": key, + "value": NodeHandler._serialize_value(value), + "old_value": NodeHandler._serialize_value(old_value), + "type": type_string(typeof(value)), + "undoable": false, + "reason": "ProjectSettings changes are saved to disk", + } + } + + +func run_project(params: Dictionary) -> Dictionary: + var mode: String = params.get("mode", "main") + var autosave: bool = params.get("autosave", true) + # Idempotent: a project that's already running satisfies the caller's intent. + # Returning INVALID_PARAMS here punished agents that legitimately called run + # to ensure the project is playing (87+ installs/day hit the matching + # stop-not-running case in telemetry). Surface state via was_already_running + # so a caller wanting a *different* scene can detect and stop+restart. + if EditorInterface.is_playing_scene(): + return { + "data": { + "mode": mode, + "scene": params.get("scene", ""), + "autosave": autosave, + "was_already_running": true, + "undoable": false, + "reason": "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')") + 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() + + 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 + + return { + "data": { + "mode": mode, + "scene": params.get("scene", ""), + "autosave": autosave, + "was_already_running": false, + "undoable": false, + "reason": "Play/stop is a runtime action", + } + } + + +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) + 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", + } + } + + +func _finish_stop_project_deferred(request_id: String) -> void: + # 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. + var tree := _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(ErrorCodes.EDITOR_NOT_READY, "EditorFileSystem not available") + + 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..4cb8253 --- /dev/null +++ b/addons/godot_ai/handlers/resource_handler.gd @@ -0,0 +1,398 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") +const ClassIntrospection := preload("res://addons/godot_ai/utils/class_introspection.gd") + +## Handles resource search, inspection, and assignment to nodes. + +const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd") + +var _undo_redo: EditorUndoRedoManager +var _connection: McpConnection + + +func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void: + _undo_redo = undo_redo + _connection = connection + + +func search_resources(params: Dictionary) -> Dictionary: + var type_filter: String = params.get("type", "") + var path_filter: String = params.get("path", "") + + if type_filter.is_empty() and path_filter.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "At least one filter (type, path) is required") + + var efs := EditorInterface.get_resource_filesystem() + if efs == null: + return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "EditorFileSystem not available") + + 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 class_err := _validate_resource_class(type_str) + if class_err != null: + return class_err + + var instance := ClassDB.instantiate(type_str) + if instance == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % type_str) + if not (instance is Resource): + return ErrorCodes.make( + ErrorCodes.INTERNAL_ERROR, + "Instantiated %s but result is not a Resource (got %s)" % [type_str, instance.get_class()] + ) + var res: Resource = instance + + 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 + + +## 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. +static func _apply_resource_properties(res: Resource, properties: Dictionary) -> Variant: + 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() + var err := ErrorCodes.make( + ErrorCodes.PROPERTY_NOT_ON_CLASS, + "Property '%s' not found on %s. Call resource_get_info('%s') to list available properties." % [key, res.get_class(), res.get_class()] + ) + 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__", "") + var class_err := _validate_resource_class(sub_type) + if class_err != null: + return class_err + var sub_instance := ClassDB.instantiate(sub_type) + if sub_instance == null or not (sub_instance is Resource): + return ErrorCodes.make( + ErrorCodes.INTERNAL_ERROR, + "Failed to instantiate %s as a Resource for property '%s'" % [sub_type, key] + ) + var sub_res: Resource = sub_instance + var remaining: Dictionary = (v as Dictionary).duplicate() + remaining.erase("__class__") + if not remaining.is_empty(): + var nested_err := _apply_resource_properties(sub_res, remaining) + if nested_err != null: + return nested_err + v = sub_res + 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, + "Property '%s' not found on %s" % [property, node.get_class()] + ) + 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): + 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_* 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} 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..f2514bc --- /dev/null +++ b/addons/godot_ai/handlers/scene_handler.gd @@ -0,0 +1,267 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Handles scene tree reading and node search. + +var _connection: McpConnection +var _save_scene_callable: Callable = Callable() +var _save_scene_as_callable: Callable = Callable() + + +func _init(connection: McpConnection = null) -> void: + _connection = connection + + +func get_scene_tree(params: Dictionary) -> Dictionary: + var max_depth: int = params.get("depth", 10) + var scene_root := EditorInterface.get_edited_scene_root() + if scene_root == null: + return {"data": {"nodes": [], "message": "No scene open"}} + + var nodes: Array[Dictionary] = [] + _walk_tree(scene_root, nodes, 0, max_depth, scene_root) + return {"data": {"nodes": nodes, "total_count": nodes.size()}} + + +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 + + var packed := PackedScene.new() + packed.pack(root) + root.free() + + if _connection: + _connection.pause_processing = true + var err := ResourceSaver.save(packed, path) + 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", + } + } + + +## Open an existing scene by file path. +func open_scene(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, "Scene not found: %s" % path) + + EditorInterface.open_scene_from_path(path) + + return { + "data": { + "path": path, + "undoable": false, + "reason": "Scene navigation cannot be undone via editor undo", + } + } + + +## 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) -> void: + if depth > max_depth: + return + out.append({ + "name": node.name, + "type": node.get_class(), + "path": McpScenePath.from_node(node, scene_root), + "children_count": node.get_child_count(), + }) + for child in node.get_children(): + _walk_tree(child, out, depth + 1, max_depth, scene_root) 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..1a4aaec --- /dev/null +++ b/addons/godot_ai/handlers/script_handler.gd @@ -0,0 +1,398 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Handles script creation, reading, attaching, detaching, and symbol inspection. + +var _undo_redo: EditorUndoRedoManager +var _connection: McpConnection + +# Bounded settle window for `ResourceLoader.exists(path)` after `scan()` so +# that an agent calling create_script -> attach_script back-to-back doesn't +# race the editor's import pipeline (#261). Polled once per frame, with an +# elapsed-time cap below the dispatcher's create_script deferred timeout. If +# import is still not visible at the cap, we still return committed=true +# instead of letting the already-written file surface as DEFERRED_TIMEOUT. +const _IMPORT_SETTLE_MAX_FRAMES := 300 +const _IMPORT_SETTLE_MAX_MSEC := 3500 + + +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") + + # 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 existed_before := FileAccess.file_exists(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.close() + + # Register just this file with the editor instead of a full recursive + # scan(). A scan() per write stacks `update_scripts_classes` / + # `update_script_paths_documentation` WorkerThreadPool tasks under concurrent + # script creation ("Task ... already exists" / "!tasks.has(p_task)"), which + # races the global-class registry and can SIGABRT in + # ScriptServer::remove_global_class_by_path (see dsarno/godot#6). + # update_file() is the single-file path the rest of the plugin already uses. + var efs := EditorInterface.get_resource_filesystem() + if efs != null: + efs.update_file(path) + + 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", + } + # `.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(): + _finish_create_script_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} + + +# `static` is load-bearing: the deferred completion captures no `self`, so the +# coroutine survives even if the ScriptHandler RefCounted is freed mid-await. +# Under concurrent script_create storms with editor_reload_plugin fired during +# the burst, the handler instance is otherwise GC'd between `await` and resume, +# producing "Resumed function '_finish_create_script_deferred()' 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. +static func _finish_create_script_deferred( + connection: McpConnection, + request_id: String, + path: String, + data: Dictionary, +) -> void: + if not is_instance_valid(connection): + return + var tree := connection.get_tree() + if tree == null: + return + var deadline_ms := Time.get_ticks_msec() + _IMPORT_SETTLE_MAX_MSEC + # Let _dispatch() return DEFERRED_RESPONSE and register the request before + # this coroutine can send a committed result. ResourceLoader.exists(path) + # may already be true on fast imports; without this handoff the connection + # treats the response as late/unregistered and drops it, then the dispatcher + # times out a file that was already written (#324). The deadline starts + # before this await so a slow handoff frame is counted against the bounded + # settle window. + await tree.process_frame + var frames := 0 + while ( + frames < _IMPORT_SETTLE_MAX_FRAMES + and Time.get_ticks_msec() < deadline_ms + and not ResourceLoader.exists(path) + ): + await tree.process_frame + frames += 1 + # If the plugin tears down (_exit_tree frees the connection) during the + # await, is_instance_valid() goes false and we drop the response silently — + # the server's request timeout will surface the failure to the caller. + if not is_instance_valid(connection): + return + var payload := data.duplicate() + var settled := ResourceLoader.exists(path) + payload["import_settled"] = settled + payload["import_settle"] = "settled" if settled else "timeout" + payload["import_pending"] = not settled + connection.send_deferred_response(request_id, {"data": payload}) + + +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), + } + } + + +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 + + var write := FileAccess.open(path, FileAccess.WRITE) + if write == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to open file for writing: %s" % path) + write.store_string(new_content) + write.close() + + # Single-file register, not a full scan() — see create_script (dsarno/godot#6). + var efs := EditorInterface.get_resource_filesystem() + if efs != null: + efs.update_file(path) + + return { + "data": { + "path": path, + "replacements": replacements, + "size": new_content.length(), + "old_size": content.length(), + "undoable": false, + "reason": "File system operations cannot be undone via editor undo", + } + } + + +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 + if line.begins_with("class_name "): + class_name_str = line.substr(11).strip_edges() + + # 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..ad214fc --- /dev/null +++ b/addons/godot_ai/handlers/signal_handler.gd @@ -0,0 +1,258 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Handles signal listing, connecting, and disconnecting on scene nodes. + +var _undo_redo: EditorUndoRedoManager + + +func _init(undo_redo: EditorUndoRedoManager) -> void: + _undo_redo = undo_redo + + +func list_signals(params: Dictionary) -> Dictionary: + var path: String = params.get("path", "") + 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) + _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]) + + _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) + _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..3efffdb --- /dev/null +++ b/addons/godot_ai/handlers/test_handler.gd @@ -0,0 +1,82 @@ +@tool +extends RefCounted + +## Discovers and runs McpTestSuite scripts from res://tests/. +## Exposes run_tests and get_test_results as MCP commands. + +var _runner: McpTestRunner +var _undo_redo: EditorUndoRedoManager +var _log_buffer: McpLogBuffer + + +func _init(undo_redo: EditorUndoRedoManager, log_buffer: McpLogBuffer) -> void: + _runner = McpTestRunner.new() + _undo_redo = undo_redo + _log_buffer = log_buffer + + +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 discovery := _discover_suites() + var suites: Array = discovery.suites + if suites.is_empty(): + var msg := "No test suites found in res://tests/" + if not discovery.errors.is_empty(): + msg += " (%d script(s) failed to load: %s)" % [ + discovery.errors.size(), + ", ".join(discovery.errors), + ] + return {"data": {"error": msg, "total": 0, "load_errors": discovery.errors}} + + var ctx := { + "undo_redo": _undo_redo, + "log_buffer": _log_buffer, + } + + var results := _runner.run_suites(suites, suite_filter, test_filter, ctx, verbose, exclude_test_filter) + if not discovery.errors.is_empty(): + results["load_errors"] = discovery.errors + return {"data": results} + + +func get_test_results(params: Dictionary) -> Dictionary: + var verbose: bool = params.get("verbose", false) + return {"data": _runner.get_results(verbose)} + + +func _discover_suites() -> Dictionary: + ## Returns {"suites": Array, "errors": Array[String]}. + ## Resilient: a broken script doesn't kill discovery of the rest. + 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"]} + + 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 path := "res://tests/" + file_name + var script = ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE) + if script == null: + errors.append("%s (load failed — check for parse errors or duplicate methods)" % file_name) + elif script.can_instantiate(): + var instance = script.new() + if instance is McpTestSuite: + suites.append(instance) + else: + errors.append("%s (not a McpTestSuite subclass)" % file_name) + else: + errors.append("%s (cannot instantiate — abstract or broken)" % file_name) + file_name = dir.get_next() + + ## Sort by suite name for deterministic order. + suites.sort_custom(func(a, b) -> bool: + return a.suite_name() < b.suite_name() + ) + return {"suites": suites, "errors": errors} 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..8910175 --- /dev/null +++ b/addons/godot_ai/handlers/texture_handler.gd @@ -0,0 +1,199 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Creates procedural textures — GradientTexture2D (wrapping a Gradient) +## and NoiseTexture2D (wrapping a FastNoiseLite). Assigns to a node slot +## (undoable, bundles sub-resources) or saves to a .tres file. + +const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd") + +var _undo_redo: EditorUndoRedoManager +var _connection: McpConnection + + +func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void: + _undo_redo = undo_redo + _connection = connection + + +const _FILL_MODES := { + "linear": GradientTexture2D.FILL_LINEAR, + "radial": GradientTexture2D.FILL_RADIAL, + "square": GradientTexture2D.FILL_SQUARE, +} + +const _NOISE_TYPES := { + "simplex": FastNoiseLite.TYPE_SIMPLEX, + "simplex_smooth": FastNoiseLite.TYPE_SIMPLEX_SMOOTH, + "perlin": FastNoiseLite.TYPE_PERLIN, + "cellular": FastNoiseLite.TYPE_CELLULAR, + "value": FastNoiseLite.TYPE_VALUE, + "value_cubic": FastNoiseLite.TYPE_VALUE_CUBIC, +} + + +# ============================================================================ +# gradient_texture_create +# ============================================================================ + +func create_gradient_texture(params: Dictionary) -> Dictionary: + var stops: Array = params.get("stops", []) + var width: int = params.get("width", 256) + var height: int = params.get("height", 1) + var fill: String = params.get("fill", "linear") + + if stops.size() < 2: + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "gradient_texture_create requires at least 2 stops, got %d" % stops.size() + ) + if not _FILL_MODES.has(fill): + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "Invalid fill '%s'. Valid: %s" % [fill, ", ".join(_FILL_MODES.keys())] + ) + + var home_err := McpResourceIO.validate_home(params) + if home_err != null: + return home_err + + var gradient := Gradient.new() + var offsets := PackedFloat32Array() + var colors := PackedColorArray() + for i in range(stops.size()): + var stop = stops[i] + if not stop is Dictionary: + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "stops[%d] must be a dict with 'offset' and 'color' keys" % i + ) + if not stop.has("offset") or not stop.has("color"): + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "stops[%d] missing 'offset' or 'color' key" % i + ) + offsets.append(float(stop["offset"])) + var color_value = NodeHandler._coerce_value(stop["color"], TYPE_COLOR) + var color_err := NodeHandler._check_coerced(color_value, TYPE_COLOR, "stops[%d].color" % i) + if color_err != null: + return color_err + colors.append(color_value) + gradient.offsets = offsets + gradient.colors = colors + + var tex := GradientTexture2D.new() + tex.gradient = gradient + tex.width = width + tex.height = height + tex.fill = _FILL_MODES[fill] + + return _finalize(tex, [gradient], params, "Gradient texture", { + "texture_class": "GradientTexture2D", + "gradient_class": "Gradient", + "stop_count": stops.size(), + "fill": fill, + }) + + +# ============================================================================ +# noise_texture_create +# ============================================================================ + +func create_noise_texture(params: Dictionary) -> Dictionary: + var noise_type: String = params.get("noise_type", "simplex_smooth") + var width: int = params.get("width", 512) + var height: int = params.get("height", 512) + var frequency: float = params.get("frequency", 0.01) + var seed_value: int = params.get("seed", 0) + var fractal_octaves: int = params.get("fractal_octaves", 0) # 0 = leave default + + if not _NOISE_TYPES.has(noise_type): + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "Invalid noise_type '%s'. Valid: %s" % [noise_type, ", ".join(_NOISE_TYPES.keys())] + ) + + var home_err := McpResourceIO.validate_home(params) + if home_err != null: + return home_err + + var noise := FastNoiseLite.new() + noise.noise_type = _NOISE_TYPES[noise_type] + noise.frequency = frequency + noise.seed = seed_value + if fractal_octaves > 0: + noise.fractal_octaves = fractal_octaves + + var tex := NoiseTexture2D.new() + tex.noise = noise + tex.width = width + tex.height = height + + return _finalize(tex, [noise], params, "Noise texture", { + "texture_class": "NoiseTexture2D", + "noise_class": "FastNoiseLite", + "noise_type": noise_type, + }) + + +# ============================================================================ +# shared helpers +# ============================================================================ + +func _finalize(tex: Resource, sub_resources: Array, params: Dictionary, label: String, extra: Dictionary) -> Dictionary: + var node_path: String = params.get("path", "") + var property: String = params.get("property", "") + var resource_path: String = params.get("resource_path", "") + var overwrite: bool = params.get("overwrite", false) + + if not resource_path.is_empty(): + return McpResourceIO.save_to_disk(tex, resource_path, overwrite, label, extra, _connection) + return _assign_texture(tex, sub_resources, node_path, property, label, extra) + + +func _assign_texture(tex: Resource, sub_resources: Array, node_path: String, property: String, label: String, extra: Dictionary) -> Dictionary: + var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path") + if _resolved.has("error"): + return _resolved + var node: Node = _resolved.node + var _scene_root: Node = _resolved.scene_root + + var found := false + var prop_type: int = TYPE_NIL + for prop in node.get_property_list(): + if prop.name == property: + found = true + prop_type = prop.get("type", TYPE_NIL) + break + if not found: + return ErrorCodes.make( + ErrorCodes.PROPERTY_NOT_ON_CLASS, + "Property '%s' not found on %s" % [property, node.get_class()] + ) + 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..979fe0c --- /dev/null +++ b/addons/godot_ai/handlers/theme_handler.gd @@ -0,0 +1,488 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Handles Theme resource authoring: creating, modifying color/constant/font-size/ +## stylebox slots, and applying a theme to a Control subtree. +## +## Themes are Godot's equivalent of USS: a Theme holds (class, name) -> value +## entries (colors, constants, fonts, font_sizes, styleboxes, icons) which +## cascade down a Control subtree when the theme is assigned at any ancestor. +## One well-authored theme replaces hundreds of per-node property sets. + +const _COLOR_HINT := "expected hex #rrggbb, named color, or {r,g,b,a} dict" + +var _undo_redo: EditorUndoRedoManager + + +func _init(undo_redo: EditorUndoRedoManager) -> void: + _undo_redo = undo_redo + + +# ============================================================================ +# 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 := ResourceSaver.save(theme, path) + 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) + ResourceSaver.save(theme, theme_path) + + +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) + ResourceSaver.save(theme, theme_path) + + +# ============================================================================ +# 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) + ResourceSaver.save(theme, theme_path) + + +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) + ResourceSaver.save(theme, theme_path) + + +# ============================================================================ +# 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. +static func _parse_color(value: Variant) -> Variant: + if value is Color: + return value + if value is String: + var s: String = value + # Color.from_string returns the default on parse failure, so call it twice + # with distinct sentinels — if both agree, parsing succeeded. + var sentinel_a := Color(0, 0, 0, 0) + var sentinel_b := Color(1, 1, 1, 1) + var a := Color.from_string(s, sentinel_a) + var b := Color.from_string(s, sentinel_b) + if a != b: + return null + return a + if value is Dictionary: + var d: Dictionary = value + if d.has("r") and d.has("g") and d.has("b"): + return Color(float(d.r), float(d.g), float(d.b), float(d.get("a", 1.0))) + return null + + +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/ui_handler.gd b/addons/godot_ai/handlers/ui_handler.gd new file mode 100644 index 0000000..5d8db9b --- /dev/null +++ b/addons/godot_ai/handlers/ui_handler.gd @@ -0,0 +1,533 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Handles UI-specific (Control) layout helpers: anchor presets, etc. +## +## Anchors/offsets are the worst part of Control layout to set one-property-at-a-time. +## This handler wraps Godot's built-in presets (FULL_RECT, CENTER, TOP_LEFT, ...) so +## callers can set a whole layout with one command, with proper undo. + +var _undo_redo: EditorUndoRedoManager + + +const _PRESETS := { + "top_left": Control.PRESET_TOP_LEFT, + "top_right": Control.PRESET_TOP_RIGHT, + "bottom_left": Control.PRESET_BOTTOM_LEFT, + "bottom_right": Control.PRESET_BOTTOM_RIGHT, + "center_left": Control.PRESET_CENTER_LEFT, + "center_top": Control.PRESET_CENTER_TOP, + "center_right": Control.PRESET_CENTER_RIGHT, + "center_bottom": Control.PRESET_CENTER_BOTTOM, + "center": Control.PRESET_CENTER, + "left_wide": Control.PRESET_LEFT_WIDE, + "top_wide": Control.PRESET_TOP_WIDE, + "right_wide": Control.PRESET_RIGHT_WIDE, + "bottom_wide": Control.PRESET_BOTTOM_WIDE, + "vcenter_wide": Control.PRESET_VCENTER_WIDE, + "hcenter_wide": Control.PRESET_HCENTER_WIDE, + "full_rect": Control.PRESET_FULL_RECT, +} + +const _RESIZE_MODES := { + "minsize": Control.PRESET_MODE_MINSIZE, + "keep_width": Control.PRESET_MODE_KEEP_WIDTH, + "keep_height": Control.PRESET_MODE_KEEP_HEIGHT, + "keep_size": Control.PRESET_MODE_KEEP_SIZE, +} + +const _ANCHOR_OFFSET_PROPS := [ + "anchor_left", "anchor_top", "anchor_right", "anchor_bottom", + "offset_left", "offset_top", "offset_right", "offset_bottom", +] + + +func _init(undo_redo: EditorUndoRedoManager) -> void: + _undo_redo = undo_redo + + +## Apply a Control layout preset (anchors + offsets) to a UI node. +## +## Params: +## path - scene path to a Control node (required) +## preset - preset name: full_rect, center, top_left, ... (required) +## resize_mode - minsize | keep_width | keep_height | keep_size (default: minsize) +## margin - integer margin in pixels from the anchor edges (default: 0) +func set_anchor_preset(params: Dictionary) -> Dictionary: + var node_path: String = params.get("path", "") + if node_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path") + + var preset_name: String = str(params.get("preset", "")).to_lower() + if preset_name.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: preset") + if not _PRESETS.has(preset_name): + var names := _PRESETS.keys() + names.sort() + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "Unknown preset '%s'. Valid: %s" % [preset_name, ", ".join(names)] + ) + + var resize_mode_name: String = str(params.get("resize_mode", "minsize")).to_lower() + if not _RESIZE_MODES.has(resize_mode_name): + var names := _RESIZE_MODES.keys() + names.sort() + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "Unknown resize_mode '%s'. Valid: %s" % [resize_mode_name, ", ".join(names)] + ) + + var margin: int = int(params.get("margin", 0)) + + var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path") + if _resolved.has("error"): + return _resolved + var node: Node = _resolved.node + var scene_root: Node = _resolved.scene_root + if not node is Control: + var got_class: String = node.get_class() + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Node %s is not a Control (got %s)%s" % [ + node_path, got_class, _canvas_layer_overlay_hint(got_class) + ] + ) + + var control := node as Control + var preset_value: int = _PRESETS[preset_name] + var resize_mode_value: int = _RESIZE_MODES[resize_mode_name] + + # Snapshot before so we can undo every property the preset may have touched. + var before: Dictionary = {} + for prop in _ANCHOR_OFFSET_PROPS: + before[prop] = control.get(prop) + + _undo_redo.create_action("MCP: Set %s anchor preset %s" % [control.name, preset_name]) + _undo_redo.add_do_method( + control, "set_anchors_and_offsets_preset", preset_value, resize_mode_value, margin + ) + for prop in _ANCHOR_OFFSET_PROPS: + _undo_redo.add_undo_property(control, prop, before[prop]) + _undo_redo.commit_action() + + var after: Dictionary = {} + for prop in _ANCHOR_OFFSET_PROPS: + after[prop] = control.get(prop) + + return { + "data": { + "path": node_path, + "preset": preset_name, + "resize_mode": resize_mode_name, + "margin": margin, + "anchors": { + "left": after.anchor_left, + "top": after.anchor_top, + "right": after.anchor_right, + "bottom": after.anchor_bottom, + }, + "offsets": { + "left": after.offset_left, + "top": after.offset_top, + "right": after.offset_right, + "bottom": after.offset_bottom, + }, + "undoable": true, + } + } + + +## Set the visible `text` property on a UI Control (Label, Button + subclasses, +## LineEdit, TextEdit, RichTextLabel, LinkButton). Undoable. +func set_text(params: Dictionary) -> Dictionary: + var node_path: String = params.get("path", "") + if node_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path") + + if not params.has("text"): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: text") + var text_value: Variant = params["text"] + if typeof(text_value) != TYPE_STRING: + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "text must be a string") + + var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path") + if _resolved.has("error"): + return _resolved + var node: Node = _resolved.node + var scene_root: Node = _resolved.scene_root + var node_type := node.get_class() + if not node is Control: + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Node %s is not a Control (got %s)" % [node_path, node_type] + ) + # Scan get_property_list() (matches set_property / _apply_property in this + # repo) so we can both confirm `text` exists and that it's actually a String + # — guards against a custom Control whose `text` happens to be some other + # type, where set()-ing a String would silently mis-coerce. + var text_prop_type := TYPE_NIL + var has_text := false + for prop in node.get_property_list(): + if prop.get("name", "") == "text": + has_text = true + text_prop_type = prop.get("type", TYPE_NIL) + break + if not has_text: + return ErrorCodes.make( + ErrorCodes.PROPERTY_NOT_ON_CLASS, + "Control %s has no 'text' property (got %s)" % [node_path, node_type] + ) + if text_prop_type != TYPE_STRING: + return ErrorCodes.make( + ErrorCodes.PROPERTY_NOT_ON_CLASS, + "Control %s has a non-string 'text' property (got %s)" % [node_path, node_type] + ) + + var old_value: String = node.get("text") + + _undo_redo.create_action("MCP: Set %s text" % node.name) + _undo_redo.add_do_property(node, "text", text_value) + _undo_redo.add_undo_property(node, "text", old_value) + _undo_redo.commit_action() + + return { + "data": { + "path": node_path, + "text": text_value, + "old_text": old_value, + "node_type": node_type, + "undoable": true, + } + } + + +# ============================================================================ +# build_layout — declarative nested-dict → Control tree in one undo action +# ============================================================================ + +## Build a tree of Control nodes atomically. +## +## Params: +## tree - Dictionary describing the root node. Required fields: "type". +## Optional: "name", "properties" (dict), "anchor_preset", +## "anchor_margin", "theme" (res://, uid:// or user:// path), "children" (array). +## parent_path - Parent scene path. Empty or "/" = scene root. +## +## Validation is done before any scene mutation: class names, property +## existence, and res:// paths are all checked up-front. If anything is +## invalid, no node is created. +func build_layout(params: Dictionary) -> Dictionary: + var tree = params.get("tree") + if not params.has("tree"): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: tree") + if typeof(tree) != TYPE_DICTIONARY: + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "tree must be a dictionary") + + var _scene_check := McpNodeValidator.require_scene_or_error() + if _scene_check.has("error"): + return _scene_check + var scene_root: Node = _scene_check.scene_root + + var parent_path: String = params.get("parent_path", "") + var parent: Node = scene_root + if not parent_path.is_empty() and parent_path != "/": + parent = McpScenePath.resolve(parent_path, scene_root) + if parent == null: + return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root)) + + # Validate + build in memory first; if anything fails, free and bail. + var built := _build_subtree(tree) + if built.has("error"): + return built + var root_node: Node = built.node + var created: Array[Node] = built.created + + _undo_redo.create_action("MCP: Build UI layout (%d nodes)" % created.size()) + _undo_redo.add_do_method(parent, "add_child", root_node, true) + _undo_redo.add_do_method(root_node, "set_owner", scene_root) + for n in created: + _undo_redo.add_do_method(n, "set_owner", scene_root) + _undo_redo.add_do_reference(n) + _undo_redo.add_undo_method(parent, "remove_child", root_node) + _undo_redo.commit_action() + + return { + "data": { + "root_path": McpScenePath.from_node(root_node, scene_root), + "node_count": created.size(), + "undoable": true, + } + } + + +## Recursively instantiate + configure a node and its children in memory. +## Returns {"node": root, "created": [all descendants incl. root]} or {"error": ...}. +func _build_subtree(spec: Dictionary) -> Dictionary: + var node_type: String = spec.get("type", "") + if node_type.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Every layout node requires a 'type'") + if not ClassDB.class_exists(node_type): + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown type: %s" % node_type) + if not ClassDB.is_parent_class(node_type, "Node"): + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Node type" % node_type) + + var node: Node = ClassDB.instantiate(node_type) + if node == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % node_type) + + var node_name: String = spec.get("name", "") + if not node_name.is_empty(): + node.name = node_name + + # Properties. + if spec.has("properties"): + var props = spec.get("properties") + if typeof(props) != TYPE_DICTIONARY: + node.free() + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "properties must be a dictionary") + for key in props: + var value = props[key] + var apply_err := _apply_property(node, str(key), value) + if apply_err != null: + node.free() + return apply_err + + # Theme (res:// / uid:// / user:// path -> Resource). + if spec.has("theme"): + var theme_path: String = str(spec.get("theme", "")) + if not theme_path.is_empty(): + var theme_path_err = McpPathValidator.loadable_error(theme_path, "theme") + if theme_path_err != null: + node.free() + return theme_path_err + if not ResourceLoader.exists(theme_path): + node.free() + return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Theme not found: %s" % theme_path) + var theme_res: Resource = ResourceLoader.load(theme_path) + if theme_res == null or not theme_res is Theme: + node.free() + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "theme path must point to a Theme resource: %s" % theme_path) + if not node is Control and not node is Window: + node.free() + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "theme can only be set on Control / Window (got %s)%s" % [ + node_type, _canvas_layer_overlay_hint(node_type) + ] + ) + node.theme = theme_res as Theme + + # Anchor preset — applied before children so children inherit sensible anchors. + if spec.has("anchor_preset"): + var preset_name: String = str(spec.get("anchor_preset", "")).to_lower() + if not _PRESETS.has(preset_name): + node.free() + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown anchor_preset: %s" % preset_name) + if not node is Control: + node.free() + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "anchor_preset requires a Control (got %s)%s" % [ + node_type, _canvas_layer_overlay_hint(node_type) + ] + ) + var preset_value: int = _PRESETS[preset_name] + var margin: int = int(spec.get("anchor_margin", 0)) + (node as Control).set_anchors_and_offsets_preset(preset_value, Control.PRESET_MODE_MINSIZE, margin) + + var created: Array[Node] = [node] + if spec.has("children"): + var children = spec.get("children") + if typeof(children) != TYPE_ARRAY: + node.free() + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "children must be an array") + for child_spec in children: + if typeof(child_spec) != TYPE_DICTIONARY: + node.free() + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "each child must be a dictionary") + var child_result := _build_subtree(child_spec) + if child_result.has("error"): + node.free() + return child_result + var child_node: Node = child_result.node + node.add_child(child_node) + for n in child_result.created: + created.append(n) + return {"node": node, "created": created} + + +## Mapping from theme_override_* property prefixes to their add/remove methods. +const _THEME_OVERRIDE_MAP := { + "theme_override_colors/": { + "add": "add_theme_color_override", + "remove": "remove_theme_color_override", + "coerce_type": TYPE_COLOR, + }, + "theme_override_constants/": { + "add": "add_theme_constant_override", + "remove": "remove_theme_constant_override", + "coerce_type": TYPE_INT, + }, + "theme_override_font_sizes/": { + "add": "add_theme_font_size_override", + "remove": "remove_theme_font_size_override", + "coerce_type": TYPE_INT, + }, + "theme_override_styles/": { + "add": "add_theme_stylebox_override", + "remove": "remove_theme_stylebox_override", + "coerce_type": TYPE_OBJECT, + }, +} + + +## Apply a property to a newly-instantiated node. Handles Color/Vector2/NodePath +## coercion from JSON-friendly forms. Returns null on success, error dict on failure. +func _apply_property(node: Node, prop: String, value: Variant) -> Variant: + # Handle theme_override_* pseudo-properties before the regular property scan. + for prefix in _THEME_OVERRIDE_MAP: + if prop.begins_with(prefix): + if not node is Control: + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "theme_override_* requires a Control node (got %s)" % node.get_class() + ) + var override_name := prop.substr(prefix.length()) + var info: Dictionary = _THEME_OVERRIDE_MAP[prefix] + var coerce_type: int = info.coerce_type + + # For stylebox overrides, load from a res:// / uid:// / user:// path. + if coerce_type == TYPE_OBJECT: + if value is String and (value.begins_with("res://") or value.begins_with("uid://") or value.begins_with("user://")): + var style_path_err = McpPathValidator.loadable_error(value, "stylebox") + if style_path_err != null: + return style_path_err + var res := ResourceLoader.load(value) + if res == null or not res is StyleBox: + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "Style resource not found or not a StyleBox: %s" % value + ) + node.call(info.add, override_name, res) + else: + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "theme_override_styles/ expects a res:// / uid:// / user:// path to a StyleBox" + ) + else: + var coercion := _coerce_for_type(value, coerce_type) + if not coercion.ok: + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Cannot coerce '%s' for %s" % [value, prop] + ) + node.call(info.add, override_name, coercion.value) + return null + + var found := false + var prop_type := TYPE_NIL + for p in node.get_property_list(): + if p.name == prop: + found = true + prop_type = p.get("type", TYPE_NIL) + break + if not found: + return ErrorCodes.make( + ErrorCodes.PROPERTY_NOT_ON_CLASS, + McpPropertyErrors.build_message(node, prop) + ) + + var coercion := _coerce_for_type(value, prop_type) + if not coercion.ok: + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Property '%s' on %s expects type %s (cannot coerce %s)" % [ + prop, node.get_class(), type_string(prop_type), value + ] + ) + node.set(prop, coercion.value) + return null + + +## Coerce a JSON-friendly value to the target Godot type. Returns +## {"ok": true, "value": coerced} on success, {"ok": false} on failure. +## For types we don't explicitly coerce, the value is returned as-is +## (Godot will typecheck at set() time and fail loudly if it disagrees). +static func _coerce_for_type(value: Variant, prop_type: int) -> Dictionary: + match prop_type: + TYPE_COLOR: + if value is Color: + return {"ok": true, "value": value} + if value is String: + 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 {"ok": true, "value": a} + return {"ok": false} + if value is Dictionary and value.has("r") and value.has("g") and value.has("b"): + return { + "ok": true, + "value": Color(float(value.r), float(value.g), float(value.b), float(value.get("a", 1.0))), + } + return {"ok": false} + TYPE_VECTOR2: + if value is Vector2: + return {"ok": true, "value": value} + if value is Dictionary and value.has("x") and value.has("y"): + return {"ok": true, "value": Vector2(float(value.x), float(value.y))} + if value is Array and value.size() == 2: + return {"ok": true, "value": Vector2(float(value[0]), float(value[1]))} + 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..bd02190 --- /dev/null +++ b/addons/godot_ai/mcp_dock.gd @@ -0,0 +1,2496 @@ +@tool +class_name McpDock +extends VBoxContainer + +## Editor dock panel showing MCP connection status, client config, and command log. +## +## Audit-v2 #360 partial extraction. Two cohesive subpanels live in +## res://addons/godot_ai/dock_panels/: +## - log_viewer.gd: MCP request/response log (dev-mode only). +## - port_picker_panel.gd: spawn-failure escape hatch nested in the crash panel. +## +## The audit also called for ServerStatusPanel and ClientRowController +## extractions; those were *deliberately deferred*. Their UI scatters across +## the dock layout (status icon at top, crash panel mid, setup section lower; +## client rows + drift banner + scroll grid spread similarly), so a clean +## extract-by-panel needs either visible UI reorganization or a coordinator- +## Node pattern with property-accessor façades on McpDock that re-tangle the +## very state they claim to move. +## +## A future refactor probably wants extract-by-concern instead — e.g. +## `utils/mcp_async_refresh_state_machine.gd` owning the IDLE → RUNNING → +## RUNNING_TIMED_OUT → DEFERRED_FOR_FILESYSTEM → SHUTTING_DOWN transitions +## and pending-flag triplet, `utils/mcp_client_action_dispatcher.gd` owning +## the per-row Configure/Remove worker pool. The dock would keep UI +## construction and lose the state-machine ownership. See issue #360. + +const ServerStateScript := preload("res://addons/godot_ai/utils/mcp_server_state.gd") +const ClientRefreshStateScript := preload("res://addons/godot_ai/utils/mcp_client_refresh_state.gd") +const Telemetry := preload("res://addons/godot_ai/telemetry.gd") +const UpdateManagerScript := preload("res://addons/godot_ai/utils/update_manager.gd") +const UpdateMixedStateScript := preload("res://addons/godot_ai/utils/update_mixed_state.gd") +const Client := preload("res://addons/godot_ai/clients/_base.gd") +const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd") +const ClientRegistry := preload("res://addons/godot_ai/clients/_registry.gd") +const JsonStrategy := preload("res://addons/godot_ai/clients/_json_strategy.gd") +const TomlStrategy := preload("res://addons/godot_ai/clients/_toml_strategy.gd") +const CliStrategy := preload("res://addons/godot_ai/clients/_cli_strategy.gd") +const ToolCatalog := preload("res://addons/godot_ai/tool_catalog.gd") +const LogViewerScript := preload("res://addons/godot_ai/dock_panels/log_viewer.gd") +const PortPickerPanelScript := preload("res://addons/godot_ai/dock_panels/port_picker_panel.gd") + +const DEV_MODE_SETTING := "godot_ai/dev_mode" +const CLIENT_STATUS_REFRESH_COOLDOWN_MSEC := 15 * 1000 +const CLIENT_STATUS_REFRESH_TIMEOUT_MSEC := 30 * 1000 +static var COLOR_MUTED := Color(0.7, 0.7, 0.7) +static var COLOR_HEADER := Color(0.95, 0.95, 0.95) +## Used for "in-progress" / "stale, action needed" UI: the startup-grace +## status icon, the spawn-failure suggested-port hint, the drift banner, +## and the per-row mismatch dot. One constant so a future palette tweak +## doesn't have to find every literal. +static var COLOR_AMBER := Color(1.0, 0.75, 0.25) + +var _connection +var _log_buffer +var _plugin: EditorPlugin + +# Always visible +var _redock_btn: Button +var _status_icon: ColorRect +var _status_label: Label +var _client_grid: VBoxContainer +var _client_configure_all_btn: Button +var _clients_summary_label: Label +var _clients_window: Window +var _dev_mode_toggle: CheckButton +var _install_label: Label + +# Settings 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 & Restart Server` writes pending → setting and triggers a +# plugin reload so the new server comes up with the trimmed list. +var _tools_pending_excluded: PackedStringArray = PackedStringArray() +var _tools_saved_excluded: PackedStringArray = PackedStringArray() +var _tools_domain_checkboxes: Dictionary = {} +var _tools_count_label: Label +var _tools_apply_btn: Button +var _tools_reset_btn: Button +var _tools_dirty_warning: Label +var _tools_close_confirm: ConfirmationDialog +var _telemetry_toggle: CheckButton +var _telemetry_pending_enabled: bool = true +var _telemetry_saved_enabled: bool = true + +## Per-client UI handles, keyed by client id. Each entry holds the row's +## status dot, configure button, remove button, manual-command panel + text. +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 +## Handles for the Setup section's "Server" row. `_update_status` keeps +## the label text/color in sync with `McpConnection.server_version` so the +## dock reports the TRUE running server version, not the plugin's +## expected version. See #174 follow-up — a plugin upgrade via self- +## update can leave the plugin connected to an older adopted server +## (foreign-port branch never sets `_server_pid`, so `_stop_server` +## can't kill it); the line has to show the mismatch honestly. +var _setup_server_label: Label +## Last rendered server-version string. `_update_status` runs every +## frame; early-outs text repaint when nothing changed. Empty means +## "no line rendered yet" (dev-checkout branch doesn't render a +## user-mode Server line). +var _last_rendered_server_text: String = "" +## Restart-server button shown next to the Setup container when +## `McpConnection.server_version` drifts from the plugin version. Hidden +## in the match case so the UI stays calm. +var _version_restart_btn: Button +var _server_restart_in_progress := false +## Sorted snapshot of the most recent mismatched-client set. Powers two things: +## (a) the Reconfigure button reuses this list instead of re-running +## `check_status` per row (saves ~18 filesystem reads per click), and +## (b) `_refresh_drift_banner` early-returns when the set is unchanged so +## repeated explicit refreshes don't repaint identical text. Mirrors the +## `_last_server_status` pattern used by the crash panel. +var _last_mismatched_ids: Array[String] = [] +var _client_status_refresh_thread: Thread +## Single source of truth for the refresh-sweep state machine. See +## `ClientRefreshStateScript` for the transition table. Replaces the +## previously scattered booleans (`_in_flight`, `_timed_out`, +## `_deferred_until_filesystem_ready`, `_shutdown_requested`). +var _refresh_state: int = ClientRefreshStateScript.IDLE +## Pending-request flags. Kept separate from `_refresh_state` because +## they're "what should the next refresh look like" — not state of +## any current refresh. A pending request is queued when a refresh +## arrives during RUNNING / RUNNING_TIMED_OUT and consumed by +## `_apply_client_status_refresh_results` once the in-flight worker +## drains. `_pending_force` also captures forced retries deferred via +## DEFERRED_FOR_FILESYSTEM so a pending user click survives the wait. +var _client_status_refresh_pending: bool = false +var _client_status_refresh_pending_force: bool = false +var _client_status_refresh_pending_initial: bool = false +var _last_client_status_refresh_completed_msec: int = 0 +var _client_status_refresh_started_msec: int = 0 +var _client_status_refresh_generation: int = 0 +## Owns the self-update slice: GitHub Releases poll, ZIP download, install +## orchestration, and the install-in-flight gate. Dock keeps banner UI +## only and consults the gate via `_is_self_update_in_progress()`. +var _update_manager +static var _orphaned_client_status_refresh_threads: Array[Thread] = [] + +## Per-row worker state for Configure / Remove. Issue #239: shelling out +## to a hung CLI on main hangs the editor. We dispatch each click to its +## own thread (one slot per client) and apply the result via call_deferred +## once the subprocess returns or the wall-clock budget in McpCliExec +## kicks in. The buttons stay disabled while the slot is busy so the user +## can't queue a re-click on the same row. +## +## Per-client (not single-slot) so Configure-all can fan out — the +## workers are independent, only the row UI is shared, and McpCliExec +## bounds the wall-clock for each. +## +## No orphan-thread list (unlike the refresh worker): action threads +## never get abandoned mid-flight. McpCliExec's wall-clock budget caps +## the worst case at ~10s, so the `_exit_tree` / `McpUpdateManager` +## install-time drain blocks briefly and finishes — there's no path that +## "gives up" on an action thread the way `_abandon_client_status_refresh_thread` +## does for the refresh worker. +var _client_action_threads: Dictionary = {} +var _client_action_generations: Dictionary = {} + +# Dev-mode only +var _dev_section: VBoxContainer +var _server_label: Label +var _reload_btn: Button +var _setup_section: VBoxContainer +var _setup_container: VBoxContainer +## Primary dev-section button — always (re)starts a `--reload` dev server. +## Same-version Python edits get adopted as compatible by the lifecycle, so +## neither the drift nor the crash Restart button surfaces; this is the +## unconditional kick contributors need to pick up source changes without +## a version bump. +var _dev_primary_btn: Button +## Small "✕" affordance next to the primary — stops the dev server without +## spawning a replacement. Disabled when no dev server is running. +var _dev_stop_btn: Button +var _log_viewer: LogViewerScript + +var _last_connected := false +var _last_status_text := "" +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 +## Port-picker escape hatch — visible inside the crash panel when the root +## cause is port contention (PORT_EXCLUDED or FOREIGN_PORT). The dock writes +## the EditorSetting and reloads the plugin in response to the panel's +## `port_apply_requested` signal. +var _port_picker_panel: PortPickerPanelScript +## Last status Dict rendered into the panel — used to skip re-population +## when nothing changed, which would otherwise reset the user's scroll +## position on every frame. GDScript Dicts compare by value with `==`. +var _last_server_status: Dictionary = {} + +# First-run grace: uvx installs 60+ Python packages on first run (can take +# 10-30s on a slow connection). Don't scare users with "Disconnected" during +# that window — show "Starting server…" instead. After this expires, fall +# back to the normal disconnect UI. +const STARTUP_GRACE_MSEC := 60 * 1000 + +# Update banner — visible UI only. Releases polling, ZIP download, and +# the install pipeline live on `_update_manager`. +var _update_banner: VBoxContainer +var _update_label: Label +var _update_btn: Button + +# Mixed-state banner — surfaces when `addons/godot_ai/` contains +# `*.update_backup` files left by a self-update whose rollback failed +# (`UpdateReloadRunner.InstallStatus.FAILED_MIXED`). Without this banner +# the user sees "plugin won't start" with no actionable context, re-runs +# the update, and compounds the mismatch (issue #354 / audit-v2 #10). +var _mixed_state_banner: VBoxContainer +var _mixed_state_label: Label +var _mixed_state_files: RichTextLabel +var _mixed_state_rescan_btn: Button + + +func setup(connection: McpConnection, log_buffer: McpLogBuffer, plugin: EditorPlugin) -> void: + _connection = connection + _log_buffer = log_buffer + _plugin = plugin + _startup_grace_until_msec = Time.get_ticks_msec() + STARTUP_GRACE_MSEC + + +func _ready() -> void: + _build_ui() + + +func _process(_delta: float) -> void: + if _connection == null: + return + _prune_orphaned_client_status_refresh_threads() + _check_client_status_refresh_timeout() + _retry_deferred_client_status_refresh() + _update_status() + if _log_viewer != null and _log_viewer.visible: + _log_viewer.tick() + + +func _exit_tree() -> void: + ## Block on any in-flight refresh worker before letting the dock leave the + ## tree. The plugin disable path (editor_reload_plugin, Project Settings + ## toggle) reloads the McpDock script class — which wipes the static + ## `_orphaned_client_status_refresh_threads`, GCs the Thread objects mid- + ## execution, and triggers `~Thread … destroyed without its completion + ## having been realized` plus GDScript VM corruption (Opcode: 0, IP-bounds + ## errors, intermittent SIGSEGV). Probes finish in well under a second + ## under normal conditions; if a CLI probe genuinely hung, the runtime + ## timeout path (`_abandon_client_status_refresh_thread`) has already + ## moved that thread into the orphan list, so we drain it here too. + ## + ## `wait_to_finish` is unbounded by design: GDScript's Thread API has no + ## timeout, and a polling/abandon fallback would just re-introduce the + ## GC-mid-execution crash this fix exists to prevent. Blocking the editor + ## briefly on plugin-reload is strictly better than the SIGSEGV. + _refresh_state = ClientRefreshStateScript.SHUTTING_DOWN + _drain_client_status_refresh_workers() + _drain_client_action_workers() + + +## Public drain entry consulted by `McpUpdateManager._install_zip` before +## any disk write. Pairs both worker pools so the manager doesn't reach +## into private dock methods. `_exit_tree` still calls the two underlying +## drains directly because it has additional state-machine work +## (SHUTTING_DOWN sticky-set) that the install-time path must NOT inherit. +func prepare_for_self_update_drain() -> void: + _drain_client_status_refresh_workers() + _drain_client_action_workers() + + +func _drain_client_status_refresh_workers() -> void: + ## Block until any in-flight refresh worker (and any orphaned workers from + ## a prior timeout) finish, then clear refresh state. Same blocking + ## semantics as the `_exit_tree` drain — see #232. Used by `_exit_tree` + ## (dock teardown) and `McpUpdateManager._install_zip` (before extract + ## overwrites plugin scripts on disk). + _client_status_refresh_generation += 1 + if _client_status_refresh_thread != null: + _client_status_refresh_thread.wait_to_finish() + _client_status_refresh_thread = null + for thread in _orphaned_client_status_refresh_threads: + if thread != null: + thread.wait_to_finish() + _orphaned_client_status_refresh_threads.clear() + ## Don't transition out of SHUTTING_DOWN — the drain is called from + ## `_exit_tree` (sticky shutdown) and from + ## `McpUpdateManager._install_zip`'s post-drain reset, which writes + ## the state explicitly. + if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN: + _refresh_state = ClientRefreshStateScript.IDLE + _client_status_refresh_pending = false + _client_status_refresh_pending_force = false + _client_status_refresh_pending_initial = false + + +func _drain_client_action_workers() -> void: + ## Same drain semantics as the refresh worker (see comment above): the + ## plugin disable / install-update path reloads our script class, so any + ## live Thread must finish before its slot is GC'd or we hit + ## `~Thread … destroyed without its completion having been realized` → + ## VM corruption. Bounded by `McpCliExec` wall-clock budgets, so the + ## worst case is a ~10s blocking drain, vs. an unbounded SIGSEGV. + ## + ## Generation-bumped per-row so any pending `call_deferred( + ## "_apply_client_action_result")` from a worker that finished after we + ## started draining detects the generation mismatch and short-circuits + ## without touching freed UI state. + ## + ## After draining, restore the row UI for any in-flight rows: bare + ## `_client_action_threads.clear()` would leave the dock stuck showing + ## "Configuring…" / "Removing…" with disabled buttons forever — a + ## user-visible failure mode for the install-update bail-out branch + ## (zip extract failure on the manager clears `_install_in_flight` and + ## the dock stays alive). + for client_id in _client_action_threads.keys(): + var t: Thread = _client_action_threads[client_id] + if t != null: + t.wait_to_finish() + _client_action_generations[client_id] = int(_client_action_generations.get(client_id, 0)) + 1 + _finalize_action_buttons(String(client_id)) + var row: Dictionary = _client_rows.get(String(client_id), {}) + if not row.is_empty(): + _apply_row_status( + String(client_id), + row.get("status", Client.Status.NOT_CONFIGURED), + "" + ) + _client_action_threads.clear() + + +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) + + +func _should_refresh_client_statuses_on_focus_in() -> bool: + ## Focus-in is part of Godot/editor window activation. Keep automatic refresh, + ## but only through the async/cooldown-protected path; never run a blocking + ## client-status sweep directly from this notification. + return true + + +func _is_floating() -> bool: + var p := get_parent() + while p != null: + if p is Window: + return p != get_tree().root + p = p.get_parent() + return false + + +func _update_redock_visibility() -> void: + if _redock_btn == null: + return + var floating := _is_floating() + if _redock_btn.visible != floating: + _redock_btn.visible = floating + + +func _on_redock() -> void: + # When floating, our Window is NOT the editor root. Closing it triggers + # Godot's internal dock-return logic (same as clicking the window's X). + var win := get_window() + if win != null and win != get_tree().root: + win.close_requested.emit() + + +func _build_margin_container(margin: int = 12) -> MarginContainer: + var margin_container := MarginContainer.new() + margin_container.add_theme_constant_override("margin_left", margin) + margin_container.add_theme_constant_override("margin_right", margin) + margin_container.add_theme_constant_override("margin_top", margin) + margin_container.add_theme_constant_override("margin_bottom", margin) + return margin_container + + +func _build_ui() -> void: + add_theme_constant_override("separation", 8) + + # --- Top row: status indicator + redock button (when floating) --- + var status_row := HBoxContainer.new() + status_row.add_theme_constant_override("separation", 8) + + _status_icon = ColorRect.new() + _status_icon.custom_minimum_size = Vector2(14, 14) + # Amber on first paint — matches the "Starting server…" label text and + # distinguishes from a real disconnect (red). + _status_icon.color = COLOR_AMBER + var icon_center := CenterContainer.new() + icon_center.add_child(_status_icon) + status_row.add_child(icon_center) + + _status_label = Label.new() + # Start in grace state — _update_status will take over on the next frame + # once the connection is available. Never show bare "Disconnected" on + # first paint because that's misleading while the server is still + # spinning up. + _status_label.text = "Starting server…" + _status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + status_row.add_child(_status_label) + + _redock_btn = Button.new() + _redock_btn.text = "Dock" + _redock_btn.tooltip_text = "Return this panel to the editor dock" + _redock_btn.visible = false + _redock_btn.pressed.connect(_on_redock) + status_row.add_child(_redock_btn) + + add_child(status_row) + + # Install-mode line — so a git-clone user doesn't press the yellow Update + # banner below and silently downgrade from main to the last release tag. + # See #144. + _install_label = Label.new() + _install_label.add_theme_color_override("font_color", COLOR_MUTED) + _install_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _install_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _install_label.text = _install_mode_text() + _install_label.tooltip_text = _install_mode_tooltip() + _install_label.mouse_filter = Control.MOUSE_FILTER_STOP + add_child(_install_label) + + # --- 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_panel.add_child(HSeparator.new()) + add_child(_crash_panel) + + _build_mixed_state_banner() + _refresh_mixed_state_banner() + + # --- Update banner (top of dock, hidden until check finds a newer version) --- + _update_banner = VBoxContainer.new() + _update_banner.add_theme_constant_override("separation", 4) + _update_banner.visible = false + + _update_label = Label.new() + _update_label.add_theme_font_size_override("font_size", 15) + _update_label.add_theme_color_override("font_color", Color(1.0, 0.85, 0.3)) + ## Wrap long banner text (e.g. the < 4.4 manual-update guidance) instead + ## of letting a single line stretch the whole dock wide. The dock is a + ## fixed-width side panel, so constrain horizontally and wrap. + _update_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _update_label.size_flags_horizontal = Control.SIZE_FILL + _update_label.custom_minimum_size = Vector2(0, 0) + _update_banner.add_child(_update_label) + + var update_btn_row := HBoxContainer.new() + update_btn_row.add_theme_constant_override("separation", 6) + + _update_btn = Button.new() + _update_btn.text = "Update" + _update_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _update_btn.pressed.connect(_on_update_pressed) + update_btn_row.add_child(_update_btn) + + var release_link := Button.new() + release_link.text = "Release notes" + release_link.pressed.connect(func(): OS.shell_open(UpdateManagerScript.RELEASES_PAGE)) + update_btn_row.add_child(release_link) + + _update_banner.add_child(update_btn_row) + _update_banner.add_child(HSeparator.new()) + + add_child(_update_banner) + + if _update_manager == null: + _update_manager = UpdateManagerScript.new() + _update_manager.setup(_plugin, self) + _update_manager.update_check_completed.connect(_on_update_check_result) + _update_manager.install_state_changed.connect(_on_install_state_changed) + add_child(_update_manager) + _update_manager.check_for_updates.call_deferred() + + # --- Dev-only connection extras (server label + reload button) --- + _dev_section = VBoxContainer.new() + _dev_section.add_theme_constant_override("separation", 6) + 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) + 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) + + add_child(HSeparator.new()) + + # --- Clients --- + var clients_row := HBoxContainer.new() + clients_row.add_theme_constant_override("separation", 8) + + var clients_header := _make_header("Clients") + clients_row.add_child(clients_header) + + _clients_summary_label = Label.new() + _clients_summary_label.add_theme_color_override("font_color", COLOR_MUTED) + _clients_summary_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + clients_row.add_child(_clients_summary_label) + + 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_row.add_child(clients_refresh_btn) + + var clients_open_btn := Button.new() + clients_open_btn.text = "Clients & Settings" + clients_open_btn.tooltip_text = "Open the MCP settings 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_row.add_child(clients_open_btn) + + add_child(clients_row) + + # 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) + add_child(_drift_banner) + + _clients_window = Window.new() + _clients_window.title = "MCP Clients & 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) + + ## Two-tab secondary window: Clients (existing per-client rows) and Tools + ## (domain-exclusion checkboxes for clients that cap total tool count, + ## like Antigravity at 100). Adding a third tab is one more _build_*_tab + ## call and a set_tab_title line — 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) + + 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) + add_child(dev_toggle_row) + + # --- Log section (dev-only) --- + _log_viewer = LogViewerScript.new() + _log_viewer.setup(_log_buffer) + _log_viewer.logging_enabled_changed.connect(_on_log_logging_enabled_changed) + 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 + ## 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) + + 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) + + _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, + "manual_panel": manual_panel, + "manual_text": manual_text, + } + + +# --- Status updates --- + +func _update_status() -> void: + var connected: bool = _connection.is_connected + ## During plugin self-update there's a brief window where this dock + ## script is already the new version (Godot hot-reloads scripts on + ## file change) but `_plugin` is still the old `EditorPlugin` instance + ## (only `set_plugin_enabled(false, true)` re-instantiates that). When + ## the new dock calls a method the old plugin doesn't have, `_process` + ## errors every frame until `McpUpdateManager._reload_after_update` + ## lands. Guard every `_plugin.()` call with `has_method` + ## so that window stays silent. See #168. + var server_status: Dictionary = ( + _plugin.get_server_status() + if _plugin != null and _plugin.has_method("get_server_status") + else {} + ) + var state: int = int(server_status.get("state", ServerStateScript.UNINITIALIZED)) + if ServerStateScript.blocks_client_health(state): + connected = false + + ## One `match`/`elif` chain, one source of truth. Adding a new + ## spawn outcome = one `ServerStateScript` constant + one arm here + + ## one body string in `_crash_body_for_state`. + var status_text: String + var status_color: Color + if _server_restart_in_progress: + status_text = "Restarting server..." + status_color = COLOR_AMBER + elif connected: + status_text = "Connected" + status_color = Color.GREEN + elif state == ServerStateScript.CRASHED: + var exit_ms: int = server_status.get("exit_ms", 0) + status_text = "Server exited after %.1fs" % (exit_ms / 1000.0) + status_color = Color.RED + elif state == ServerStateScript.PORT_EXCLUDED: + status_text = "Port %d reserved by Windows" % ClientConfigurator.http_port() + status_color = Color.RED + elif state == ServerStateScript.INCOMPATIBLE: + status_text = "Incompatible server on port %d" % ClientConfigurator.http_port() + status_color = Color.RED + elif state == ServerStateScript.FOREIGN_PORT: + status_text = "Port %d held by another process" % ClientConfigurator.http_port() + status_color = Color.RED + elif state == ServerStateScript.NO_COMMAND: + status_text = "No server command found" + status_color = Color.RED + elif Time.get_ticks_msec() < _startup_grace_until_msec: + ## Inside startup grace — distinguish from real disconnect so + ## first-run users don't assume it's broken while uvx downloads. + status_text = "Starting server…" + status_color = COLOR_AMBER + else: + status_text = "Disconnected" + status_color = Color.RED + + _update_crash_panel(server_status) + _refresh_server_version_label(server_status) + + var changed: bool = connected != _last_connected or status_text != _last_status_text + if not changed: + return + _last_connected = connected + _last_status_text = status_text + _status_icon.color = status_color + _status_label.text = status_text + + _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 + ) + + var port_picker_visible := ( + state == ServerStateScript.PORT_EXCLUDED + or state == ServerStateScript.FOREIGN_PORT + ) + _port_picker_panel.visible = port_picker_visible + if port_picker_visible: + ## Seed the spinbox with a suggested non-reserved port each time the + ## panel surfaces. Idempotent when the user already has a good + ## candidate queued up. + _port_picker_panel.seed_suggested_port() + + +static func _crash_body_for_state(state: int, server_status: Dictionary = {}) -> String: + ## Single sentence per state. The top status label already names the + ## problem; don't repeat it here. This copy answers "what do I do?". + var port := ClientConfigurator.http_port() + match state: + ServerStateScript.PORT_EXCLUDED: + return "Windows (Hyper-V / WSL2 / Docker) reserved port %d. Pick a free port or try `net stop winnat; net start winnat` in an admin shell." % port + ServerStateScript.INCOMPATIBLE: + var message := str(server_status.get("message", "")) + if bool(server_status.get("can_recover_incompatible", false)): + var expected := str(server_status.get("expected_version", "")) + if expected.is_empty(): + expected = ClientConfigurator.get_plugin_version() + if not message.is_empty(): + return "%s Click Restart Server below to replace it with godot-ai v%s." % [message, expected] + return "Port %d is occupied by an older godot-ai server. Click Restart Server below to replace it with godot-ai v%s." % [port, expected] + if not message.is_empty(): + return message + return "Port %d is occupied by an incompatible server. Stop it or change both HTTP and WS ports." % port + ServerStateScript.FOREIGN_PORT: + return "Another process is already bound to port %d. Pick a free port or stop the other process." % port + ServerStateScript.CRASHED: + ## Both spawn attempts failed on the uvx tier — almost always + ## means PyPI hasn't propagated this version yet (~10 min after + ## publish). `_start_server` already tried `--refresh` once, so + ## the next realistic move is to wait and reload. + if ClientConfigurator.get_server_launch_mode() == "uvx": + var version := ClientConfigurator.get_plugin_version() + 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." % version + 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 "" + + +## Build the mixed-state banner. Hidden until `_refresh_mixed_state_banner` +## confirms `*.update_backup` files exist in the addons tree. Mirrors the +## issue #354 fix shape: structured, agent-readable diagnostic that survives +## a normal editor restart so the user can act on it instead of re-running +## the update. +func _build_mixed_state_banner() -> void: + _mixed_state_banner = VBoxContainer.new() + _mixed_state_banner.add_theme_constant_override("separation", 4) + _mixed_state_banner.visible = false + + _mixed_state_label = Label.new() + _mixed_state_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _mixed_state_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _mixed_state_label.add_theme_color_override("font_color", Color.RED) + _mixed_state_banner.add_child(_mixed_state_label) + + _mixed_state_files = RichTextLabel.new() + _mixed_state_files.bbcode_enabled = false + _mixed_state_files.fit_content = true + _mixed_state_files.autowrap_mode = TextServer.AUTOWRAP_OFF + _mixed_state_files.selection_enabled = true + _mixed_state_files.scroll_active = true + _mixed_state_files.custom_minimum_size = Vector2(0, 90) + _mixed_state_files.add_theme_color_override("default_color", COLOR_AMBER) + _mixed_state_banner.add_child(_mixed_state_files) + + _mixed_state_rescan_btn = Button.new() + _mixed_state_rescan_btn.text = "Re-scan" + _mixed_state_rescan_btn.tooltip_text = ( + "Scan addons/godot_ai/ for *.update_backup files again." + + " Click after restoring the addon manually to dismiss this banner." + ) + _mixed_state_rescan_btn.pressed.connect(func(): _refresh_mixed_state_banner(true)) + _mixed_state_banner.add_child(_mixed_state_rescan_btn) + + _mixed_state_banner.add_child(HSeparator.new()) + add_child(_mixed_state_banner) + + +func _refresh_mixed_state_banner(force: bool = false) -> void: + ## Re-scan button passes `force=true` to bypass the scanner's TTL + ## cache so a manual fix is reflected immediately. + _apply_mixed_state_banner_diagnostic(UpdateMixedStateScript.diagnose( + UpdateMixedStateScript.ADDON_DIR, force + )) + + +## Render seam exposed for testing — the GDScript test suite drives this +## directly with synthetic diagnostics so dock banner contracts can be +## pinned without polluting the real `addons/godot_ai/` tree with backup +## files. Callers from production go through `_refresh_mixed_state_banner`. +func _apply_mixed_state_banner_diagnostic(diag: Dictionary) -> void: + if _mixed_state_banner == null: + return + if diag.is_empty(): + _mixed_state_banner.visible = false + return + _mixed_state_banner.visible = true + ## `Dictionary.get(...)` returns Variant; Label.text is typed String. + ## Explicit cast keeps the type contract honest and dodges some Godot + ## 4.x point-release quirks around Variant→typed-property assignment. + _mixed_state_label.text = String(diag.get("message", "")) + _mixed_state_files.clear() + for path in diag.get("backup_files", []): + _mixed_state_files.add_text(String(path)) + _mixed_state_files.newline() + if bool(diag.get("truncated", false)): + _mixed_state_files.add_text( + "… (list truncated at %d entries)" % UpdateMixedStateScript.MAX_BACKUP_RESULTS + ) + _mixed_state_files.newline() + + +## Signal handler for the extracted LogViewer — the panel owns its own +## display visibility, the dock owns dispatcher logging routing. +func _on_log_logging_enabled_changed(enabled: bool) -> void: + if _connection and _connection.dispatcher: + _connection.dispatcher.mcp_logging = enabled + + +## Signal handler for the extracted PortPickerPanel — the panel range-validates +## the spinbox value before emitting, so we just write the EditorSetting and +## reload the plugin here. +func _on_port_apply_requested(new_port: int) -> void: + var es := EditorInterface.get_editor_settings() + if es != null: + es.set_setting(McpSettings.SETTING_HTTP_PORT, new_port) + ## Every saved client config now points at the old port. Re-sweep so the + ## drift banner appears in the same frame the user committed the change — + ## the plugin reload below will run a second sweep on its own first paint, + ## but we want the banner up immediately rather than after the reload + ## handshake races to completion. See #166. + _refresh_all_client_statuses() + ## Reload after the setting is committed so `_start_server` reads the new + ## port on the re-enabled plugin instance. + _on_reload_plugin() + + +func _refresh_server_label() -> void: + if _server_label == null: + return + var ws_port := ClientConfigurator.ws_port() + if _plugin != null and _plugin.has_method("get_resolved_ws_port"): + ws_port = int(_plugin.get_resolved_ws_port()) + _server_label.text = "WS: %d HTTP: %d" % [ws_port, ClientConfigurator.http_port()] + + +# --- Telemetry setting persistence --- + + +## Returns true if GODOT_AI_DISABLE_TELEMETRY or DISABLE_TELEMETRY is set +## to a truthy value, false if either is set and non-truthy, null if neither +## env var is present at all. +func _is_telemetry_disabled_via_env() -> Variant: + if not (OS.has_environment("GODOT_AI_DISABLE_TELEMETRY") or OS.has_environment("DISABLE_TELEMETRY")): + return null + return McpSettings.env_truthy("GODOT_AI_DISABLE_TELEMETRY") or McpSettings.env_truthy("DISABLE_TELEMETRY") + + +## Reads the telemetry preference, applying env-var override when present. +## Initialises _telemetry_pending_enabled / _telemetry_saved_enabled and +## sets the checkbox state + locked tooltip. Call after _telemetry_toggle +## has been created. +func _load_telemetry_setting() -> void: + var es := EditorInterface.get_editor_settings() + var env_disabled = _is_telemetry_disabled_via_env() + + var enabled: bool + if env_disabled != null: + ## Env var present: resolve and save to EditorSettings so future sessions without + ## the env var honour the last-set value. + enabled = not bool(env_disabled) + if es != null: + es.set_setting(McpSettings.SETTING_TELEMETRY_ENABLED, enabled) + else: + ## No env var: read (or create) the EditorSettings key. + if es != null and es.has_setting(McpSettings.SETTING_TELEMETRY_ENABLED): + enabled = bool(es.get_setting(McpSettings.SETTING_TELEMETRY_ENABLED)) + else: + enabled = true + if es != null: + es.set_setting(McpSettings.SETTING_TELEMETRY_ENABLED, true) + + _telemetry_pending_enabled = enabled + _telemetry_saved_enabled = enabled + + if _telemetry_toggle == null: + return + _telemetry_toggle.set_pressed_no_signal(enabled) + if env_disabled != null: + _telemetry_toggle.disabled = true + _telemetry_toggle.tooltip_text = ( + "Telemetry is controlled by an environment variable " + + "(GODOT_AI_DISABLE_TELEMETRY / DISABLE_TELEMETRY)." + ) + else: + _telemetry_toggle.disabled = false + _telemetry_toggle.tooltip_text = "" + + +func _on_telemetry_toggled(pressed: bool) -> void: + _telemetry_pending_enabled = pressed + _refresh_tools_ui_state() + + +# --- Dev mode persistence --- + + +func _load_dev_mode() -> bool: + # Default OFF for every install (including dev checkouts). Contributors + # who want the extra diagnostic UI (Reload Plugin, MCP log + # panel, Start/Stop Dev Server) can flip the toggle once — editor + # settings persist across sessions. + var es := EditorInterface.get_editor_settings() + if es == null: + return false + if not es.has_setting(DEV_MODE_SETTING): + es.set_setting(DEV_MODE_SETTING, false) + return false + return bool(es.get_setting(DEV_MODE_SETTING)) + + +func _on_dev_mode_toggled(enabled: bool) -> void: + var es := EditorInterface.get_editor_settings() + if es != null: + es.set_setting(DEV_MODE_SETTING, enabled) + _apply_dev_mode_visibility() + _refresh_setup_status() + + +func _apply_dev_mode_visibility() -> void: + 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). + var is_dev := ClientConfigurator.is_dev_checkout() + var uv_missing := not is_dev and ClientConfigurator.check_uv_version().is_empty() + _setup_section.visible = dev or uv_missing + + +# --- Button handlers --- + + +func _do_plugin_reload() -> void: + EditorInterface.set_plugin_enabled("res://addons/godot_ai/plugin.cfg", false) + EditorInterface.set_plugin_enabled("res://addons/godot_ai/plugin.cfg", true) + + +func _on_reload_plugin() -> void: + # Persist a pending plugin_reload telemetry event *before* the + # disable kills the live WebSocket — the new plugin's _enter_tree + # flushes it via `_telemetry.flush_pending_plugin_reload()`. + Telemetry.record_pending_plugin_reload("dock_button") + # Defer the toggle so any in-flight input event finishes propagating + # before the dock (and its Window children) leave the tree. Calling + # set_plugin_enabled synchronously from a button press frees the + # viewport mid-dispatch. + _do_plugin_reload.call_deferred() + + +## Setup-section "Server" row: always report the TRUE running server +## version (from the handshake_ack) rather than the plugin's expected +## version, and highlight the mismatch so self-update drift is visible +## at a glance instead of silently masked by a green label. +## +## Render states, keyed off live version metadata: +## - empty (pre-ack): show the expected version only as an unverified target +## - matches plugin: show it green, no Restart button +## - dev mismatch: show amber with an explicit dev marker +## - release mismatch: show actual vs expected; only surface Restart when the +## plugin has ownership proof for the process +func _refresh_server_version_label(server_status: Dictionary = {}) -> void: + if _setup_server_label == null: + return + var plugin_ver := ClientConfigurator.get_plugin_version() + if server_status.is_empty(): + ## Re-fetch only when called outside `_update_status`'s frame + ## (e.g. from `_apply_new_port`, `_on_restart_*`). Inside the + ## per-frame loop, the caller threads its cached snapshot through + ## so we don't allocate a fresh Dictionary every frame. + server_status = ( + _plugin.get_server_status() + if _plugin != null and _plugin.has_method("get_server_status") + else {} + ) + var server_ver: String = _connection.server_version if _connection != null else "" + if server_ver.is_empty(): + server_ver = str(server_status.get("actual_version", "")) + var expected_ver := str(server_status.get("expected_version", "")) + if expected_ver.is_empty(): + expected_ver = plugin_ver + var state: int = int(server_status.get("state", ServerStateScript.UNINITIALIZED)) + if _server_restart_in_progress and ( + server_ver == expected_ver + or ( + ServerStateScript.is_terminal_diagnosis(state) + and state != ServerStateScript.INCOMPATIBLE + ) + ): + _server_restart_in_progress = false + var text: String + var color: Color + var show_restart := false + if _server_restart_in_progress: + text = "restarting server..." + color = COLOR_AMBER + show_restart = true + elif server_ver.is_empty(): + text = "checking live version (expected godot-ai == %s)" % expected_ver + color = COLOR_MUTED + elif server_ver == expected_ver: + text = "godot-ai == %s" % server_ver + color = Color.GREEN + else: + text = "godot-ai == %s (expected %s)" % [server_ver, expected_ver] + var is_incompatible: bool = state == ServerStateScript.INCOMPATIBLE + color = Color.RED if is_incompatible else COLOR_AMBER + var has_managed_proof: bool = ( + _plugin != null + and _plugin.has_method("can_restart_managed_server") + and _plugin.can_restart_managed_server() + ) + var can_recover: bool = bool(server_status.get("can_recover_incompatible", false)) + show_restart = ( + (not is_incompatible and has_managed_proof) + ## Recoverable incompatible servers get the primary action in + ## the top error panel. Duplicating it in Setup made the UI + ## look like it had multiple restart paths. + or (is_incompatible and can_recover and _crash_restart_btn == null) + ) + if text == _last_rendered_server_text: + _setup_server_label.add_theme_color_override("font_color", color) + _update_restart_button(show_restart) + return + _last_rendered_server_text = text + _setup_server_label.text = text + _setup_server_label.add_theme_color_override("font_color", color) + _update_restart_button(show_restart) + + +func _update_restart_button(visible: bool) -> void: + if _version_restart_btn != null: + _version_restart_btn.visible = visible + _version_restart_btn.disabled = _server_restart_in_progress + _version_restart_btn.text = "Restarting..." if _server_restart_in_progress else "Restart" + if _crash_restart_btn != null: + _crash_restart_btn.disabled = _server_restart_in_progress + _crash_restart_btn.text = "Restarting..." if _server_restart_in_progress else "Restart Server" + + +func _on_restart_stale_server() -> void: + if _plugin == null or _server_restart_in_progress: + return + _server_restart_in_progress = true + _last_rendered_server_text = "" + _refresh_server_version_label() + if not is_inside_tree(): + _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 _plugin == null: + return false + var status: Dictionary = ( + _plugin.get_server_status() + if _plugin.has_method("get_server_status") + else {} + ) + if int(status.get("state", ServerStateScript.UNINITIALIZED)) == ServerStateScript.INCOMPATIBLE: + if _plugin.has_method("recover_incompatible_server"): + return bool(_plugin.recover_incompatible_server()) + elif _plugin.has_method("force_restart_server"): + _plugin.force_restart_server() + return true + return false + + +# --- Setup section --- + +func _refresh_setup_status() -> void: + if _setup_container == null: + return + for child in _setup_container.get_children(): + child.queue_free() + _dev_primary_btn = null + _dev_stop_btn = null + + var is_dev := ClientConfigurator.is_dev_checkout() + if is_dev: + _setup_container.add_child(_make_status_row("Mode", "Dev (venv)", Color.CYAN)) + + var btn_row := HBoxContainer.new() + btn_row.add_theme_constant_override("separation", 4) + btn_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + + _dev_primary_btn = Button.new() + _dev_primary_btn.text = "Restart Dev Server" + _dev_primary_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _dev_primary_btn.pressed.connect(_on_dev_primary_pressed) + btn_row.add_child(_dev_primary_btn) + + _dev_stop_btn = Button.new() + _dev_stop_btn.text = "✕" + _dev_stop_btn.tooltip_text = "Stop the dev server without spawning a replacement." + _dev_stop_btn.pressed.connect(_on_dev_stop_pressed) + btn_row.add_child(_dev_stop_btn) + + _setup_container.add_child(btn_row) + _update_dev_section_buttons() + return + + # User mode — check for uv + var uv_version := ClientConfigurator.check_uv_version() + if not uv_version.is_empty(): + _setup_container.add_child(_make_status_row("uv", uv_version, Color.GREEN)) + ## 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 = "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 addons_path := ProjectSettings.globalize_path("res://addons/godot_ai") + var dir := DirAccess.open(addons_path.get_base_dir()) + if dir == null or not dir.is_link(addons_path): + return "" + var target := dir.read_link(addons_path) + if target.is_empty(): + return "" + if target.is_relative_path(): + target = addons_path.get_base_dir().path_join(target).simplify_path() + return target + + +func _make_status_row(label_text: String, value_text: String, value_color: Color) -> HBoxContainer: + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 6) + + var label := Label.new() + label.text = label_text + label.add_theme_color_override("font_color", COLOR_MUTED) + label.custom_minimum_size.x = 60 + row.add_child(label) + + var value := Label.new() + value.text = value_text + value.add_theme_color_override("font_color", value_color) + row.add_child(value) + + return row + + +## Pure helper for the primary "Restart Dev Server" button. Always enabled +## (clicking with nothing running just spawns fresh); tooltip adapts to +## whether a kill+respawn or fresh spawn is what'll happen. +static func _dev_primary_btn_state(has_managed: bool, dev_running: bool) -> Dictionary: + var port := ClientConfigurator.http_port() + if has_managed or dev_running: + return { + "text": "Restart Dev Server", + "tooltip": ( + "Kill the server on port %d and start a fresh --reload dev server. " + + "Use this to pick up Python source changes that don't bump the version." + ) % port, + } + return { + "text": "Start Dev Server", + "tooltip": "Spawn a --reload dev server on port %d. Auto-restarts when you edit Python sources." % port, + } + + +## Pure helper for the small "✕" stop button — only enabled when a dev +## server is actually running. Stops without respawning; intentionally +## never targets a managed server (that's the lifecycle's responsibility). +static func _dev_stop_btn_state(dev_running: bool) -> Dictionary: + if dev_running: + return {"enabled": true, "tooltip": "Stop the dev server without spawning a replacement."} + return {"enabled": false, "tooltip": "No --reload dev server to stop."} + + +func _on_dev_primary_pressed() -> void: + if _plugin == null or _server_restart_in_progress: + return + if not _plugin.has_method("force_restart_or_start_dev_server"): + return + if _plugin.has_method("record_dev_server_toggle"): + _plugin.record_dev_server_toggle("start") + _server_restart_in_progress = true + _update_dev_section_buttons() + if not is_inside_tree(): + ## Test path — no scene tree means no timer; run synchronously + ## so suite assertions see the dispatch without `await`. + _plugin.force_restart_or_start_dev_server() + _server_restart_in_progress = false + return + call_deferred("_perform_dev_restart_after_feedback") + + +func _on_dev_stop_pressed() -> void: + if _plugin == null: + return + if _plugin.has_method("stop_dev_server"): + _plugin.stop_dev_server() + if _plugin.has_method("record_dev_server_toggle"): + _plugin.record_dev_server_toggle("stop") + _update_dev_section_buttons.call_deferred() + + +func _perform_dev_restart_after_feedback() -> void: + ## Brief paint cycle so the user sees "Restarting..." before the + ## blocking _wait_for_port_free freezes the editor for up to 5s. + await get_tree().create_timer(0.15).timeout + ## Re-check has_method post-await — a self-update mixed-state window + ## could swap _plugin's script class while we were sleeping, leaving + ## the old reference pointing at a class that no longer carries the + ## new method. Same #168 guard pattern as _update_dev_section_buttons. + if _plugin != null and _plugin.has_method("force_restart_or_start_dev_server"): + _plugin.force_restart_or_start_dev_server() + ## start_dev_server's spawn happens via a 0.5s SceneTree timer; give + ## it time to land plus a buffer for the WS reconnect before clearing + ## the busy state. The unconditional clear matches sibling restart + ## buttons — overshoot is fine because subsequent _update_status calls + ## refresh the button against live plugin state. + await get_tree().create_timer(2.0).timeout + _server_restart_in_progress = false + _update_dev_section_buttons() + + +## Single-scan refresh of every dev-section button state. Both buttons +## key off the same `has_managed_server` / `is_dev_server_running` pair, +## and the latter scrapes lsof/ps — so doing the discovery once and +## applying to both avoids the duplicate subprocess fork on every +## connection-state transition. +func _update_dev_section_buttons() -> void: + if _plugin == null: + return + if not (_plugin.has_method("has_managed_server") and _plugin.has_method("is_dev_server_running")): + return + var has_managed: bool = _plugin.has_managed_server() + var dev_running: bool = _plugin.is_dev_server_running() + if _dev_primary_btn != null: + if _server_restart_in_progress: + _dev_primary_btn.disabled = true + _dev_primary_btn.text = "Restarting..." + _dev_primary_btn.tooltip_text = "Killing the current server and respawning..." + else: + var primary_state := _dev_primary_btn_state(has_managed, dev_running) + _dev_primary_btn.disabled = false + _dev_primary_btn.text = primary_state["text"] + _dev_primary_btn.tooltip_text = primary_state["tooltip"] + if _dev_stop_btn != null: + var stop_state := _dev_stop_btn_state(dev_running) + _dev_stop_btn.disabled = (not stop_state["enabled"]) or _server_restart_in_progress + _dev_stop_btn.tooltip_text = stop_state["tooltip"] + + +func _on_install_uv() -> void: + match OS.get_name(): + "Windows": + OS.execute("powershell", ["-ExecutionPolicy", "ByPass", "-c", "irm https://astral.sh/uv/install.ps1 | iex"], [], false) + _: + OS.execute("bash", ["-c", "curl -LsSf https://astral.sh/uv/install.sh | sh"], [], false) + ## Drop the cached uvx path AND the cached `uvx --version` so the + ## next `_refresh_setup_status` finds and reads the freshly-installed + ## binary instead of returning the pre-install "not found" result. + ## Routing through the configurator here 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_uvx_cli_cache() + ClientConfigurator.invalidate_uv_version_cache() + _refresh_setup_status.call_deferred() + + +# --- Client section --- + +func _on_configure_client(client_id: String) -> void: + if _server_blocks_client_health(): + _apply_row_status(client_id, Client.Status.ERROR, _server_blocked_client_message()) + _refresh_clients_summary() + return + _dispatch_client_action(client_id, "configure") + + +func _on_remove_client(client_id: String) -> void: + _dispatch_client_action(client_id, "remove") + + +## Spawn a worker thread for Configure / Remove so a hung CLI can't lock +## the editor (issue #239). The action verbs are: "configure" → calls +## `ClientConfigurator.configure`; "remove" → calls +## `ClientConfigurator.remove`. Both routes shell out to the per-client +## CLI via `McpCliExec.run`, which is wall-clock-bounded. +## +## Per-row in-flight rules: +## - One worker at a time per client (the row's slot). +## - Both buttons disabled while the slot is busy — prevents a +## double-click queueing a stale Configure on top of a still-running +## Remove. +## - The dot turns amber and the row label gets a "Configuring…" / +## "Removing…" suffix so the user can see the click was registered. +func _dispatch_client_action(client_id: String, action: String) -> void: + if _is_self_update_in_progress(): + ## Same gate as the refresh worker — the install window overwrites + ## plugin scripts on disk, and a worker mid-call into them would + ## SIGABRT in `GDScriptFunction::call`. See `_update_manager`. + return + if _client_action_threads.has(client_id): + return + var row: Dictionary = _client_rows.get(client_id, {}) + if row.is_empty(): + return + + _set_row_action_in_flight(client_id, action) + ## Snapshot `server_url` on main: `http_url()` reads + ## `EditorInterface.get_editor_settings()`, which is main-thread-only. + ## The status-refresh worker uses the same pattern — see + ## `_perform_initial_client_status_refresh` and + ## `_request_client_status_refresh`. + var server_url := ClientConfigurator.http_url() + var generation := int(_client_action_generations.get(client_id, 0)) + 1 + _client_action_generations[client_id] = generation + var thread := Thread.new() + _client_action_threads[client_id] = thread + var err := thread.start( + Callable(self, "_run_client_action_worker").bind(client_id, action, server_url, generation) + ) + if err != OK: + _client_action_threads.erase(client_id) + _finalize_action_buttons(client_id) + _apply_row_status(client_id, Client.Status.ERROR, "couldn't start worker thread") + _refresh_clients_summary() + + +func _run_client_action_worker(client_id: String, action: String, server_url: String, generation: int) -> void: + var result: Dictionary + if action == "remove": + result = ClientConfigurator.remove(client_id, server_url) + else: + result = ClientConfigurator.configure(client_id, server_url) + if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN: + call_deferred("_apply_client_action_result", client_id, action, result, generation) + + +func _apply_client_action_result(client_id: String, action: String, result: Dictionary, generation: int) -> void: + if int(_client_action_generations.get(client_id, 0)) != generation: + return + if _refresh_state == ClientRefreshStateScript.SHUTTING_DOWN: + return + if _client_action_threads.has(client_id): + var t: Thread = _client_action_threads[client_id] + if t != null: + t.wait_to_finish() + _client_action_threads.erase(client_id) + _finalize_action_buttons(client_id) + if _server_blocks_client_health(): + _apply_row_status(client_id, Client.Status.ERROR, _server_blocked_client_message()) + _refresh_clients_summary() + return + + var success_status := Client.Status.NOT_CONFIGURED if action == "remove" else Client.Status.CONFIGURED + if result.get("status") == "ok": + _apply_row_status(client_id, success_status) + var row: Dictionary = _client_rows.get(client_id, {}) + if not row.is_empty(): + (row["manual_panel"] as VBoxContainer).visible = false + else: + _apply_row_status(client_id, Client.Status.ERROR, str(result.get("message", "failed"))) + if action == "configure": + _show_manual_command_for(client_id) + _refresh_clients_summary() + + +## In-flight visual: rewrite the verb onto the button the user just +## clicked ("Configuring…" / "Removing…") so the feedback lands where +## their attention already is. Don't pollute the row label — that'd +## clobber any drift hint ("URL out of date") still relevant to the row. +## The dot turns amber so the row reads as "busy" at a glance, not as +## green (premature success) or red (premature failure). Both buttons +## go disabled so a double-click or second action can't queue stale +## work behind the in-flight worker. +func _set_row_action_in_flight(client_id: String, action: String) -> void: + var row: Dictionary = _client_rows.get(client_id, {}) + if row.is_empty(): + return + var configure_btn: Button = row["configure_btn"] + var remove_btn: Button = row["remove_btn"] + configure_btn.disabled = true + remove_btn.disabled = true + if action == "remove": + remove_btn.text = "Removing…" + else: + configure_btn.text = "Configuring…" + (row["dot"] as ColorRect).color = COLOR_AMBER + + +## Re-enable both buttons and reset their text back to canonical labels. +## `_apply_row_status` sets `configure_btn.text` per the resulting +## Status (Configure / Reconfigure / Retry), so we only need to reset +## `remove_btn.text` here — its sibling visibility toggle already +## handles whether to show it at all. +func _finalize_action_buttons(client_id: String) -> void: + var row: Dictionary = _client_rows.get(client_id, {}) + if row.is_empty(): + return + (row["configure_btn"] as Button).disabled = false + var remove_btn: Button = row["remove_btn"] + remove_btn.disabled = false + remove_btn.text = "Remove" + + +func _on_refresh_clients_pressed() -> void: + _request_client_status_refresh(true) + + +func _on_configure_all_clients() -> void: + if _server_blocks_client_health(): + for client_id in _client_rows: + _apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message()) + _refresh_clients_summary() + return + if ClientRefreshStateScript.has_worker_alive(_refresh_state): + return + for client_id in _client_rows: + var status: Client.Status = _client_rows[client_id].get("status", Client.Status.NOT_CONFIGURED) + if status == Client.Status.CONFIGURED: + continue + _on_configure_client(String(client_id)) + _refresh_clients_summary() + + +func _on_open_clients_window() -> void: + if _clients_window == null: + return + ## Re-sweep before the user has time to act on stale dot colors. The request + ## is async/stale-while-refreshing so the popup paints immediately with + ## last-known state; the fresh colors land when the background worker returns. + ## This is an explicit user action, so it bypasses the focus-in cooldown. + _request_client_status_refresh(true) + ## Also re-sync the Tools tab from the persisted setting — another + ## editor instance (or a hand-edit of editor_settings-4.tres) may have + ## changed the excluded list while the window was closed. + _reset_tools_pending_from_setting() + _refresh_tools_ui_state() + # 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 + + +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 = "Settings" + 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() + core_count.add_theme_color_override("font_color", COLOR_MUTED) + core_row.add_child(core_count) + core_row.tooltip_text = ", ".join(ToolCatalog.CORE_TOOLS) + grid.add_child(core_row) + + grid.add_child(HSeparator.new()) + + _tools_domain_checkboxes.clear() + for entry in ToolCatalog.DOMAINS: + _build_tools_domain_row(grid, entry) + + tools_tab.add_child(HSeparator.new()) + + var telemetry_row := HBoxContainer.new() + telemetry_row.add_theme_constant_override("separation", 8) + var telemetry_label := Label.new() + telemetry_label.text = "Telemetry" + telemetry_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + telemetry_row.add_child(telemetry_label) + _telemetry_toggle = CheckButton.new() + _telemetry_toggle.toggled.connect(_on_telemetry_toggled) + telemetry_row.add_child(_telemetry_toggle) + tools_tab.add_child(telemetry_row) + + tools_tab.add_child(HSeparator.new()) + + var footer := HBoxContainer.new() + footer.add_theme_constant_override("separation", 8) + + _tools_apply_btn = Button.new() + _tools_apply_btn.text = "Apply && 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?" + _tools_close_confirm.dialog_text = ( + "You've checked/unchecked domains but haven't clicked Apply.\n" + + "Close the window and discard those changes?" + ) + _tools_close_confirm.ok_button_text = "Discard" + _tools_close_confirm.confirmed.connect(_on_tools_discard_confirmed) + add_child(_tools_close_confirm) + + _reset_tools_pending_from_setting() + _refresh_tools_ui_state() + + +func _build_tools_domain_row(parent: VBoxContainer, entry: Dictionary) -> void: + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 8) + + var chk := CheckBox.new() + chk.button_pressed = true # default; `_reset_tools_pending_from_setting` corrects + chk.toggled.connect(_on_tools_domain_toggled.bind(String(entry["id"]))) + row.add_child(chk) + + var name_label := Label.new() + name_label.text = String(entry["label"]) + name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(name_label) + + var count_label := Label.new() + count_label.text = "%d tools" % int(entry["count"]) + count_label.add_theme_color_override("font_color", COLOR_MUTED) + row.add_child(count_label) + + ## Hover tooltip = flat list of tool names in this domain. Lets the + ## user decide without leaving the dock (e.g. "I just want to drop + ## `animation_preset_*` — do I lose anything else?"). + var tools_list: Array = entry.get("tools", []) + row.tooltip_text = ", ".join(tools_list) + name_label.tooltip_text = row.tooltip_text + count_label.tooltip_text = row.tooltip_text + + parent.add_child(row) + _tools_domain_checkboxes[String(entry["id"])] = chk + + +func _reset_tools_pending_from_setting() -> void: + ## Read the saved setting → pending/saved arrays, then sync checkbox state. + ## Unknown domain names in the setting (e.g. from an older plugin + ## version) are silently dropped — matches the Python side's + ## warn-and-continue behavior when it sees an unknown name. + 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() + + +func _on_tools_domain_toggled(pressed: bool, domain_id: String) -> void: + var idx := _tools_pending_excluded.find(domain_id) + if pressed and idx != -1: + _tools_pending_excluded.remove_at(idx) + elif not pressed and idx == -1: + _tools_pending_excluded.append(domain_id) + _tools_pending_excluded.sort() + _refresh_tools_ui_state() + + +func _refresh_tools_ui_state() -> void: + if _tools_count_label == null: + return + var enabled := ToolCatalog.enabled_tool_count(_tools_pending_excluded) + var total := ToolCatalog.total_tool_count() + _tools_count_label.text = "%d / %d" % [enabled, total] + var dirty := _settings_are_dirty() + _tools_dirty_warning.visible = dirty + _tools_apply_btn.disabled = not dirty + ## Color the count when the user is over Antigravity's cap — a soft + ## signal that their selection still won't fit. 100 is the Antigravity + ## limit; other clients may cap higher, so this is advisory only. + if enabled > 100: + _tools_count_label.add_theme_color_override("font_color", COLOR_AMBER) + else: + _tools_count_label.remove_theme_color_override("font_color") + + +func _on_tools_apply() -> void: + var canonical_excluded := ToolCatalog.canonical(_tools_pending_excluded) + var es := EditorInterface.get_editor_settings() + if es != null: + es.set_setting(McpSettings.SETTING_EXCLUDED_DOMAINS, canonical_excluded) + es.set_setting(McpSettings.SETTING_TELEMETRY_ENABLED, _telemetry_pending_enabled) + _tools_saved_excluded = _tools_pending_excluded.duplicate() + _telemetry_saved_enabled = _telemetry_pending_enabled + _refresh_tools_ui_state() + ## Plugin reload respawns the server with the new `--exclude-domains` flag + ## (see `plugin.gd::_build_server_flags`) and telemetry option. Mirrors the + ## port-change Apply flow. + _on_reload_plugin() + + +func _on_tools_reset() -> void: + ## Resets only the tool-domain exclusions, not the telemetry toggle. + ## Telemetry is a privacy preference users typically want to set once + ## and have honored — flipping it back to "on" via a generic Reset + ## button would be a surprising privacy regression. The button label + ## is scoped to tools accordingly. + _tools_pending_excluded = PackedStringArray() + for id in _tools_domain_checkboxes: + var chk: CheckBox = _tools_domain_checkboxes[id] + chk.set_pressed_no_signal(true) + _refresh_tools_ui_state() + + +func _show_tools_close_confirm() -> void: + if _tools_close_confirm == null: + return + _tools_close_confirm.popup_centered() + + +func _on_tools_discard_confirmed() -> void: + _reset_tools_pending_from_setting() + _refresh_tools_ui_state() + if _clients_window != null: + _clients_window.hide() + + +func _refresh_clients_summary() -> void: + # Count from cached row status values — `_apply_row_status` is the single + # source of truth, and reading cached status avoids re-running + # filesystem/CLI-hitting checks on every refresh. The same cache re-derives + # the drift banner so per-row mutations (Configure/Reconfigure/Remove on a + # row in the Clients & Tools window) keep the dock-level banner in sync + # without an extra sweep. See #166 and #226. + if _clients_summary_label == null: + return + var configured := 0 + var mismatched_ids: Array[String] = [] + for client_id in _client_rows: + var status: Client.Status = _client_rows[client_id].get("status", Client.Status.NOT_CONFIGURED) + if status == Client.Status.CONFIGURED: + configured += 1 + elif status == Client.Status.CONFIGURED_MISMATCH: + mismatched_ids.append(client_id) + var text := "%d / %d configured" % [configured, _client_rows.size()] + if mismatched_ids.size() > 0: + text += " (%d stale)" % mismatched_ids.size() + if ClientRefreshStateScript.should_show_checking_badge(_refresh_state): + text += ( + " (checking...)" + if _refresh_state != ClientRefreshStateScript.RUNNING_TIMED_OUT + else " (client probe still running)" + ) + _clients_summary_label.text = text + if _client_configure_all_btn != null: + _client_configure_all_btn.disabled = ClientRefreshStateScript.has_worker_alive(_refresh_state) + _refresh_drift_banner(mismatched_ids) + + +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 + + +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 _refresh_all_client_statuses() -> void: + ## Compatibility wrapper for older explicit call sites. Treat this as a manual + ## refresh: it bypasses focus-in cooldown but still runs probes off the editor + ## main thread. + if _server_blocks_client_health(): + for client_id in _client_rows: + _apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message()) + _refresh_clients_summary() + return + _request_client_status_refresh(true) + + +func _is_client_status_refresh_in_cooldown() -> bool: + if _last_client_status_refresh_completed_msec <= 0: + return false + return Time.get_ticks_msec() - _last_client_status_refresh_completed_msec < CLIENT_STATUS_REFRESH_COOLDOWN_MSEC + + +func _has_client_status_refresh_timed_out() -> bool: + if not ClientRefreshStateScript.has_worker_alive(_refresh_state): + return false + if _client_status_refresh_started_msec <= 0: + return false + return Time.get_ticks_msec() - _client_status_refresh_started_msec >= CLIENT_STATUS_REFRESH_TIMEOUT_MSEC + + +func _check_client_status_refresh_timeout() -> void: + if not _has_client_status_refresh_timed_out(): + return + if _refresh_state == ClientRefreshStateScript.RUNNING_TIMED_OUT: + return + _refresh_state = ClientRefreshStateScript.RUNNING_TIMED_OUT + _refresh_clients_summary() + + +func _abandon_client_status_refresh_thread() -> void: + ## GDScript cannot interrupt a blocking `OS.execute(..., true)` call in a + ## worker. If a CLI probe hangs, orphan this run, bump the generation so any + ## late result becomes a no-op, and let a forced/manual refresh start a fresh + ## probe slot. Completed orphan threads are pruned from `_process`. + _client_status_refresh_generation += 1 + if _client_status_refresh_thread != null: + _orphaned_client_status_refresh_threads.append(_client_status_refresh_thread) + _client_status_refresh_thread = null + if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN: + _refresh_state = ClientRefreshStateScript.IDLE + ## Reset the full pending-request triplet, not just the + ## focus-in / cooldown half. A timed-out worker has already + ## warmed bytecode, so any stale `_pending_initial` from an + ## earlier deferred-during-busy startup is no longer load-bearing + ## — leaving it set would cause `_retry_deferred_*` to dispatch + ## `_perform_initial_*` a second time after this abandon + ## (which would then no-op because no fresh worker is needed + ## but still re-warm bytecode and walk the row set redundantly). + _client_status_refresh_pending = false + _client_status_refresh_pending_force = false + _client_status_refresh_pending_initial = false + _client_status_refresh_started_msec = 0 + _refresh_clients_summary() + + +func _prune_orphaned_client_status_refresh_threads() -> void: + for i in range(_orphaned_client_status_refresh_threads.size() - 1, -1, -1): + var thread := _orphaned_client_status_refresh_threads[i] + if thread == null: + _orphaned_client_status_refresh_threads.remove_at(i) + elif not thread.is_alive(): + thread.wait_to_finish() + _orphaned_client_status_refresh_threads.remove_at(i) + + +func _perform_initial_client_status_refresh() -> void: + ## Pre-warm strategy bytecode on main, then hand every client probe + ## (JSON / TOML / CLI alike) to the worker. + ## + ## Godot's GDScript hot-reload of overwritten plugin files is lazy: the + ## bytecode swap happens on first dereference, not at `set_plugin_enabled` + ## time. A worker thread spawned from a fresh `_build_ui` walks into + ## `_json_strategy.*` / `_cli_strategy.*` / `client_configurator.*` while + ## bytecode pages are mid-swap → SIGABRT. Dereferencing those scripts on + ## main first forces the swap to complete here; the worker then finds + ## stable bytecode. Filesystem signals don't bracket the swap window + ## (they fire before bytecode replacement), and FOCUS_IN doesn't fire on + ## in-place plugin reload because the editor stays focused — so neither + ## works as a gate. See #233 / #235. + ## + ## Phase 1 (sync, on main): a single explicit `_warm_strategy_bytecode` + ## call invokes a pure-memory helper on each strategy script — + ## `_json_strategy.gd`, `_toml_strategy.gd`, `_cli_strategy.gd`, plus + ## `client_configurator.gd` via `client_ids()` / `get_by_id`. No disk, + ## no `OS.execute`, no JSON parse on main. `client_status_probe_snapshot` + ## per client adds the `installed` flag and (for CLI clients) a cached + ## CLI path to each probe. + ## + ## Phase 2 (worker): every probe — JSON, TOML, CLI — runs through the + ## same `_run_client_status_refresh_worker` pipeline. Disk reads + JSON + ## parses for the ~17 non-CLI clients now happen off the main thread, + ## so the dock paints immediately on cold open instead of stalling + ## behind ~16 sync `FileAccess.open` + `JSON.parse_string` calls. + ## + ## No-op outside the tree — GDScript tests instantiate via `new()`. + if not is_inside_tree(): + return + if _client_rows.is_empty(): + return + if _refresh_state == ClientRefreshStateScript.SHUTTING_DOWN: + return + if _is_self_update_in_progress(): + return + if _is_editor_filesystem_busy(): + _defer_initial_client_status_refresh_until_filesystem_ready() + return + if ClientRefreshStateScript.has_worker_alive(_refresh_state): + return + + if _server_blocks_client_health(): + for client_id in _client_rows: + _apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message()) + _refresh_clients_summary() + return + + _warm_strategy_bytecode() + + var generation := _begin_client_status_refresh_run() + var server_url := ClientConfigurator.http_url() + var all_probes: Array[Dictionary] = [] + + for client_id in _client_rows: + var probe := ClientConfigurator.client_status_probe_snapshot(String(client_id)) + if probe.is_empty(): + continue + all_probes.append(probe) + _refresh_clients_summary() + + if all_probes.is_empty(): + _finalize_completed_refresh() + return + + _client_status_refresh_thread = Thread.new() + var err := _client_status_refresh_thread.start( + Callable(self, "_run_client_status_refresh_worker").bind( + all_probes, server_url, generation + ) + ) + if err != OK: + _refresh_state = ClientRefreshStateScript.IDLE + _client_status_refresh_thread = null + _refresh_clients_summary() + + +## Force GDScript's lazy bytecode swap to complete for every script the +## worker thread will reach into. Each call is pure-memory — no disk, no +## network, no `OS.execute` — so it only costs the bytecode dereference +## itself. See `_perform_initial_client_status_refresh` for context and +## #233 / #235 for the SIGABRT this exists to prevent. +func _warm_strategy_bytecode() -> void: + var ids := ClientConfigurator.client_ids() + if ids.is_empty(): + return + var any_client := ClientRegistry.get_by_id(String(ids[0])) + if any_client != null: + JsonStrategy.verify_entry(any_client, {}, "") + TomlStrategy.format_body(PackedStringArray(), "") + CliStrategy.format_args(PackedStringArray(), "", "") + + +func _begin_client_status_refresh_run() -> int: + ## Marks a refresh as starting and returns the new generation token. + ## Generation is bumped here (not at completion) so that a worker callback + ## arriving after `_abandon_client_status_refresh_thread` or `_exit_tree` + ## fires can be detected as stale via generation mismatch. + _refresh_state = ClientRefreshStateScript.RUNNING + _client_status_refresh_pending = false + _client_status_refresh_pending_force = false + _client_status_refresh_started_msec = Time.get_ticks_msec() + _client_status_refresh_generation += 1 + _refresh_clients_summary() + return _client_status_refresh_generation + + +func _finalize_completed_refresh() -> void: + ## Stamps cooldown and clears in-flight state. Called at the end of every + ## refresh that successfully applied results — the worker callback path + ## and the no-CLI fast path in `_perform_initial_client_status_refresh`. + _last_client_status_refresh_completed_msec = Time.get_ticks_msec() + if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN: + _refresh_state = ClientRefreshStateScript.IDLE + _refresh_clients_summary() + + +func _request_client_status_refresh(force: bool = false) -> bool: + ## Stale-while-refreshing: do not clear dots, summary, or the drift banner + ## when a refresh is requested. The existing UI remains visible until the + ## background worker's result is applied on the main thread. + if _server_blocks_client_health(): + for client_id in _client_rows: + _apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message()) + _refresh_clients_summary() + return false + if _is_self_update_in_progress(): + ## Self-update is overwriting plugin scripts on disk; spawning a worker + ## now would crash it inside `GDScriptFunction::call` once the bytecode + ## swap reaches a script the worker is mid-call into. Focus-in / + ## manual button / cooldown timer all funnel through here, so one + ## gate covers every spawn path during the install window. The flag + ## lives on `_update_manager` and dies with the dock instance during + ## `set_plugin_enabled(false)`. + return false + if ClientRefreshStateScript.has_worker_alive(_refresh_state): + if force and _has_client_status_refresh_timed_out(): + _abandon_client_status_refresh_thread() + else: + _client_status_refresh_pending = true + _client_status_refresh_pending_force = _client_status_refresh_pending_force or force + _refresh_clients_summary() + return false + if _refresh_state == ClientRefreshStateScript.SHUTTING_DOWN: + return false + if not force and _is_client_status_refresh_in_cooldown(): + return false + if _client_rows.is_empty(): + return false + if _is_editor_filesystem_busy(): + if force: + _defer_client_status_refresh_until_filesystem_ready(force) + return false + + ## Manual refresh (any `force=true` path: button click, popup open, + ## external API caller) implies "may have installed a CLI since the + ## last sweep" — flush CliFinder so freshly-installed binaries get + ## re-detected. Focus-in (`force=false`) stays cached so the cheap + ## case stays cheap. Per-CLI invalidation + ## (`invalidate_uvx_cli_cache`) still pairs with specific events + ## like `_on_install_uv` where the binary name is known. + if force: + ClientConfigurator.invalidate_cli_cache() + + ## Force the bytecode swap on the same scripts the worker will reach + ## into — same #233/#235 guard `_perform_initial_*` already had. + ## Without this, a manual refresh dispatched before the initial sweep + ## has run (e.g. user clicks Refresh during the deferred-initial + ## window after `_defer_client_status_refresh_until_filesystem_ready` + ## cleared `_pending_initial`) walks into mid-swap bytecode and + ## SIGABRTs. + _warm_strategy_bytecode() + + var client_probes: Array[Dictionary] = [] + for client_id in _client_rows: + client_probes.append(ClientConfigurator.client_status_probe_snapshot(String(client_id))) + var server_url := ClientConfigurator.http_url() + + var generation := _begin_client_status_refresh_run() + _client_status_refresh_thread = Thread.new() + var err := _client_status_refresh_thread.start( + Callable(self, "_run_client_status_refresh_worker").bind(client_probes, server_url, generation) + ) + if err != OK: + _refresh_state = ClientRefreshStateScript.IDLE + _client_status_refresh_thread = null + _refresh_clients_summary() + return false + return true + + +func _is_editor_filesystem_busy() -> bool: + var fs := EditorInterface.get_resource_filesystem() + return fs != null and fs.is_scanning() + + +func _defer_initial_client_status_refresh_until_filesystem_ready() -> void: + _refresh_state = ClientRefreshStateScript.DEFERRED_FOR_FILESYSTEM + _client_status_refresh_pending_initial = true + + +func _defer_client_status_refresh_until_filesystem_ready(force: bool) -> void: + ## Godot can still be reparsing/reloading plugin scripts while the editor + ## filesystem is busy. Do not spawn a worker into that window: the worker + ## can call plugin GDScript while the main thread is reloading it, which + ## crashes in `GDScriptFunction::call`. + ## + ## A manual refresh request is more recent intent than any earlier + ## deferred-initial sweep, so we clear `_pending_initial` here. + ## `_request_client_status_refresh` warms strategy bytecode itself + ## now (see #233/#235), so the safety net the initial path provided + ## still applies to the replayed manual refresh. + _refresh_state = ClientRefreshStateScript.DEFERRED_FOR_FILESYSTEM + _client_status_refresh_pending_force = _client_status_refresh_pending_force or force + _client_status_refresh_pending_initial = false + + +func _retry_deferred_client_status_refresh() -> void: + if _refresh_state != ClientRefreshStateScript.DEFERRED_FOR_FILESYSTEM: + return + if _is_self_update_in_progress(): + return + if _is_editor_filesystem_busy(): + return + + var initial := _client_status_refresh_pending_initial + var force := _client_status_refresh_pending_force + _refresh_state = ClientRefreshStateScript.IDLE + _client_status_refresh_pending_force = false + _client_status_refresh_pending_initial = false + if initial: + _perform_initial_client_status_refresh() + else: + _request_client_status_refresh(force) + + +func _run_client_status_refresh_worker(client_probes: Array[Dictionary], server_url: String, generation: int) -> void: + var results: Dictionary = {} + for probe in client_probes: + var client_id := String(probe.get("id", "")) + if client_id.is_empty(): + continue + var details := ClientConfigurator.check_status_details_for_url_with_cli_path( + client_id, + server_url, + String(probe.get("cli_path", "")) + ) + var installed := bool(probe.get("installed", false)) + results[client_id] = { + "status": details.get("status", Client.Status.NOT_CONFIGURED), + "installed": installed, + "error_msg": details.get("error_msg", ""), + } + if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN: + call_deferred("_apply_client_status_refresh_results", results, generation) + + +func _apply_client_status_refresh_results(results: Dictionary, generation: int) -> void: + if generation != _client_status_refresh_generation or _refresh_state == ClientRefreshStateScript.SHUTTING_DOWN: + return + if _client_status_refresh_thread != null: + _client_status_refresh_thread.wait_to_finish() + _client_status_refresh_thread = null + if _server_blocks_client_health(): + for client_id in _client_rows: + _apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message()) + _finalize_completed_refresh() + return + + for client_id in results: + ## Skip rows whose Configure / Remove worker is still running so the + ## status refresh doesn't overwrite the "Configuring…" / "Removing…" + ## badge with a stale dot color. The action's own completion handler + ## will repaint the row when it lands. + if _client_action_threads.has(String(client_id)): + continue + var result: Dictionary = results[client_id] + _apply_row_status( + String(client_id), + result.get("status", Client.Status.NOT_CONFIGURED), + str(result.get("error_msg", "")), + result.get("installed", false) + ) + _finalize_completed_refresh() + + if _client_status_refresh_pending: + var pending_force := _client_status_refresh_pending_force + _client_status_refresh_pending = false + _client_status_refresh_pending_force = false + _request_client_status_refresh(pending_force) + + +func _server_blocks_client_health() -> bool: + if _plugin == null or not _plugin.has_method("get_server_status"): + return false + var status: Dictionary = _plugin.get_server_status() + return ServerStateScript.blocks_client_health( + int(status.get("state", ServerStateScript.UNINITIALIZED)) + ) + + +func _server_blocked_client_message() -> String: + if _plugin == null or not _plugin.has_method("get_server_status"): + return "server incompatible" + var status: Dictionary = _plugin.get_server_status() + var message := str(status.get("message", "")) + return message if not message.is_empty() else "server incompatible" + + +func _refresh_drift_banner(mismatched_ids: Array[String]) -> void: + if _drift_banner == null: + return + ## Sort so set-equality is order-independent — `_client_rows` iteration + ## order is dict-insertion order, but a future change to the iteration + ## site shouldn't make us repaint identical content. + mismatched_ids = mismatched_ids.duplicate() + mismatched_ids.sort() + if mismatched_ids == _last_mismatched_ids: + return + _last_mismatched_ids = mismatched_ids + if mismatched_ids.is_empty(): + _drift_banner.visible = false + return + var names: Array[String] = [] + for id in mismatched_ids: + names.append(ClientConfigurator.client_display_name(id)) + ## Active server URL is already shown on the WS:/HTTP: line above the + ## Clients section, so it doesn't need to repeat here. Lead with the + ## client names — that's the only thing the user can act on. + var verb := "needs" if mismatched_ids.size() == 1 else "need" + _drift_label.text = "%s %s to be reconfigured." % [", ".join(names), verb] + _drift_banner.visible = true + + +func _on_reconfigure_mismatched() -> void: + ## Re-Configure every client whose URL is currently stale. Iterates the + ## cached list from the most recent sweep instead of re-running + ## `check_status` per row (saves ~18 filesystem reads per click). The + ## trailing `_refresh_all_client_statuses()` re-sweeps anyway, so any + ## entries the user manually fixed between sweep and click get re-counted + ## as CONFIGURED there. + for client_id in _last_mismatched_ids: + if _client_rows.has(client_id): + _on_configure_client(client_id) + _refresh_all_client_statuses() + + +func _apply_row_status( + client_id: String, + status: Client.Status, + error_msg: String = "", + installed_override: Variant = null, +) -> void: + var row: Dictionary = _client_rows.get(client_id, {}) + if row.is_empty(): + return + row["status"] = status + var dot: ColorRect = row["dot"] + var configure_btn: Button = row["configure_btn"] + var remove_btn: Button = row["remove_btn"] + var name_label: Label = row["name_label"] + var base_name := ClientConfigurator.client_display_name(client_id) + match status: + Client.Status.CONFIGURED: + dot.color = Color.GREEN + configure_btn.text = "Reconfigure" + remove_btn.visible = true + name_label.text = base_name + Client.Status.NOT_CONFIGURED: + dot.color = COLOR_MUTED + configure_btn.text = "Configure" + remove_btn.visible = false + var installed: bool = installed_override if installed_override != null else ClientConfigurator.is_installed(client_id) + name_label.text = base_name if installed else "%s (not detected)" % base_name + Client.Status.CONFIGURED_MISMATCH: + ## Amber matches the dock-level drift banner so a glance at the + ## row + the banner read as the same condition. + dot.color = COLOR_AMBER + configure_btn.text = "Reconfigure" + remove_btn.visible = true + name_label.text = "%s (URL out of date)" % base_name + _: + dot.color = Color.RED + configure_btn.text = "Retry" + remove_btn.visible = false + name_label.text = "%s — %s" % [base_name, error_msg] if not error_msg.is_empty() else base_name + + +# --- Update check & self-update --- + +## Tolerates a null manager so test fixtures that build the dock without +## `_build_ui()` don't false-positive on the worker-spawn gate. +func _is_self_update_in_progress() -> bool: + return _update_manager != null and bool(_update_manager.is_install_in_flight()) + + +func _on_update_pressed() -> void: + if _update_manager != null: + _update_manager.start_install() + + +func _on_update_check_result(result: Dictionary) -> void: + _update_label.text = String(result.get("label_text", "")) + _update_banner.visible = true + + +## Apply only the keys present so the manager can ship partial updates +## (e.g. button-text-only during the download phase) without clobbering +## banner state. +func _on_install_state_changed(state: Dictionary) -> void: + if state.has("button_text") and _update_btn != null: + _update_btn.text = String(state["button_text"]) + if state.has("button_disabled") and _update_btn != null: + _update_btn.disabled = bool(state["button_disabled"]) + if state.has("label_text") and _update_label != null: + _update_label.text = String(state["label_text"]) + if state.has("banner_visible") and _update_banner != null: + _update_banner.visible = bool(state["banner_visible"]) + if String(state.get("outcome", "")) == "success" and _update_label != null: + ## Visual confirmation for the pre-4.4 "Updated! Restart the editor." + ## terminal state — the only outcome the manager paints green for. + _update_label.add_theme_color_override("font_color", Color.GREEN) diff --git a/addons/godot_ai/mcp_dock.gd.uid b/addons/godot_ai/mcp_dock.gd.uid new file mode 100644 index 0000000..5868719 --- /dev/null +++ b/addons/godot_ai/mcp_dock.gd.uid @@ -0,0 +1 @@ +uid://b8yknttdjanm5 diff --git a/addons/godot_ai/plugin.cfg b/addons/godot_ai/plugin.cfg new file mode 100644 index 0000000..5b36d7b --- /dev/null +++ b/addons/godot_ai/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="Godot AI" +description="MCP server and AI tools for Godot" +author="Godot AI" +version="2.7.3" +script="plugin.gd" diff --git a/addons/godot_ai/plugin.gd b/addons/godot_ai/plugin.gd new file mode 100644 index 0000000..ea91ac0 --- /dev/null +++ b/addons/godot_ai/plugin.gd @@ -0,0 +1,1690 @@ +@tool +extends EditorPlugin + +const GAME_HELPER_AUTOLOAD_NAME := "_mcp_game_helper" +const GAME_HELPER_AUTOLOAD_PATH := "res://addons/godot_ai/runtime/game_helper.gd" + +## Editor-process Logger subclass — captures parse errors, @tool runtime +## errors, and push_error/push_warning so the LLM can read them via +## `logs_read(source="editor")`. Loaded dynamically because +## `extends Logger` requires Godot 4.5+. The logger script lives in the +## `.gdignore`'d `runtime/loggers/` folder so Godot's editor scan never +## parses it (no "Could not find base class Logger" error on < 4.5), and +## LoggerLoader compiles it from source at runtime only after the +## ClassDB.class_exists("Logger") gate below. See issue #231 / #475. +const LoggerLoader := preload("res://addons/godot_ai/runtime/logger_loader.gd") + +## EditorSettings keys used to remember which server process the plugin +## spawned — survives editor restarts, lets a later editor session adopt +## and manage a server it didn't spawn itself. See #135. +const MANAGED_SERVER_PID_SETTING := "godot_ai/managed_server_pid" +const MANAGED_SERVER_VERSION_SETTING := "godot_ai/managed_server_version" +const MANAGED_SERVER_WS_PORT_SETTING := "godot_ai/managed_server_ws_port" +const UPDATE_RELOAD_RUNNER_SCRIPT := preload("res://addons/godot_ai/update_reload_runner.gd") + +## Preloaded so `_stop_server` / `force_restart_server` have a local script +## dependency for the cleanup helper. See utils/uv_cache_cleanup.gd for what +## this does and why it lives next to the server-stop hot path. +const UvCacheCleanup := preload("res://addons/godot_ai/utils/uv_cache_cleanup.gd") + +## Server lifecycle + port discovery extracted from this file (#297 PR 5). +## State enums + version-check seam extracted in PR 6 (#297). Plugin.gd +## keeps thin shims so the dock and characterization tests see an +## unchanged public surface; spawn-machinery state now lives in the +## lifecycle manager. +const ServerLifecycleManager := preload("res://addons/godot_ai/utils/server_lifecycle.gd") +const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd") +const ServerStateScript := preload("res://addons/godot_ai/utils/mcp_server_state.gd") +const StartupPathScript := preload("res://addons/godot_ai/utils/mcp_startup_path.gd") + +## Plugin-class scripts used by this file. The script-local preload aliases +## are ordinary dependency shorthand and keep construction sites compact. +## They are not the self-update safety boundary; #398 was stale Script-object +## content from a mixed old/new snapshot, fixed by the runner's single-phase +## write-before-scan model. +const Connection := preload("res://addons/godot_ai/connection.gd") +const Dispatcher := preload("res://addons/godot_ai/dispatcher.gd") +const Telemetry := preload("res://addons/godot_ai/telemetry.gd") +const LogBuffer := preload("res://addons/godot_ai/utils/log_buffer.gd") +const GameLogBuffer := preload("res://addons/godot_ai/utils/game_log_buffer.gd") +const EditorLogBuffer := preload("res://addons/godot_ai/utils/editor_log_buffer.gd") +const Dock := preload("res://addons/godot_ai/mcp_dock.gd") +const DebuggerPlugin := preload("res://addons/godot_ai/debugger/mcp_debugger_plugin.gd") +const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd") +const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd") + +## Handlers — preloaded as consts instead of registered via `class_name` so +## they don't pollute the project-wide global scope. A user project that +## happens to define its own `InputHandler`, `SceneHandler`, etc. would +## otherwise hard-error on plugin enable. +const EditorHandler := preload("res://addons/godot_ai/handlers/editor_handler.gd") +const SceneHandler := preload("res://addons/godot_ai/handlers/scene_handler.gd") +const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd") +const ProjectHandler := preload("res://addons/godot_ai/handlers/project_handler.gd") +const ClientHandler := preload("res://addons/godot_ai/handlers/client_handler.gd") +const ScriptHandler := preload("res://addons/godot_ai/handlers/script_handler.gd") +const ResourceHandler := preload("res://addons/godot_ai/handlers/resource_handler.gd") +const ApiHandler := preload("res://addons/godot_ai/handlers/api_handler.gd") +const FilesystemHandler := preload("res://addons/godot_ai/handlers/filesystem_handler.gd") +const SignalHandler := preload("res://addons/godot_ai/handlers/signal_handler.gd") +const AutoloadHandler := preload("res://addons/godot_ai/handlers/autoload_handler.gd") +const InputHandler := preload("res://addons/godot_ai/handlers/input_handler.gd") +const TestHandler := preload("res://addons/godot_ai/handlers/test_handler.gd") +const BatchHandler := preload("res://addons/godot_ai/handlers/batch_handler.gd") +const UiHandler := preload("res://addons/godot_ai/handlers/ui_handler.gd") +const ThemeHandler := preload("res://addons/godot_ai/handlers/theme_handler.gd") +const AnimationHandler := preload("res://addons/godot_ai/handlers/animation_handler.gd") +const MaterialHandler := preload("res://addons/godot_ai/handlers/material_handler.gd") +const ParticleHandler := preload("res://addons/godot_ai/handlers/particle_handler.gd") +const CameraHandler := preload("res://addons/godot_ai/handlers/camera_handler.gd") +const AudioHandler := preload("res://addons/godot_ai/handlers/audio_handler.gd") +const PhysicsShapeHandler := preload("res://addons/godot_ai/handlers/physics_shape_handler.gd") +const EnvironmentHandler := preload("res://addons/godot_ai/handlers/environment_handler.gd") +const TextureHandler := preload("res://addons/godot_ai/handlers/texture_handler.gd") +const CurveHandler := preload("res://addons/godot_ai/handlers/curve_handler.gd") +const ControlDrawRecipeHandler := preload("res://addons/godot_ai/handlers/control_draw_recipe_handler.gd") + +## The Python server writes its own PID here on startup (passed as +## `--pid-file`) and unlinks on clean exit. Deterministic replacement +## for scraping `netstat -ano` to find the port owner — especially on +## Windows where `OS.kill` on the uvx launcher doesn't take the Python +## child with it, and the scrape was the only path to the real PID. +## See issue for #154-era Windows update friction. +## Re-export of PortResolver.SERVER_PID_FILE so the spawn flags, the +## resolver, and characterization tests share one source of truth. +const SERVER_PID_FILE := PortResolver.SERVER_PID_FILE + +## How long we watch the spawned server for early exit. If the process is +## still alive when this expires, we stop watching. Mid-session crashes +## after this point get caught by the WebSocket disconnect flow. +const SERVER_WATCH_MS := 30 * 1000 +## Python's import graph (FastMCP + Rich + uvicorn) plus the pid-file write +## take a beat on cold starts, especially on Windows. Hold off on declaring +## a spawn a crash until this window elapses so the watch loop has time to +## observe either the pid-file (dev venv) or the port listening (uvx). +const SPAWN_GRACE_MS := 5 * 1000 +const SERVER_STATUS_PATH := "/godot-ai/status" +const SERVER_STATUS_PROBE_TIMEOUT_MS := 800 +const SERVER_HANDSHAKE_VERSION_TIMEOUT_MS := 5 * 1000 +const STARTUP_TRACE_COUNTER_NAMES := [ + "powershell", + "netstat", + "netsh", + "lsof", + "http_status_probe", + "server_command_discovery", +] + +## Untyped on purpose — see policy below. Type fences move to handler `_init` +## sites that take typed parameters. +## +## Self-update field and load-surface policy: plugin entry-load fields that +## survive reload stay untyped. Typed fields against plugin-defined classes +## were the #242 / #244 crash class: Godot can reparse a long-lived script +## while its old field storage and the new type shape disagree. Static-var +## initializers are the most dangerous form because they execute at +## script-load; a top-level typed Dictionary/Array storage change can fail +## before `_enter_tree` runs. +## +## The mitigation is two-part: +## (1) Field declarations are untyped (this block). +## (2) Construction and static access use local names declared at the top +## of the file (e.g. `Connection`, `Dispatcher`, `LogBuffer`, +## `ClientConfigurator`, `WindowsPortReservation`, ...), which keeps +## this entry script's load surface explicit and reviewable. +## +## Constructors, constants, and static methods on `Mcp*` classes are not the +## self-update safety metric under the single-phase runner. The old syntactic +## lint counted bare `Mcp*.MEMBER` references, but #398 was caused by the +## runner scanning a mixed old/new snapshot and reusing stale Script-object +## content. Bare names and preload aliases can both be parsed against stale +## content under an old two-phase runner; from the fixed runner onward the +## full v(N+1) snapshot is written before the scan. In short: preload aliases +## are not the self-update safety metric. +## +## `tests/unit/test_plugin_self_update_safety.py` locks this wording in. +## +## `_editor_logger` is untyped because its script extends Godot 4.5+'s Logger +## class: `logger_loader.gd` compiles it at runtime from on-disk source +## (FileAccess + `GDScript.new()`) past the `ClassDB.class_exists("Logger")` +## gate in `_attach_editor_logger`, so the plugin still parses on 4.4. Null on +## Godot < 4.5 or before `_attach_editor_logger` runs; "attached" state IS +## exactly "non-null". +var _connection +var _dispatcher +var _telemetry +var _log_buffer +var _game_log_buffer +var _editor_log_buffer +var _editor_logger +var _dock +var _handlers: Array = [] # prevent GC of RefCounted handlers +var _debugger_plugin +## Spawn / stop / adopt orchestration plus state machine; allocated in +## `_init` so test fixtures (which never enter the tree) can drive +## `_start_server`. Owns `_server_pid`, `_server_state`, the version- +## check seam, and the adoption-confirmation deadline — see +## `utils/server_lifecycle.gd`. +var _lifecycle +static var _server_started_this_session := false # guard against re-entrant spawns +static var _resolved_ws_port := ClientConfigurator.DEFAULT_WS_PORT + +## Server-watch timer lives on the plugin because it's a Node — the +## manager is RefCounted and can't host children. +var _server_watch_timer: Timer = null +var _headless_disabled := false +var _startup_trace_enabled := false +var _startup_trace_start_ms := 0 +var _startup_trace_last_ms := 0 +var _startup_trace_counters: Dictionary = {} +var _startup_trace_netsh_start_count := 0 + + +func _init() -> void: + _lifecycle = ServerLifecycleManager.new(self) + + +func _enter_tree() -> void: + _startup_trace_begin() + + ## `_process` is only used by the adoption-confirmation watcher; keep + ## it off until `_watch_for_adoption_confirmation` arms it, so the + ## plugin has zero per-frame cost in the common case. + set_process(false) + + if _mcp_disabled_for_headless_launch(): + _headless_disabled = true + print("MCP | plugin disabled in headless mode") + return + + ## Self-update from a pre-loggers/ version leaves the old logger scripts + ## orphaned at runtime/*.gd (the runner only writes files in the new ZIP, + ## it doesn't prune). Those still `extends Logger` and re-emit the parse + ## errors on Godot < 4.5. Delete them once so upgraders match a fresh + ## install. No-op on fresh installs and dev checkouts (files absent). + _cleanup_legacy_logger_scripts() + + ## Register port overrides before spawn so `http_port()` / `ws_port()` + ## return the user's configured values (if any) when `_start_server` + ## builds the CLI args. + ClientConfigurator.ensure_settings_registered() + _startup_trace_phase("settings_registered") + + _log_buffer = LogBuffer.new() + _start_server() + _startup_trace_phase("server_start") + + _game_log_buffer = GameLogBuffer.new() + _editor_log_buffer = EditorLogBuffer.new() + _attach_editor_logger() + _dispatcher = Dispatcher.new(_log_buffer) + _startup_trace_phase("core_objects") + + _connection = Connection.new() + _connection.log_buffer = _log_buffer + _connection.ws_port = _resolved_ws_port + _connection.connect_blocked = _lifecycle.is_connection_blocked() + _connection.connect_block_reason = _lifecycle.get_status_dict().get("message", "") + if ( + not _lifecycle.is_connection_blocked() + and not ServerStateScript.is_terminal_diagnosis(_lifecycle.get_state()) + ): + _arm_server_version_check() + + _telemetry = Telemetry.new(_connection) + + _debugger_plugin = DebuggerPlugin.new(_log_buffer, _game_log_buffer) + add_debugger_plugin(_debugger_plugin) + _ensure_game_helper_autoload() + + var editor_handler := EditorHandler.new(_log_buffer, _connection, _debugger_plugin, _game_log_buffer, _editor_log_buffer) + var scene_handler := SceneHandler.new(_connection) + var node_handler := NodeHandler.new(get_undo_redo()) + var project_handler := ProjectHandler.new(_connection, _debugger_plugin) + var client_handler := ClientHandler.new() + var script_handler := ScriptHandler.new(get_undo_redo(), _connection) + var resource_handler := ResourceHandler.new(get_undo_redo(), _connection) + var api_handler := ApiHandler.new() + var filesystem_handler := FilesystemHandler.new() + var signal_handler := SignalHandler.new(get_undo_redo()) + var autoload_handler := AutoloadHandler.new() + var input_handler := InputHandler.new() + var test_handler := TestHandler.new(get_undo_redo(), _log_buffer) + var batch_handler := BatchHandler.new(_dispatcher, get_undo_redo()) + var ui_handler := UiHandler.new(get_undo_redo()) + var theme_handler := ThemeHandler.new(get_undo_redo()) + var animation_handler := AnimationHandler.new(get_undo_redo()) + var material_handler := MaterialHandler.new(get_undo_redo()) + var particle_handler := ParticleHandler.new(get_undo_redo()) + var camera_handler := CameraHandler.new(get_undo_redo()) + var audio_handler := AudioHandler.new(get_undo_redo()) + var physics_shape_handler := PhysicsShapeHandler.new(get_undo_redo()) + var environment_handler := EnvironmentHandler.new(get_undo_redo(), _connection) + var texture_handler := TextureHandler.new(get_undo_redo(), _connection) + var curve_handler := CurveHandler.new(get_undo_redo(), _connection) + var control_draw_recipe_handler := ControlDrawRecipeHandler.new(get_undo_redo()) + _handlers = [editor_handler, scene_handler, node_handler, project_handler, client_handler, script_handler, resource_handler, api_handler, filesystem_handler, signal_handler, autoload_handler, input_handler, test_handler, batch_handler, ui_handler, theme_handler, animation_handler, material_handler, particle_handler, camera_handler, audio_handler, physics_shape_handler, environment_handler, texture_handler, curve_handler, control_draw_recipe_handler] + + _dispatcher.register("get_editor_state", editor_handler.get_editor_state) + _dispatcher.register("get_scene_tree", scene_handler.get_scene_tree) + _dispatcher.register("get_open_scenes", scene_handler.get_open_scenes) + _dispatcher.register("find_nodes", scene_handler.find_nodes) + _dispatcher.register("create_scene", scene_handler.create_scene) + _dispatcher.register("open_scene", scene_handler.open_scene) + _dispatcher.register("save_scene", scene_handler.save_scene) + _dispatcher.register("save_scene_as", scene_handler.save_scene_as) + _dispatcher.register("get_selection", editor_handler.get_selection) + _dispatcher.register("create_node", node_handler.create_node) + _dispatcher.register("delete_node", node_handler.delete_node) + _dispatcher.register("reparent_node", node_handler.reparent_node) + _dispatcher.register("set_property", node_handler.set_property) + _dispatcher.register("rename_node", node_handler.rename_node) + _dispatcher.register("duplicate_node", node_handler.duplicate_node) + _dispatcher.register("move_node", node_handler.move_node) + _dispatcher.register("add_to_group", node_handler.add_to_group) + _dispatcher.register("remove_from_group", node_handler.remove_from_group) + _dispatcher.register("set_selection", node_handler.set_selection) + _dispatcher.register("get_node_properties", node_handler.get_node_properties) + _dispatcher.register("get_children", node_handler.get_children) + _dispatcher.register("get_groups", node_handler.get_groups) + _dispatcher.register("get_logs", editor_handler.get_logs) + _dispatcher.register("clear_logs", editor_handler.clear_logs) + _dispatcher.register("take_screenshot", editor_handler.take_screenshot) + _dispatcher.register("get_performance_monitors", editor_handler.get_performance_monitors) + _dispatcher.register("reload_plugin", editor_handler.reload_plugin) + _dispatcher.register("quit_editor", editor_handler.quit_editor) + _dispatcher.register("game_eval", editor_handler.game_eval) + _dispatcher.register("game_command", editor_handler.game_command) + _dispatcher.register("get_project_setting", project_handler.get_project_setting) + _dispatcher.register("set_project_setting", project_handler.set_project_setting) + _dispatcher.register("run_project", project_handler.run_project) + _dispatcher.register("stop_project", project_handler.stop_project) + _dispatcher.register("search_filesystem", project_handler.search_filesystem) + _dispatcher.register("configure_client", client_handler.configure_client) + _dispatcher.register("remove_client", client_handler.remove_client) + _dispatcher.register("check_client_status", client_handler.check_client_status) + _dispatcher.register("create_script", script_handler.create_script) + _dispatcher.register("patch_script", script_handler.patch_script) + _dispatcher.register("read_script", script_handler.read_script) + _dispatcher.register("attach_script", script_handler.attach_script) + _dispatcher.register("detach_script", script_handler.detach_script) + _dispatcher.register("find_symbols", script_handler.find_symbols) + _dispatcher.register("search_resources", resource_handler.search_resources) + _dispatcher.register("load_resource", resource_handler.load_resource) + _dispatcher.register("assign_resource", resource_handler.assign_resource) + _dispatcher.register("create_resource", resource_handler.create_resource) + _dispatcher.register("get_resource_info", resource_handler.get_resource_info) + _dispatcher.register("get_class_info", api_handler.get_class_info) + _dispatcher.register("read_file", filesystem_handler.read_file) + _dispatcher.register("write_file", filesystem_handler.write_file) + _dispatcher.register("reimport", filesystem_handler.reimport) + _dispatcher.register("list_signals", signal_handler.list_signals) + _dispatcher.register("connect_signal", signal_handler.connect_signal) + _dispatcher.register("disconnect_signal", signal_handler.disconnect_signal) + _dispatcher.register("list_autoloads", autoload_handler.list_autoloads) + _dispatcher.register("add_autoload", autoload_handler.add_autoload) + _dispatcher.register("remove_autoload", autoload_handler.remove_autoload) + _dispatcher.register("list_actions", input_handler.list_actions) + _dispatcher.register("add_action", input_handler.add_action) + _dispatcher.register("remove_action", input_handler.remove_action) + _dispatcher.register("bind_event", input_handler.bind_event) + _dispatcher.register("run_tests", test_handler.run_tests) + _dispatcher.register("get_test_results", test_handler.get_test_results) + _dispatcher.register("batch_execute", batch_handler.batch_execute) + _dispatcher.register("set_anchor_preset", ui_handler.set_anchor_preset) + _dispatcher.register("set_text", ui_handler.set_text) + _dispatcher.register("build_layout", ui_handler.build_layout) + _dispatcher.register("create_theme", theme_handler.create_theme) + _dispatcher.register("theme_set_color", theme_handler.set_color) + _dispatcher.register("theme_set_constant", theme_handler.set_constant) + _dispatcher.register("theme_set_font_size", theme_handler.set_font_size) + _dispatcher.register("theme_set_stylebox_flat", theme_handler.set_stylebox_flat) + _dispatcher.register("apply_theme", theme_handler.apply_theme) + _dispatcher.register("animation_player_create", animation_handler.create_player) + _dispatcher.register("animation_create", animation_handler.create_animation) + _dispatcher.register("animation_add_property_track", animation_handler.add_property_track) + _dispatcher.register("animation_add_method_track", animation_handler.add_method_track) + _dispatcher.register("animation_set_autoplay", animation_handler.set_autoplay) + _dispatcher.register("animation_play", animation_handler.play) + _dispatcher.register("animation_stop", animation_handler.stop) + _dispatcher.register("animation_list", animation_handler.list_animations) + _dispatcher.register("animation_get", animation_handler.get_animation) + _dispatcher.register("animation_create_simple", animation_handler.create_simple) + _dispatcher.register("animation_delete", animation_handler.delete_animation) + _dispatcher.register("animation_validate", animation_handler.validate_animation) + _dispatcher.register("animation_preset_fade", animation_handler.preset_fade) + _dispatcher.register("animation_preset_slide", animation_handler.preset_slide) + _dispatcher.register("animation_preset_shake", animation_handler.preset_shake) + _dispatcher.register("animation_preset_pulse", animation_handler.preset_pulse) + _dispatcher.register("material_create", material_handler.create_material) + _dispatcher.register("material_set_param", material_handler.set_param) + _dispatcher.register("material_set_shader_param", material_handler.set_shader_param) + _dispatcher.register("material_get", material_handler.get_material) + _dispatcher.register("material_list", material_handler.list_materials) + _dispatcher.register("material_assign", material_handler.assign_material) + _dispatcher.register("material_apply_to_node", material_handler.apply_to_node) + _dispatcher.register("material_apply_preset", material_handler.apply_preset) + _dispatcher.register("particle_create", particle_handler.create_particle) + _dispatcher.register("particle_set_main", particle_handler.set_main) + _dispatcher.register("particle_set_process", particle_handler.set_process) + _dispatcher.register("particle_set_draw_pass", particle_handler.set_draw_pass) + _dispatcher.register("particle_restart", particle_handler.restart_particle) + _dispatcher.register("particle_get", particle_handler.get_particle) + _dispatcher.register("particle_apply_preset", particle_handler.apply_preset) + _dispatcher.register("camera_create", camera_handler.create_camera) + _dispatcher.register("camera_configure", camera_handler.configure) + _dispatcher.register("camera_set_limits_2d", camera_handler.set_limits_2d) + _dispatcher.register("camera_set_damping_2d", camera_handler.set_damping_2d) + _dispatcher.register("camera_follow_2d", camera_handler.follow_2d) + _dispatcher.register("camera_get", camera_handler.get_camera) + _dispatcher.register("camera_list", camera_handler.list_cameras) + _dispatcher.register("camera_apply_preset", camera_handler.apply_preset) + _dispatcher.register("audio_player_create", audio_handler.create_player) + _dispatcher.register("audio_player_set_stream", audio_handler.set_stream) + _dispatcher.register("audio_player_set_playback", audio_handler.set_playback) + _dispatcher.register("audio_play", audio_handler.play) + _dispatcher.register("audio_stop", audio_handler.stop) + _dispatcher.register("audio_list", audio_handler.list_streams) + _dispatcher.register("physics_shape_autofit", physics_shape_handler.autofit) + _dispatcher.register("environment_create", environment_handler.create_environment) + _dispatcher.register("gradient_texture_create", texture_handler.create_gradient_texture) + _dispatcher.register("noise_texture_create", texture_handler.create_noise_texture) + _dispatcher.register("curve_set_points", curve_handler.set_points) + _dispatcher.register( + "control_draw_recipe", control_draw_recipe_handler.control_draw_recipe + ) + + _connection.dispatcher = _dispatcher + add_child(_connection) + _startup_trace_phase("handlers_registered") + + # Dock panel + _dock = Dock.new() + _dock.name = "Godot AI" + _dock.setup(_connection, _log_buffer, self) + add_control_to_dock(DOCK_SLOT_RIGHT_BL, _dock) + _startup_trace_phase("dock_attached") + + _log_buffer.log("plugin loaded") + if _telemetry != null: + _telemetry.record_dock_startup() + _flush_pending_self_update_telemetry() + _telemetry.flush_pending_plugin_reload() + var startup_path: String = str(_lifecycle.get_startup_path()) + _startup_trace_finish(startup_path if not startup_path.is_empty() else "loaded") + + +## Public wrapper around the dev-server-toggle telemetry emit. Lets the +## dock (or any other caller) record without reaching into ``_telemetry`` +## directly — keeps the plugin's internal field encapsulated. The dev +## server is a Python subprocess unrelated to the plugin's own +## lifecycle, so emission can be synchronous (no EditorSettings persist +## dance like ``plugin_reload`` / ``self_update``). +func record_dev_server_toggle(action: String) -> void: + if _telemetry == null: + return + _telemetry.record_dev_server_toggle(action) + + +## Drain any self_update event written by `update_reload_runner` during the +## previous disable -> enable window. +func _flush_pending_self_update_telemetry() -> void: + var key := UPDATE_RELOAD_RUNNER_SCRIPT.PENDING_SELF_UPDATE_TELEMETRY_KEY + var parsed = Telemetry._drain_editor_setting_dict(key) + if parsed == null: + return + var status := str(parsed.get("status", "unknown")) + var error := str(parsed.get("error", "")) + ## Positional args: GDScript doesn't support keyword args in calls + ## (unlike Python). from_version + to_version are empty strings here + ## — only ``status`` and ``error`` are known at flush time. + _telemetry.record_self_update(status, "", "", error) + + + + +func _exit_tree() -> void: + if _headless_disabled: + _server_started_this_session = false + _headless_disabled = false + return + + ## Outer-to-inner teardown. Dispatcher Callables hold RefCounted handlers + ## alive past the point where Godot reloads their class_name scripts — the + ## first post-reload call into a typed-array-holding handler (e.g. + ## McpGameLogBuffer._storage) then SIGSEGVs against a stale class descriptor. + ## See issue #46. + + # Stop inbound work first so _process can't enqueue new commands or + # null-deref log_buffer on the next tick mid-teardown. + if _connection: + _connection.teardown() + + # Break the Callable -> handler ref chain before dropping _handlers, so the + # array clear actually decrefs the handler RefCounteds to zero. + if _dispatcher: + _dispatcher.clear() + + # Handler destructors run here, while their class_name scripts are still loaded. + _handlers.clear() + + if _dock: + remove_control_from_docks(_dock) + _dock.queue_free() + _dock = null + if _connection: + _connection.queue_free() + _connection = null + if _debugger_plugin: + remove_debugger_plugin(_debugger_plugin) + _debugger_plugin = null + + ## Detach the editor logger BEFORE nulling the buffer. After remove_logger + ## returns, Godot guarantees no further virtual calls — so the logger's + ## next access to `_buffer` (if any in flight) lands on a still-live + ## ref-counted buffer, not a freed one. + _detach_editor_logger() + + _dispatcher = null + _log_buffer = null + _game_log_buffer = null + _editor_log_buffer = null + + _stop_server() + ## Symmetric with prepare_for_update_reload: the static guard persists + ## across disable/enable within a single editor session, so the re-enabled + ## plugin instance's _start_server would short-circuit and never respawn. + ## Pre-#159 this was masked — the old kill path usually left Python alive + ## and the new instance adopted it on port 8000. Now that _stop_server is + ## deterministic, nothing is left to adopt and the reload hangs. + _server_started_this_session = false + print("MCP | plugin unloaded") + + +## Attach editor_logger.gd as a Godot logger so editor-process script +## errors (parse errors, @tool runtime errors, EditorPlugin errors, +## push_error/push_warning) flow into _editor_log_buffer for +## logs_read(source="editor"). Logger subclassing is 4.5+ only; the +## ClassDB gate keeps the plugin loadable on 4.4 with no-op editor logs +## (the buffer stays empty, logs_read returns no entries). +## +## 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: + if not (ClassDB.class_exists("Logger") and OS.has_method("add_logger")): + return + var logger_script := LoggerLoader.build(LoggerLoader.EDITOR_LOGGER_PATH) + if logger_script == null: + return + _editor_logger = logger_script.new(_editor_log_buffer) + OS.call("add_logger", _editor_logger) + + +## Remove the pre-2.5.8 logger scripts left at runtime/*.gd by a self-update +## (the runner doesn't prune files dropped between versions). They `extends +## Logger` and would re-emit "Could not find base class Logger" parse errors +## on Godot < 4.5 even though the live copies now live in the .gdignore'd +## runtime/loggers/ folder. Idempotent: existence-guarded, so it's a no-op on +## fresh installs and symlinked dev checkouts. +func _cleanup_legacy_logger_scripts() -> void: + var legacy := [ + "res://addons/godot_ai/runtime/editor_logger.gd", + "res://addons/godot_ai/runtime/editor_logger.gd.uid", + "res://addons/godot_ai/runtime/game_logger.gd", + "res://addons/godot_ai/runtime/game_logger.gd.uid", + ] + for res_path in legacy: + if FileAccess.file_exists(res_path): + DirAccess.remove_absolute(ProjectSettings.globalize_path(res_path)) + + +func _detach_editor_logger() -> void: + if _editor_logger != null and OS.has_method("remove_logger"): + OS.call("remove_logger", _editor_logger) + _editor_logger = null + + +## Register the game-side autoload on plugin enable. Runs the helper inside +## the game process so the editor-side debugger plugin can request +## framebuffer captures over EngineDebugger messages. Removed on +## _disable_plugin so disabling the plugin leaves project.godot clean. +func _enable_plugin() -> void: + if _mcp_disabled_for_headless_launch(): + return + _ensure_game_helper_autoload() + + +static func _mcp_disabled_for_headless_launch() -> bool: + return _mcp_disabled_for_headless( + OS.get_cmdline_args(), + DisplayServer.get_name(), + OS.get_environment("GODOT_AI_ALLOW_HEADLESS") + ) + + +static func _mcp_disabled_for_headless(args: PackedStringArray, display_name: String, allow_value: String) -> bool: + if McpSettings.truthy(allow_value): + return false + return _args_request_headless(args) or display_name.to_lower() == "headless" + + +static func _args_request_headless(args: PackedStringArray) -> bool: + for i in range(args.size()): + var arg := args[i] + if arg == "--headless": + return true + if arg == "--display-driver" and i + 1 < args.size() and args[i + 1] == "headless": + return true + if arg.begins_with("--display-driver=") and arg.get_slice("=", 1) == "headless": + return true + return false + + + + +func _disable_plugin() -> void: + var key := "autoload/" + GAME_HELPER_AUTOLOAD_NAME + if not ProjectSettings.has_setting(key): + return + ProjectSettings.clear(key) + ProjectSettings.save() + + +func _ensure_game_helper_autoload() -> void: + ## Write the autoload directly to ProjectSettings and save immediately. + ## EditorPlugin.add_autoload_singleton only mutates in-memory settings — + ## the on-disk project.godot is only persisted when the editor saves + ## (e.g. on quit). CI spawns the game subprocess before any save fires, + ## so the child process never sees the autoload and the capture times + ## out. Mirror AutoloadHandler's pattern: set_setting + save(). + var key := "autoload/" + GAME_HELPER_AUTOLOAD_NAME + var value := "*" + GAME_HELPER_AUTOLOAD_PATH # "*" prefix = singleton + if ProjectSettings.get_setting(key, "") == value: + return ## already registered with the right target + ProjectSettings.set_setting(key, value) + ProjectSettings.set_initial_value(key, "") + ProjectSettings.set_as_basic(key, true) + var err := ProjectSettings.save() + if err != OK: + push_warning("MCP: failed to save project.godot after registering %s autoload (error %d)" + % [GAME_HELPER_AUTOLOAD_NAME, err]) + + +func _startup_trace_begin() -> void: + _startup_trace_enabled = ClientConfigurator.startup_trace_enabled() + if not _startup_trace_enabled: + return + _startup_trace_start_ms = Time.get_ticks_msec() + _startup_trace_last_ms = _startup_trace_start_ms + _startup_trace_netsh_start_count = WindowsPortReservation.netsh_query_count() + _startup_trace_counters.clear() + for counter in STARTUP_TRACE_COUNTER_NAMES: + _startup_trace_counters[counter] = 0 + print( + "MCP startup trace | begin platform=%s http_port=%d ws_port=%d" + % [ + OS.get_name(), + ClientConfigurator.http_port(), + ClientConfigurator.ws_port(), + ] + ) + + +func _startup_trace_count(counter: String, amount: int = 1) -> void: + if not _startup_trace_enabled: + return + _startup_trace_counters[counter] = int(_startup_trace_counters.get(counter, 0)) + amount + + +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() + _startup_trace_counters["netsh"] = ( + WindowsPortReservation.netsh_query_count() - _startup_trace_netsh_start_count + ) + print( + "MCP startup trace | done path=%s total_ms=%d counters=%s" + % [path, now - _startup_trace_start_ms, str(_startup_trace_counters)] + ) + + +func _start_server() -> void: + _lifecycle.start_server() + + +## Test-fixture shim — characterization tests in test_plugin_lifecycle +## reach for this instance method directly. Delegates to the manager's +## state-owning copy. +func _set_incompatible_server(live: Dictionary, expected_version: String, port: int) -> void: + _lifecycle._set_incompatible_server(live, expected_version, port) + + +## Static shim — kept on the plugin class because the characterization +## tests assert against `GodotAiPlugin._incompatible_server_message`. +## Implementation moved to ServerLifecycleManager. +static func _incompatible_server_message( + live: Dictionary, + expected_version: String, + port: int, + expected_ws_port: int +) -> String: + return ServerLifecycleManager._incompatible_server_message( + live, expected_version, port, expected_ws_port + ) + + +static func _server_version_compatibility( + actual_version: String, expected_version: String +) -> Dictionary: + return ServerLifecycleManager._server_version_compatibility( + actual_version, expected_version + ) + + +static func _server_status_compatibility( + actual_version: String, + expected_version: String, + actual_ws_port: int, + expected_ws_port: int, +) -> Dictionary: + return ServerLifecycleManager._server_status_compatibility( + actual_version, expected_version, actual_ws_port, expected_ws_port + ) + + +static func _managed_record_has_version_drift(record_version: String, current_version: String) -> bool: + return ServerLifecycleManager._managed_record_has_version_drift(record_version, current_version) + + +static func _probe_live_server_status(port: int, timeout_ms: int = SERVER_STATUS_PROBE_TIMEOUT_MS) -> Dictionary: + var result := { + "reachable": false, + "version": "", + "name": "", + "ws_port": 0, + "status_code": 0, + "error": "", + } + var client := HTTPClient.new() + var err := client.connect_to_host("127.0.0.1", port) + if err != OK: + result["error"] = "connect_%d" % err + return result + var deadline := Time.get_ticks_msec() + timeout_ms + while client.get_status() == HTTPClient.STATUS_RESOLVING or client.get_status() == HTTPClient.STATUS_CONNECTING: + client.poll() + if Time.get_ticks_msec() >= deadline: + result["error"] = "connect_timeout" + return result + OS.delay_msec(10) + if client.get_status() != HTTPClient.STATUS_CONNECTED: + result["error"] = "connect_status_%d" % client.get_status() + return result + err = client.request(HTTPClient.METHOD_GET, SERVER_STATUS_PATH, ["Accept: application/json"]) + if err != OK: + result["error"] = "request_%d" % err + return result + var body := PackedByteArray() + while true: + var status := client.get_status() + if status == HTTPClient.STATUS_REQUESTING: + client.poll() + elif status == HTTPClient.STATUS_BODY: + client.poll() + var chunk := client.read_response_body_chunk() + if chunk.size() > 0: + body.append_array(chunk) + elif status == HTTPClient.STATUS_CONNECTED: + break + else: + result["error"] = "response_status_%d" % status + return result + if Time.get_ticks_msec() >= deadline: + result["error"] = "response_timeout" + return result + OS.delay_msec(10) + var response_code := client.get_response_code() + result["status_code"] = response_code + if response_code != 200: + result["error"] = "http_%d" % response_code + return result + var parsed = JSON.parse_string(body.get_string_from_utf8()) + if not (parsed is Dictionary): + result["error"] = "invalid_json" + return result + result["reachable"] = true + result["name"] = str(parsed.get("name", "")) + result["version"] = _extract_server_version(parsed) + result["ws_port"] = int(parsed.get("ws_port", 0)) + ## `package_path` was added in v2.4.4 (#416) so the dock's + ## "Incompatible server" banner can name the source of a version + ## skew. Older servers omit it; treat the missing field as "". + result["package_path"] = str(parsed.get("package_path", "")) + return result + + +func _probe_live_server_status_for_port(port: int) -> Dictionary: + _startup_trace_count("http_status_probe") + return _probe_live_server_status(port) + + +static func _extract_server_version(payload: Dictionary) -> String: + var version := str(payload.get("server_version", "")) + if version.is_empty(): + version = str(payload.get("version", "")) + return version + + +static func _live_status_identifies_godot_ai(live: Dictionary) -> bool: + return ServerLifecycleManager._live_status_identifies_godot_ai(live) + + +func _verified_status_version(live: Dictionary) -> String: + if not ServerLifecycleManager._live_status_identifies_godot_ai(live): + return "" + return str(live.get("version", "")) + + +func _verified_status_ws_port(live: Dictionary) -> int: + if not ServerLifecycleManager._live_status_identifies_godot_ai(live): + return 0 + return int(live.get("ws_port", 0)) + + +func _refresh_dock_client_statuses() -> bool: + if _dock == null: + return false + if not _dock.has_method("_refresh_all_client_statuses"): + return false + _dock.call("_refresh_all_client_statuses") + return true + + +## Test-fixture shim — characterization tests in test_plugin_lifecycle +## still drive the first-writer-wins terminal-diagnosis behaviour through +## this method. Delegates to the manager's `set_terminal_diagnosis` +## (which preserves the same first-writer-wins contract). +func _set_spawn_state(state: int) -> void: + _lifecycle.set_terminal_diagnosis(state) + + +## Arm the one-shot connection watcher. Called from `_start_server`'s +## FOREIGN_PORT branch: we flagged the diagnostic preemptively assuming +## the port holder doesn't speak MCP, but if it turns out to be another +## editor's server our WebSocket will open and we need to retract the +## diagnostic. +## +## We intentionally poll `_connection.is_connected` from `_process` +## instead of wiring a new signal on McpConnection. A signal added in the +## same release as a new consumer would be another shape-coupled update: +## old two-phase runners can parse the consumer while the McpConnection +## Script object still reflects v(N). Polling only reads `is_connected` +## (present on every shipped McpConnection), so old-runner upgrade windows +## do not depend on a same-release signal addition. +## +## The watch self-disarms after SPAWN_GRACE_MS so per-frame cost drops +## back to zero if it is ever armed by a legacy adoption path. +func _watch_for_adoption_confirmation() -> void: + _lifecycle.arm_adoption_watch() + _update_process_enabled() + + +func _arm_server_version_check() -> void: + ## `arm_version_check` resolves an empty expected via the plugin + ## version, so we can pass the raw field value through. + _lifecycle.arm_version_check(_connection, str(_lifecycle._server_expected_version)) + _update_process_enabled() + + +func _update_process_enabled() -> void: + set_process( + _lifecycle.get_adoption_watch_deadline_ms() > 0 + or _lifecycle.is_awaiting_server_version() + ) + + +func _process(_delta: float) -> void: + var now := Time.get_ticks_msec() + var version_check = _lifecycle.get_version_check() + if version_check != null: + version_check.tick(now) + _lifecycle.tick_adoption_watch(now) + _update_process_enabled() + + +## A WebSocket opening only proves the occupant speaks enough of the editor +## protocol to accept a session. Compatibility is decided by the server +## version in `handshake_ack`, so this only arms that check. +func _on_connection_established() -> void: + if _lifecycle.get_state() == ServerStateScript.FOREIGN_PORT: + _arm_server_version_check() + + +## Test-fixture shim — characterization tests poke the verified path +## directly. Delegates to the version-check seam; the manager resolves +## an empty expected version via `_resolve_expected_version`. +func _on_server_version_verified(version: String) -> void: + _lifecycle.handle_server_version_verified( + str(_lifecycle._server_expected_version), version + ) + _update_process_enabled() + + +## Test-fixture shim — same shape as `_on_server_version_verified`. +func _on_server_version_unverified() -> void: + _lifecycle.handle_server_version_unverified( + str(_lifecycle._server_expected_version) + ) + _update_process_enabled() + + +## Start a 1s-tick timer that watches the spawned server for up to +## SERVER_WATCH_MS. If the process dies inside the window we drain the +## captured pipes and mark the server as crashed so the dock can surface +## what went wrong. After the window expires we close the pipes so they +## don't pin file descriptors or fill their kernel buffers. See #146. +func _start_server_watch() -> void: + _stop_server_watch() + _server_watch_timer = Timer.new() + _server_watch_timer.wait_time = 1.0 + _server_watch_timer.one_shot = false + _server_watch_timer.timeout.connect(_check_server_health) + add_child(_server_watch_timer) + _server_watch_timer.start() + + +func _stop_server_watch() -> void: + if _server_watch_timer != null: + _server_watch_timer.stop() + _server_watch_timer.queue_free() + _server_watch_timer = null + + +func _check_server_health() -> void: + _lifecycle.check_server_health() + + +## True when the first spawn looks like a stale-uvx-index failure and we +## haven't already retried. Fail signal: launcher process already declared +## dead by the caller, pid-file was never written (Python never got to +## argparse), and we're on the uvx tier (the only tier where `--refresh` +## means anything). Bug #172 — after a fresh PyPI publish, uvx's local +## index metadata keeps saying the new version doesn't exist for ~10 min, +## which cascaded into an infinite reconnect loop pre-#171. Retry-at-spawn +## catches every entry path (Update, Reload Plugin, Reconnect, editor +## restart, crash recovery) — unlike the older Update-only precheck. +func _should_retry_with_refresh() -> bool: + return _retry_with_refresh_allowed( + _lifecycle._refresh_retried, + ClientConfigurator.get_server_launch_mode(), + _read_pid_file(), + ) + + +## Pure decision helper — environment-state readers stay in the instance +## method above, the logic lives here so tests can drive the three inputs +## directly without spoofing static caches or pid-files on disk. +static func _retry_with_refresh_allowed(already_retried: bool, launch_mode: String, pid_from_file: int) -> bool: + return ( + not already_retried + and launch_mode == "uvx" + and pid_from_file == 0 + ) + + +func _respawn_with_refresh() -> void: + _lifecycle.respawn_with_refresh() + + +## Snapshot of the server-spawn outcome for the dock. +## +## `state` is one of the `McpServerState.*` int constants; the dock owns +## the UI copy per state via its own `_crash_body_for_state`. `exit_ms` +## is only meaningful for `CRASHED`. +func get_server_status() -> Dictionary: + return _lifecycle.get_status_dict() + + +func get_resolved_ws_port() -> int: + return _resolved_ws_port + + +func _set_resolved_ws_port(port: int) -> void: + _resolved_ws_port = port + if _connection != null: + _connection.ws_port = port + + +func _resolve_ws_port() -> int: + return PortResolver.resolve_ws_port( + ClientConfigurator.ws_port(), + ClientConfigurator.MAX_PORT, + _log_buffer, + ) + + +## Test-compat shim — characterization tests call this static directly. +static func _resolved_ws_port_for_existing_server( + record_ws_port: int, + record_version: String, + current_version: String, + fresh_resolved: int +) -> int: + return PortResolver.resolved_ws_port_for_existing_server( + record_ws_port, + record_version, + current_version, + fresh_resolved, + ) + + +static func _resolve_ws_port_from_output( + configured_port: int, + netsh_output: String, + span: int = 2048 +) -> int: + return PortResolver.resolve_ws_port_from_output( + configured_port, + netsh_output, + ClientConfigurator.MAX_PORT, + span, + ) + + +## Plugin-level shim around the resolver — keeps the startup-trace +## counter increment and the `_ProofPlugin` override hook on the plugin. +func _is_port_in_use(port: int) -> bool: + if PortResolver.can_bind_local_port(port): + ## POSIX can still have an IPv6 wildcard listener on this port + ## even when an IPv4 loopback bind succeeds. Confirm through + ## lsof so startup and kill-path discovery agree. + if OS.get_name() != "Windows": + _startup_trace_count("lsof") + return PortResolver.is_port_in_use_via_scrape(port) + return false + if OS.get_name() == "Windows": + _startup_trace_count("netstat") + else: + _startup_trace_count("lsof") + return PortResolver.is_port_in_use_via_scrape(port) + + +## Pass `_startup_trace_count` so the resolver bumps the right counter +## per scraper that actually ran (Windows can fall through netstat → +## PowerShell — counting both unconditionally would over-report). +func _find_pid_on_port(port: int) -> int: + return PortResolver.find_pid_on_port(port, _startup_trace_count) + + +func _find_all_pids_on_port(port: int) -> Array[int]: + return PortResolver.find_all_pids_on_port(port, _startup_trace_count) + + +static func _execute_windows_powershell(script: String, output: Array) -> int: + return PortResolver.execute_windows_powershell(script, output) + + +static func _windows_listener_pids_from_execute_result(exit_code: int, output: Array) -> Array[int]: + return PortResolver.windows_listener_pids_from_execute_result(exit_code, output) + + +static func _windows_listener_execute_result_in_use(exit_code: int, output: Array) -> bool: + return PortResolver.windows_listener_execute_result_in_use(exit_code, output) + + +static func _parse_lsof_pids(raw: String) -> Array[int]: + return PortResolver.parse_lsof_pids(raw) + + +static func _parse_pid_lines(raw: String) -> Array[int]: + return PortResolver.parse_pid_lines(raw) + + +## Find the managed server PID deterministically: prefer the pid-file +## the Python server writes on startup (see runtime_info.py), fall back +## to scraping `netstat -ano` / `lsof` only when the file is missing or +## stale. This is the replacement for raw port-scraping: on Windows the +## uvx launcher PID doesn't cover the Python child, and netstat parsing +## is fragile. +## +## Returns 0 when no server can be identified. +func _find_managed_pid(port: int) -> int: + var pid := _read_pid_file() + if pid > 0 and _pid_alive(pid): + return pid + return _find_pid_on_port(port) + + +## `live` is the result of a prior `_probe_live_server_status_for_port` +## call that the caller already has on hand. When non-empty it short- +## circuits the internal probe at the bottom of this helper, so a single +## `_start_server` invocation that probes once at the top can thread the +## same snapshot through compatibility check + recovery without paying +## for a second ~500 ms localhost HTTPClient poll loop. Default `{}` +## preserves the historical behavior for callers outside the spawn flow +## (`can_recover_incompatible_server`, the dock's UI buttons), where a +## fresh probe is the right thing. +func _evaluate_strong_port_occupant_proof(port: int, live: Dictionary = {}) -> Dictionary: + var result := {"proof": "", "pids": []} + var listener_pids := _find_all_pids_on_port(port) + if listener_pids.is_empty(): + return result + + var record := _read_managed_server_record() + var record_pid := int(record.get("pid", 0)) + var record_version := str(record.get("version", "")) + + if record_pid > 1 and record_pid != OS.get_process_id(): + if listener_pids.has(record_pid) and _pid_alive_for_proof(record_pid): + return {"proof": "managed_record", "pids": [record_pid]} + + var legacy_targets := _legacy_pidfile_kill_targets(port, listener_pids) + if not legacy_targets.is_empty(): + return {"proof": "pidfile_listener", "pids": legacy_targets} + + var current_live: Dictionary = live if not live.is_empty() else _probe_live_server_status_for_port(port) + if ( + _live_status_identifies_godot_ai(current_live) + and not record_version.is_empty() + and str(current_live.get("version", "")) == record_version + ): + return {"proof": "status_matches_record", "pids": listener_pids} + + return result + + +## See `_evaluate_strong_port_occupant_proof` for the `live` contract. +## Threads `live` through the strong-proof delegate so neither helper +## probes when the caller already knows the port-owner status. +func _evaluate_recovery_port_occupant_proof(port: int, live: Dictionary = {}) -> Dictionary: + var proof := _evaluate_strong_port_occupant_proof(port, live) + if not str(proof.get("proof", "")).is_empty(): + return proof + + var current_live: Dictionary = live if not live.is_empty() else _probe_live_server_status_for_port(port) + if _live_status_identifies_godot_ai(current_live): + return {"proof": "status_name", "pids": _find_all_pids_on_port(port)} + + return {"proof": "", "pids": []} + + +func _recover_strong_port_occupant(port: int, wait_s: float, pre_kill_live: Dictionary = {}) -> bool: + return _lifecycle.recover_strong_port_occupant(port, wait_s, pre_kill_live) + + +func _legacy_pidfile_kill_targets(_port: int, listener_pids: Array[int]) -> Array[int]: + var targets: Array[int] = [] + var pidfile_pid := _read_pid_file_for_proof() + if pidfile_pid <= 1 or pidfile_pid == OS.get_process_id(): + return targets + ## An alive, branded pid-file PID is sufficient ownership proof. Under + ## `uvicorn --reload` the reloader writes the pid-file but a child worker + ## binds the port, so `listener_pids` never contains the reloader PID. + ## Requiring `listener_pids.has(pidfile_pid)` here used to silently skip + ## the kill path for the entire reload-shaped server family. The branded + ## listener loop below still does the per-PID brand check so we never + ## kill an unrelated process that happens to share the port. + if not _pid_alive_for_proof(pidfile_pid) or not _pid_cmdline_is_godot_ai_for_proof(pidfile_pid): + return targets + + for pid in listener_pids: + if pid <= 1 or pid == OS.get_process_id(): + continue + ## Reuse the brand result already proven above when this listener is + ## the same PID as the pidfile — saves a parent-chain walk and a + ## shell-out (PowerShell on Windows, /proc on Linux, ps on macOS) per + ## startup proof evaluation. + if pid == pidfile_pid or _pid_cmdline_is_godot_ai_for_proof(pid): + targets.append(pid) + ## Also kill the reloader/launcher itself when it isn't already a listener. + ## Without this, `--reload` workers would be killed but their parent would + ## immediately respawn a replacement and the port would never free. + if not targets.has(pidfile_pid): + targets.append(pidfile_pid) + return targets + + +func _read_pid_file_for_proof() -> int: + return _read_pid_file() + + +func _pid_alive_for_proof(pid: int) -> bool: + return _pid_alive(pid) + + +func _pid_cmdline_is_godot_ai_for_proof(pid: int) -> bool: + return _pid_cmdline_is_godot_ai(pid) + + +static func _parse_windows_netstat_pid(stdout: String, port: int) -> int: + return PortResolver.parse_windows_netstat_pid(stdout, port) + + +static func _parse_windows_netstat_pids(stdout: String, port: int) -> Array[int]: + return PortResolver.parse_windows_netstat_pids(stdout, port) + + +static func _parse_windows_netstat_listening(stdout: String, port: int) -> bool: + return PortResolver.parse_windows_netstat_listening(stdout, port) + + +static func _split_on_whitespace(s: String) -> PackedStringArray: + return PortResolver.split_on_whitespace(s) + + +static func _read_pid_file() -> int: + return PortResolver.read_pid_file() + + +static func _clear_pid_file() -> void: + PortResolver.clear_pid_file() + + +func _stop_server() -> void: + _lifecycle.stop_server() + + + + +## Clear the managed-server record and pid-file only if `port` is free. +## Returns true when state was cleared. Extracted from `_stop_server` so +## the "preserve on failed kill" contract is independently testable. +func _finalize_stop_if_port_free(port: int) -> bool: + if _is_port_in_use(port): + return false + _clear_managed_server_record() + _clear_pid_file() + return true + + +## Shared tail of the server CLI: transport, ports, and `--pid-file`. Both +## the initial spawn in `_start_server` and the `--refresh` retry in +## `_respawn_with_refresh` go through here so a new flag added in one place +## can't silently drop out of the other. +static func _build_server_flags(port: int, ws_port: int) -> Array[String]: + var flags: Array[String] = [] + flags.assign([ + "--transport", "streamable-http", + "--port", str(port), + "--ws-port", str(ws_port), + "--pid-file", ProjectSettings.globalize_path(SERVER_PID_FILE), + ]) + ## Append `--exclude-domains` only when the user has actually picked at + ## least one domain to drop. Skipping the empty case keeps spawns + ## compatible with older (pre-1.4.2) servers that don't know the flag — + ## relevant during staggered plugin/server upgrades in user-mode installs. + var excluded := ClientConfigurator.excluded_domains() + if not excluded.is_empty(): + flags.append("--exclude-domains") + flags.append(excluded) + return flags + + +## Returns true only when we can prove `pid`'s command line carries the +## `godot-ai` brand AND a server flag (`--pid-file` / `--transport`). Used by +## automatic kill paths (`_legacy_pidfile_kill_targets`) so a stale pidfile +## whose PID has been recycled by an unrelated listener can't hand us a +## kill target. If the OS lookup fails or returns an empty cmdline we +## conservatively return false — better to surface incompatible-server and +## let the user click Restart than to kill the wrong process. +func _pid_cmdline_is_godot_ai(pid: int) -> bool: + ## Walks up the parent chain so a uvicorn `--reload` worker whose + ## cmdline is just `multiprocessing.spawn` still matches when its + ## parent reloader carries the godot_ai brand. Bound the walk so a + ## hypothetical loop or runaway PPID can't stall the editor. + var current := pid + for _i in range(5): + if current <= 1: + return false + var cmd := "" + if OS.get_name() == "Windows": + cmd = _windows_pid_commandline(current) + else: + cmd = _posix_pid_commandline(current) + if _commandline_is_godot_ai_server(cmd): + return true + current = _pid_parent(current) + return false + + +func _pid_parent(pid: int) -> int: + if pid <= 1: + return 0 + if OS.get_name() == "Windows": + var output: Array = [] + var script := ( + "Get-CimInstance Win32_Process -Filter 'ProcessId = %d' | " + + "Select-Object -ExpandProperty ParentProcessId" + ) % pid + _startup_trace_count("powershell") + if _execute_windows_powershell(script, output) != 0 or output.is_empty(): + return 0 + return int(str(output[0]).strip_edges()) + var output_posix: Array = [] + if OS.execute("ps", ["-o", "ppid=", "-p", str(pid)], output_posix, true) != 0 or output_posix.is_empty(): + return 0 + return int(str(output_posix[0]).strip_edges()) + + +static func _commandline_is_godot_ai_server(cmd: String) -> bool: + if cmd.is_empty(): + return false + var lower := cmd.to_lower() + ## The server is invoked with `--pid-file /godot_ai_server.pid`, + ## so the path itself contains "godot_ai". A naive substring brand + ## search would falsely match an unrelated process whose cmdline + ## happens to reference a similarly-named pidfile path. Strip the + ## value (but leave the bare flag for the has_flag check) before + ## brand matching. + var brand_search := _strip_pidfile_value(lower) + var has_brand := brand_search.find("godot-ai") >= 0 or brand_search.find("godot_ai") >= 0 + var has_flag := lower.find("--pid-file") >= 0 or lower.find("--transport") >= 0 + return has_brand and has_flag + + +static func _strip_pidfile_value(cmd: String) -> String: + var rx := RegEx.new() + ## Match `--pid-file=` and `--pid-file `; keep the bare + ## flag so the flag-presence check still succeeds for a real server. + if rx.compile("--pid-file(?:=|\\s+)\\S+") != OK: + return cmd + return rx.sub(cmd, "--pid-file ", true) + + +func _windows_pid_commandline(pid: int) -> String: + var output: Array = [] + var script := ( + "Get-CimInstance Win32_Process -Filter 'ProcessId = %d' | " + + "Select-Object -ExpandProperty CommandLine" + ) % pid + _startup_trace_count("powershell") + var exit_code := _execute_windows_powershell(script, output) + if exit_code != 0 or output.is_empty(): + return "" + return str(output[0]) + + +## POSIX command-line lookup. Linux exposes `/proc//cmdline` as +## NUL-separated argv — read it directly so we avoid a `ps` fork on Linux +## and get the full argv rather than the truncated/quoted form some `ps` +## builds emit. Falls back to `ps -ww -p -o args=` on macOS / *BSD, +## which lack a Linux-style `/proc//cmdline`. Returns "" on failure +## so callers conservatively reject the PID rather than killing it blind. +func _posix_pid_commandline(pid: int) -> String: + var proc_path := "/proc/%d/cmdline" % pid + if FileAccess.file_exists(proc_path): + var f := FileAccess.open(proc_path, FileAccess.READ) + if f != null: + ## procfs pseudo-files report length 0 (the kernel generates + ## content on read). `get_length()` therefore returns 0 and + ## `get_buffer(0)` reads nothing. Read in chunks until EOF + ## instead. Cap at ARG_MAX-class bound so a hypothetically + ## misbehaving file can never stall the editor frame. + var bytes := PackedByteArray() + var max_bytes := 1 << 20 # 1 MiB + while bytes.size() < max_bytes: + var chunk := f.get_buffer(4096) + if chunk.is_empty(): + break + bytes.append_array(chunk) + if f.eof_reached(): + break + f.close() + ## /proc cmdline is NUL-separated argv; convert NULs to spaces + ## so the substring fingerprint matches the same way it does on + ## the Windows path. Empty (kernel threads, exited processes) + ## bubbles up as "" via the strip below. + for i in range(bytes.size()): + if bytes[i] == 0: + bytes[i] = 0x20 + return bytes.get_string_from_utf8().strip_edges() + ## `-ww` removes ps's column-width truncation so trailing flags like + ## --pid-file / --transport aren't dropped from the args= field. + ## Both procps (Linux) and BSD ps (macOS / *BSD) accept the + ## double-w form. + var output: Array = [] + var exit_code := OS.execute("ps", ["-ww", "-p", str(pid), "-o", "args="], output, true) + if exit_code != 0 or output.is_empty(): + return "" + return str(output[0]).strip_edges() + + +## True if the given PID corresponds to a live (non-zombie) process. +## POSIX uses `ps -o stat=` (see inline comment for the zombie rationale); +## Windows uses `tasklist`. Called by `_start_server` to distinguish a live +## managed server that outlived its editor from a stale EditorSettings +## record, and by `_check_server_health` to detect a fast-failing launcher. +static func _pid_alive(pid: int) -> bool: + return PortResolver.pid_alive(pid) + + +## Calls `_is_port_in_use` (not `PortResolver.wait_for_port_free`) so +## `_ProofPlugin` overrides keep driving the loop. +func _wait_for_port_free(port: int, timeout_s: float) -> void: + var deadline := Time.get_ticks_msec() + int(timeout_s * 1000.0) + while _is_port_in_use(port): + if Time.get_ticks_msec() >= deadline: + push_warning("MCP | port %d still in use after %.1fs — proceeding anyway" % [port, timeout_s]) + return + OS.delay_msec(100) + + +func _read_managed_server_record() -> Dictionary: + var es := EditorInterface.get_editor_settings() + if es == null: + return {"pid": 0, "version": "", "ws_port": 0} + var pid: int = 0 + if es.has_setting(MANAGED_SERVER_PID_SETTING): + pid = int(es.get_setting(MANAGED_SERVER_PID_SETTING)) + var version: String = "" + if es.has_setting(MANAGED_SERVER_VERSION_SETTING): + version = str(es.get_setting(MANAGED_SERVER_VERSION_SETTING)) + var ws_port: int = 0 + if es.has_setting(MANAGED_SERVER_WS_PORT_SETTING): + ws_port = int(es.get_setting(MANAGED_SERVER_WS_PORT_SETTING)) + return {"pid": pid, "version": version, "ws_port": ws_port} + + +func _write_managed_server_record(pid: int, version: String) -> void: + var es := EditorInterface.get_editor_settings() + if es == null: + return + es.set_setting(MANAGED_SERVER_PID_SETTING, pid) + es.set_setting(MANAGED_SERVER_VERSION_SETTING, version) + es.set_setting(MANAGED_SERVER_WS_PORT_SETTING, _resolved_ws_port) + + +func _clear_managed_server_record() -> void: + var es := EditorInterface.get_editor_settings() + if es == null: + return + if es.has_setting(MANAGED_SERVER_PID_SETTING): + es.set_setting(MANAGED_SERVER_PID_SETTING, 0) + if es.has_setting(MANAGED_SERVER_VERSION_SETTING): + es.set_setting(MANAGED_SERVER_VERSION_SETTING, "") + if es.has_setting(MANAGED_SERVER_WS_PORT_SETTING): + es.set_setting(MANAGED_SERVER_WS_PORT_SETTING, 0) + + +func prepare_for_update_reload() -> void: + _lifecycle.prepare_for_update_reload() + + +func _adopt_compatible_server(record_version: String, current_version: String, owner: int) -> String: + return _lifecycle.adopt_compatible_server(record_version, current_version, owner) + + +static func _compatible_adoption_log_message( + owner_label: String, + owned_pid: int, + observed_owner_pid: int, + live_version: String, + live_ws_port: int, + current_version: String +) -> String: + if owner_label == "managed": + return "MCP | adopted managed server (PID %d, live v%s, WS %d, plugin v%s)" % [ + owned_pid, + live_version, + live_ws_port, + current_version + ] + return "MCP | adopted external server owner_pid=%d (live v%s, WS %d, plugin v%s)" % [ + observed_owner_pid, + live_version, + live_ws_port, + current_version + ] + + +## Hand the self-update over to a tiny runner that is not owned by this +## EditorPlugin. The runner keeps the editor process alive, but disables this +## plugin before extracting/scanning the new scripts so every plugin-owned +## instance tears down on pre-update bytecode and pre-update field storage. +func install_downloaded_update(zip_path: String, temp_dir: String, source_dock: Control) -> void: + prepare_for_update_reload() + + var detached_dock = null + if _dock != null and is_instance_valid(_dock): + detached_dock = _dock + remove_control_from_docks(_dock) + _dock = null + elif source_dock != null and is_instance_valid(source_dock): + detached_dock = source_dock + remove_control_from_docks(source_dock) + + var runner = UPDATE_RELOAD_RUNNER_SCRIPT.new() + var parent: Node = EditorInterface.get_base_control() + if parent == null: + parent = get_tree().root + parent.add_child(runner) + runner.start(zip_path, temp_dir, detached_dock) + + +func can_recover_incompatible_server() -> bool: + return _lifecycle.can_recover_incompatible_server() + + +func _resume_connection_after_recovery() -> void: + if _connection == null: + return + var state: int = _lifecycle.get_state() + if ( + _lifecycle.is_connection_blocked() + or ( + state != ServerStateScript.SPAWNING + and state != ServerStateScript.READY + ) + ): + return + _connection.connect_blocked = false + _connection.connect_block_reason = "" + _connection.server_version = "" + _connection.set_process(true) + _arm_server_version_check() + + +func recover_incompatible_server() -> bool: + if not _lifecycle.recover_incompatible_server(): + return false + _resume_connection_after_recovery() + return true + + +## Kill whichever process is holding `http_port()` right now — by resolving +## the port-owning PID via pid-file / netstat / lsof, independent of whether +## we ever set the manager's `_server_pid` — then clear ownership state +## and respawn via the lifecycle manager. The dock's version-mismatch +## banner wires here when the plugin adopted a foreign server whose +## `server_version` drifts from the current plugin version. +func force_restart_server() -> void: + _lifecycle.force_restart_server() + + +## Single entry point for the dock's primary "Restart Dev Server" button. +## The user clicking Restart is explicit consent to take over the HTTP port, +## so this is aggressive: any PID holding the port gets killed (managed, +## branded-dev, or orphan multiprocessing.spawn workers whose parent died +## so brand detection misses them). After the port frees we spawn a fresh +## --reload dev server. Returns true if a kill happened, false if the port +## was already free and we just spawned. +func force_restart_or_start_dev_server() -> bool: + var port := ClientConfigurator.http_port() + var killed := false + if has_managed_server(): + _lifecycle.reset_for_force_restart() + if _is_port_in_use(port): + _kill_processes_and_windows_spawn_children(_find_all_pids_on_port(port)) + killed = true + if killed: + ## OS.kill returns synchronously but uvicorn's listener can take + ## longer to release the port. Without this wait, start_dev_server's + ## fixed 500ms timer races the old shutdown and the new --reload + ## spawn fails to bind. + _wait_for_port_free(port, 5.0) + start_dev_server() + return killed + + +func start_dev_server() -> void: + ## Start a dev server with --reload that survives plugin reloads. + ## Kills any managed server first, waits for the port to free, then spawns. + ## + ## PYTHONPATH handling: when `res://` sits inside a checkout that owns a + ## `src/godot_ai/` (root repo or a git worktree), prepend that `src/` to + ## PYTHONPATH so `import godot_ai` and uvicorn's `reload_dirs` both pick + ## up *this* tree's source rather than the root repo's editable install. + ## On the root repo the path matches the installed package, so this is a + ## no-op; in a worktree it's what makes `--reload` actually watch the + ## worktree's Python. See #84. + _stop_server() + get_tree().create_timer(0.5).timeout.connect(func(): + var server_cmd := ClientConfigurator.get_server_command() + if server_cmd.is_empty(): + push_warning("MCP | could not find server command for dev server") + return + + var cmd: String = server_cmd[0] + _set_resolved_ws_port(_resolve_ws_port()) + var inner_args: Array[String] = [] + inner_args.assign(server_cmd.slice(1)) + inner_args.append_array([ + "--transport", "streamable-http", + "--port", str(ClientConfigurator.http_port()), + "--ws-port", str(_resolved_ws_port), + "--reload", + ]) + + var worktree_src := ClientConfigurator.find_worktree_src_dir(ProjectSettings.globalize_path("res://")) + var prev_pythonpath := OS.get_environment("PYTHONPATH") + if not worktree_src.is_empty(): + var sep := ";" if OS.get_name() == "Windows" else ":" + var new_pp := worktree_src if prev_pythonpath.is_empty() else worktree_src + sep + prev_pythonpath + OS.set_environment("PYTHONPATH", new_pp) + + var injected_telemetry: bool = _lifecycle._inject_telemetry_env() + var pid := OS.create_process(cmd, inner_args) + if injected_telemetry: + OS.unset_environment("GODOT_AI_DISABLE_TELEMETRY") + + ## Restore PYTHONPATH immediately — the spawned child has already + ## copied the env, so the editor's own process state returns to + ## baseline. Leaving it set would leak to any later OS.create_process + ## from unrelated paths. + if not worktree_src.is_empty(): + if prev_pythonpath.is_empty(): + OS.unset_environment("PYTHONPATH") + else: + OS.set_environment("PYTHONPATH", prev_pythonpath) + + if pid > 0: + ## Match `server_lifecycle.gd::start_server`'s log wording — + ## "prefix" since we prepended to any pre-existing PYTHONPATH, + ## not replaced it. See #429 review. + var suffix := " (PYTHONPATH prefix=%s)" % worktree_src if not worktree_src.is_empty() else "" + print("MCP | started dev server with --reload (PID %d): %s %s%s" % [pid, cmd, " ".join(inner_args), suffix]) + else: + push_warning("MCP | failed to start dev server") + ) + + +func stop_dev_server() -> void: + ## Stop any server running on the HTTP port (by port, not PID). + ## Used for dev servers whose PID we don't track across reloads. + if _lifecycle.get_server_pid() > 0: + # We have a managed server — use normal stop + _stop_server() + return + var port := ClientConfigurator.http_port() + var candidates: Array[int] = [] + for pid in _find_all_pids_on_port(port): + var candidate := int(pid) + if _pid_cmdline_is_godot_ai(candidate): + candidates.append(candidate) + var killed := _kill_processes_and_windows_spawn_children(candidates) + if not killed.is_empty(): + print("MCP | stopped dev server on port %d" % port) + + +func _kill_processes_and_windows_spawn_children(pids: Array[int]) -> Array[int]: + var unique: Array[int] = [] + for pid in pids: + if pid > 0 and not unique.has(pid): + unique.append(pid) + if OS.get_name() == "Windows": + for child_pid in _find_windows_spawn_children(unique): + if not unique.has(child_pid): + unique.append(child_pid) + var killed: Array[int] = [] + for pid in unique: + if OS.get_name() == "Windows": + var output: Array = [] + var exit_code := OS.execute("taskkill", ["/PID", str(pid), "/T", "/F"], output, true) + if exit_code == 0 or not _pid_alive(pid): + killed.append(pid) + else: + OS.kill(pid) + killed.append(pid) + return killed + + +func _find_windows_spawn_children(parent_pids: Array[int]) -> Array[int]: + if parent_pids.is_empty(): + var empty: Array[int] = [] + return empty + var found: Array[int] = [] + for parent_pid in parent_pids: + var output: Array = [] + var script := ( + "Get-CimInstance Win32_Process | " + + "Where-Object { $_.CommandLine -like '*spawn_main(parent_pid=%d*' } | " + + "ForEach-Object { $_.ProcessId }" + ) % parent_pid + _startup_trace_count("powershell") + var exit_code := _execute_windows_powershell(script, output) + if exit_code != 0 or output.is_empty(): + continue + for pid in _parse_pid_lines(str(output[0])): + if not found.has(pid): + found.append(pid) + return found + + +func is_dev_server_running() -> bool: + ## Returns true if a branded dev server is running on the HTTP port + ## that we didn't start as managed. + if _lifecycle.get_server_pid() > 0: + return false + for pid in _find_all_pids_on_port(ClientConfigurator.http_port()): + if _pid_cmdline_is_godot_ai(int(pid)): + return true + return false + + +func has_managed_server() -> bool: + ## Returns true if the plugin is currently managing a server process it spawned. + return _lifecycle.has_managed_server() + + +func can_restart_managed_server() -> bool: + ## Restart is allowed only when we have ownership proof. A live PID + ## means this plugin spawned/adopted a managed server; a non-empty + ## managed record is the cross-session proof used by the drift branch. + return _lifecycle.can_restart_managed_server() diff --git a/addons/godot_ai/plugin.gd.uid b/addons/godot_ai/plugin.gd.uid new file mode 100644 index 0000000..4c550dd --- /dev/null +++ b/addons/godot_ai/plugin.gd.uid @@ -0,0 +1 @@ +uid://d3ui3yx6vdigl diff --git a/addons/godot_ai/runtime/draw_recipe.gd b/addons/godot_ai/runtime/draw_recipe.gd new file mode 100644 index 0000000..3c66edd --- /dev/null +++ b/addons/godot_ai/runtime/draw_recipe.gd @@ -0,0 +1,107 @@ +@tool +extends Control + +## Runtime helper attached by control_draw_recipe and pattern_corner_brackets. +## 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 + ) + "corner_brackets": + # Synthesized op used by pattern_corner_brackets. Draws 8 line + # segments at the four corners of self.size, so brackets track + # parent resizes. Emitted by PatternHandler, not control_draw_recipe. + var L := float(op.get("length", 18.0)) + var T := float(op.get("thickness", 2.0)) + var c: Color = op.color + var w := size.x + var h := size.y + # Top-left + draw_line(Vector2(0, 0), Vector2(L, 0), c, T) + draw_line(Vector2(0, 0), Vector2(0, L), c, T) + # Top-right + draw_line(Vector2(w, 0), Vector2(w - L, 0), c, T) + draw_line(Vector2(w, 0), Vector2(w, L), c, T) + # Bottom-left + draw_line(Vector2(0, h), Vector2(L, h), c, T) + draw_line(Vector2(0, h), Vector2(0, h - L), c, T) + # Bottom-right + draw_line(Vector2(w, h), Vector2(w - L, h), c, T) + draw_line(Vector2(w, h), Vector2(w, h - L), c, T) 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/game_helper.gd b/addons/godot_ai/runtime/game_helper.gd new file mode 100644 index 0000000..cfcefb6 --- /dev/null +++ b/addons/godot_ai/runtime/game_helper.gd @@ -0,0 +1,869 @@ +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 + +const LoggerLoader := preload("res://addons/godot_ai/runtime/logger_loader.gd") + +var _registered := false +## Untyped because the McpGameLogger script is loaded dynamically (it +## extends Logger, which only exists in Godot 4.5+). +var _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 + + +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 + ## 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 subclassing was added in Godot 4.5 — gate on + ## ClassDB so the rest of the helper still loads on older engines. + ## game_logger.gd lives in the `.gdignore`'d runtime/loggers/ folder so + ## it never parse-errors during a < 4.5 editor scan; LoggerLoader + ## compiles it from source at runtime, only past this gate. + if ClassDB.class_exists("Logger") and OS.has_method("add_logger"): + var logger_script := LoggerLoader.build(LoggerLoader.GAME_LOGGER_PATH) + if logger_script != null: + _logger = logger_script.new() + OS.call("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: + ## 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 and OS.has_method("remove_logger"): + OS.call("remove_logger", _logger) + _logger_attached = false + _logger = null + + +## Dispatched for messages prefixed "mcp:" on the debugger channel. +## Different Godot versions pass either the tail ("take_screenshot") or the +## full message ("mcp:take_screenshot") to the capture callable — accept +## both forms so this works across 4.2/4.3/4.4/4.5. +func _on_debug_message(message: String, data: Array) -> bool: + var action := message.trim_prefix("mcp:") + match action: + "take_screenshot": + _handle_take_screenshot(data) + return true + "eval": + _handle_eval(data) + return true + "eval_check": + _handle_eval_check(data) + return true + "game_command": + _handle_game_command(data) + return true + return false + + +func _handle_take_screenshot(data: Array) -> void: + var request_id: String = data[0] if data.size() > 0 else "" + var max_resolution: int = int(data[1]) if data.size() > 1 else 0 + + var viewport := get_tree().root + if viewport == null: + _reply_error(request_id, "No game root viewport available") + return + + 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 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) + 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) + + var png := image.save_png_to_buffer() + var b64 := Marshalls.raw_to_base64(png) + + EngineDebugger.send_message("mcp:screenshot_response", [ + request_id, + b64, + image.get_width(), + image.get_height(), + original_width, + original_height, + ]) + + +func _reply_error(request_id: String, message: String) -> void: + 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_state": + result = _game_input_state(json.data) + _: + _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(): + scene_root = EditorInterface.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 := _dict_to_vector2(params.get("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_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} + + +func _dict_to_vector2(value: Variant) -> Vector2: + var viewport := get_viewport() + var fallback := viewport.get_mouse_position() if viewport != null else Vector2.ZERO + if value is Dictionary: + if value.is_empty() or (not value.has("x") and not value.has("y")): + return fallback + return Vector2(float(value.get("x", fallback.x)), float(value.get("y", fallback.y))) + return fallback + + +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 actionable +## "Eval exceeded 8s" message; the editor timer emits a *generic* "Game eval +## timed out" message. Raise this at/above the editor timer (or drop that +## timer below this) and the generic message wins the race, silently losing +## the diagnostic this fix exists to provide. Nothing enforces the order — +## change one, re-check the other two. +## +## NOTE: this catches a hung `await`, not a CPU-bound loop with no `await` — +## a tight `while true:` with no yield blocks the main thread, so nothing +## (including this poll) runs until it yields. That case is out of scope. +const EVAL_TIMEOUT_SEC := 8.0 + + +func _handle_eval(data: Array) -> void: + var request_id: String = data[0] if data.size() > 0 else "" + var code: String = data[1] if data.size() > 1 else "" + + if code.is_empty(): + _reply_eval_error(request_id, "No code provided") + return + + ## Wrap user code in an execute() coroutine (so it can `await` internally) + ## whose inner function is uniquely named per eval. A runtime error's + ## backtrace then carries `_mcp_run_`, letting us attribute it to + ## THIS eval — not an unrelated background game error, and not a sibling + ## overlapping eval. (#490) + _eval_token_counter += 1 + var token := str(_eval_token_counter) + var run_fn := "_mcp_run_%s" % token + var script_source := ( + "extends Node\n" + + "func execute():\n" + + "\treturn await %s()\n\n" % run_fn + + "func %s():\n" % run_fn + + _indent_eval_code(code) + ) + + ## Snapshot the logger's script-error seq BEFORE running so we only attribute + ## errors raised by this eval. In a debug build a parse error aborts reload() + ## and a runtime error aborts execute() — either way this function may never + ## reach its reply: the editor infers a compile error from the missing + ## mcp:eval_compiled beacon, and a runtime error is reported (via the + ## eval_check probe / the in-flight poll loop) once a logged error past this + ## baseline carries this eval's token. + var baseline: int = _logger.script_error_seq() if _logger != null else 0 + + var script: GDScript = GDScript.new() + script.source_code = script_source + ## #490: ack BEFORE reload(). A parse error aborts this function at reload() + ## without a return code in a debug build, so this is our only chance to tell + ## the editor "received + about to compile." The editor uses that to tell a + ## real parse error (acked, never compiled) apart from a message it simply + ## hasn't serviced yet (never acked); see mcp_debugger_plugin._on_eval_grace. + EngineDebugger.send_message("mcp:eval_ack", [request_id]) + ## reload() ABORTS this function on a parse error in a debug build (it does + ## not return a non-OK code there), so the lines below only run when the + ## source compiled. Keep reload() INLINE — moving it behind a timer/await + ## poisons subsequent evals (#490). The err branch still matters for the + ## editor process (handler unit tests), where reload() does return. + var err: int = script.reload() + if err != OK: + _reply_eval_error(request_id, + "Failed to compile GDScript (error %d). Check syntax." % err) + return + + ## Compiled OK — tell the editor so its grace timer doesn't flag a compile + ## error and so it begins probing for a runtime error. + EngineDebugger.send_message("mcp:eval_compiled", [request_id]) + + var temp_node := Node.new() + temp_node.set_script(script) + temp_node.process_mode = Node.PROCESS_MODE_ALWAYS + add_child(temp_node) + + if not temp_node.has_method("execute"): + temp_node.queue_free() + _reply_eval_error(request_id, "Internal error: eval wrapper is missing execute().") + return + + ## Register in-flight BEFORE running: a runtime error aborts execute() (and + ## may unwind this function) before we could record it afterward, and the + ## editor probe / poll loop need the entry to attribute and report the error. + _inflight_evals[request_id] = {"node": temp_node, "token": token, "baseline": baseline} + + ## Drive execute() as a fire-and-forget coroutine that records its outcome + ## into `holder`, then poll frames until it finishes or the deadline passes + ## (#488's hung-await guard). A plain `await temp_node.execute()` has no + ## escape hatch: if user code never returns, we never reach the reply/cleanup + ## below and the request hangs with the node leaked. + var holder := {"done": false, "value": null, "abandoned": false} + _drive_eval(temp_node, holder) + + var tree := get_tree() + var deadline_ms := int(EVAL_TIMEOUT_SEC * 1000.0) + var start_ms := Time.get_ticks_msec() + while not holder["done"] and (Time.get_ticks_msec() - start_ms) < deadline_ms: + ## #490 focused fast path: a runtime error aborts _drive_eval (holder + ## never completes), so check each frame whether THIS eval's token now + ## appears in a logged error and report it immediately. (Backgrounded, + ## this loop is frozen and the editor probe does the same job.) + if _try_report_eval_runtime_error(request_id): + holder["abandoned"] = true + return + await tree.process_frame + + if not holder["done"]: + ## Past the 8s deadline. Disambiguate a runtime error (its token is in a + ## logged error) from a genuine hung await before the generic timeout. + holder["abandoned"] = true + if _try_report_eval_runtime_error(request_id): + return + _inflight_evals.erase(request_id) + if is_instance_valid(temp_node): + remove_child(temp_node) + _reply_eval_error(request_id, + ("Eval exceeded %ds and was aborted — the code likely awaits " + + "something that never completes (a signal that never fires, a timer on " + + "a paused tree) or loops forever. Check logs_read(source='game').") + % int(EVAL_TIMEOUT_SEC)) + 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 + + +func _reply_eval_error(request_id: String, message: String) -> void: + EngineDebugger.send_message("mcp:eval_error", [request_id, message]) + + +func _reply_eval_response(request_id: String, value: Variant) -> void: + EngineDebugger.send_message("mcp:eval_response", + [request_id, JSON.stringify(_variant_to_json(value))]) + + +## #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/logger_loader.gd b/addons/godot_ai/runtime/logger_loader.gd new file mode 100644 index 0000000..974a4de --- /dev/null +++ b/addons/godot_ai/runtime/logger_loader.gd @@ -0,0 +1,56 @@ +@tool +extends RefCounted + +## Runtime builder for the `extends Logger` scripts in `runtime/loggers/`. +## +## `Logger` is a Godot 4.5+ class. A `.gd` file that statically declares +## `extends Logger` is rejected by the parser on Godot < 4.5 — and Godot's +## editor filesystem scan parses *every* `.gd` under the project, so just +## shipping `editor_logger.gd` / `game_logger.gd` printed two +## `Parse Error: Could not find base class "Logger"` lines on every 4.3/4.4 +## editor startup (#475 follow-up). They were functionally harmless (the +## scripts are only ever instanced behind a `ClassDB.class_exists("Logger")` +## gate) but they were real red error text we shouldn't ship. +## +## Fix: the two logger scripts live in `runtime/loggers/`, which carries a +## `.gdignore` so the editor scan skips the folder entirely — no parse, no +## error, on any engine. This loader reads the source off disk with +## `FileAccess` (unaffected by `.gdignore`, which only governs the resource +## importer) and compiles it at runtime via `GDScript.new()`. Callers gate +## on `ClassDB.class_exists("Logger")` first, so `build()` only ever runs on +## 4.5+, where `extends Logger` resolves cleanly. +## +## This script itself does NOT extend Logger, so it parses on every engine +## and is safe to `preload` from `plugin.gd` and `game_helper.gd`. + +const EDITOR_LOGGER_PATH := "res://addons/godot_ai/runtime/loggers/editor_logger.gd" +const GAME_LOGGER_PATH := "res://addons/godot_ai/runtime/loggers/game_logger.gd" + + +## Compile a `.gdignore`'d logger script from its on-disk source. Returns the +## ready-to-instance GDScript, or null if the file is missing (e.g. excluded +## from an exported game) or fails to compile. Callers must already have +## confirmed `ClassDB.class_exists("Logger")` — building an `extends Logger` +## script on an engine without the class will fail the reload() and return +## null, which the gated callers treat as "logging unavailable". +static func build(path: String) -> GDScript: + if not FileAccess.file_exists(path): + return null + var source := FileAccess.get_file_as_string(path) + if source.is_empty(): + return null + var script := GDScript.new() + script.source_code = source + ## Deliberately do NOT set `script.resource_path`: this builds a fresh + ## anonymous GDScript every call, and a reload cycle (editor_reload_plugin, + ## self-update disable→enable) calls build() again for the same path. Two + ## live Resources sharing one non-empty resource_path trips Godot's + ## "Another resource is loaded from path ..." error and leaves the new + ## script with an empty path anyway — re-introducing red console text on + ## every reload, the exact thing this folder's .gdignore set out to remove. + ## game_helper.gd::_handle_eval compiles from source the same way and also + ## omits resource_path. The script still resolves its absolute preloads / + ## class_names fine without a path. + if script.reload() != OK: + return null + return script diff --git a/addons/godot_ai/runtime/logger_loader.gd.uid b/addons/godot_ai/runtime/logger_loader.gd.uid new file mode 100644 index 0000000..12f2c96 --- /dev/null +++ b/addons/godot_ai/runtime/logger_loader.gd.uid @@ -0,0 +1 @@ +uid://d3plpedkpvec6 diff --git a/addons/godot_ai/runtime/loggers/.gdignore b/addons/godot_ai/runtime/loggers/.gdignore new file mode 100644 index 0000000..e69de29 diff --git a/addons/godot_ai/runtime/loggers/editor_logger.gd b/addons/godot_ai/runtime/loggers/editor_logger.gd new file mode 100644 index 0000000..87bbcb3 --- /dev/null +++ b/addons/godot_ai/runtime/loggers/editor_logger.gd @@ -0,0 +1,151 @@ +@tool +extends Logger + +## Editor-process Logger subclass. +## +## NOTE: deliberately no `class_name` — `extends Logger` requires the Logger +## class which Godot only exposes from 4.5+. This file lives in the +## `.gdignore`'d `runtime/loggers/` folder so Godot's editor filesystem scan +## skips it entirely — on Godot < 4.5 it is never parsed, so it emits no +## "Could not find base class Logger" error (it used to, before #475's +## follow-up). plugin.gd builds it from source at runtime via +## `logger_loader.gd` and only calls OS.add_logger() after gating on +## ClassDB.class_exists("Logger"), so the `extends Logger` parse only ever +## happens on 4.5+ where it resolves. 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. +## This script is compiled from source at runtime by logger_loader.gd; 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") + +## McpEditorLogBuffer — untyped because this script is loaded dynamically and +## McpEditorLogBuffer's class_name isn't yet registered on the parser at the +## time `extends Logger` resolves. 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/loggers/game_logger.gd b/addons/godot_ai/runtime/loggers/game_logger.gd new file mode 100644 index 0000000..3fb8edc --- /dev/null +++ b/addons/godot_ai/runtime/loggers/game_logger.gd @@ -0,0 +1,158 @@ +@tool +extends Logger + +## Game-process Logger subclass. +## +## NOTE: deliberately no `class_name` — `extends Logger` requires the Logger +## class which Godot only exposes from 4.5+. This file lives in the +## `.gdignore`'d `runtime/loggers/` folder so Godot's editor filesystem scan +## skips it entirely — on Godot < 4.5 it is never parsed, so it emits no +## "Could not find base class Logger" error (it used to, before #475's +## follow-up). game_helper.gd builds it from source at runtime via +## `logger_loader.gd` and only calls OS.add_logger() after gating on +## ClassDB.class_exists("Logger"). 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 in script_backtraces: + if bt != null and bt.get_frame_count() > 0: + for i 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 (with inlined path:line @ function) of the most recent +## script-type runtime error, or "" if none seen this run. +func last_script_error_text() -> String: + _mutex.lock() + var v: String = _recent_script_errors[-1]["text"] if not _recent_script_errors.is_empty() else "" + _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/telemetry.gd b/addons/godot_ai/telemetry.gd new file mode 100644 index 0000000..4369caf --- /dev/null +++ b/addons/godot_ai/telemetry.gd @@ -0,0 +1,199 @@ +## Plugin-side telemetry helper. +## +## Relays plugin-only events (dock startup, self-update outcome, plugin +## reload, dev-server toggle) to the Python MCP server via the existing +## `send_event("plugin_event", {...})` channel. The server's +## `transport/websocket.py` allowlists event names and forwards into the +## central telemetry pipeline — meaning opt-out, endpoint, customer_uuid +## and the bounded-queue worker stay in one place (Python), not +## duplicated in GDScript. +## +## Opt-out options priority: +## 1. `GODOT_AI_DISABLE_TELEMETRY` / `DISABLE_TELEMETRY` env vars — +## checked first so CI / operators can force-disable without touching +## EditorSettings. +## 2. The `godot_ai/telemetry_enabled` EditorSetting — set through the +## MCP dock and persisted between sessions. +## +## When telemetry is disabled, events are never buffered or sent. Only a +## *truthy* env var force-disables; a falsey or absent env var falls through +## to the EditorSetting (which defaults to enabled). See McpSettings.telemetry_enabled. +## +## Buffering: events recorded before the WebSocket is connected go into +## a small bounded buffer and flush on the next `record_event` call once +## connected. The buffer is intentionally small (`_MAX_BUFFER`); plugin +## events are sparse, and a flood means something is misconfigured. + +extends RefCounted + +## Allowlist mirrored on the Python side in +## `src/godot_ai/transport/websocket.py::_PLUGIN_EVENT_NAMES`. Update +## both together. +const _ALLOWED_EVENTS := [ + "dock_startup", + "plugin_reload", + "self_update", + "dev_server_toggle", +] + +const _MAX_BUFFER := 32 + +## EditorSetting key used to defer a ``plugin_reload`` event across the +## disable -> enable boundary. Callers that trigger plugin reload (the +## dock reload button, ``editor_reload_plugin`` MCP-tool path) write +## here *before* the disable kills the live WebSocket; the new +## plugin's ``_enter_tree`` flushes via ``flush_pending_plugin_reload``. +const PENDING_PLUGIN_RELOAD_KEY := "godot_ai/pending_plugin_reload_event" + + +## Persist a ``plugin_reload`` event so the re-enabled plugin instance +## can emit it once its new WebSocket is up. Static so callers without +## a telemetry instance handle (e.g. ``editor_handler.reload_plugin``) +## can use it via the preloaded const alias. +static func record_pending_plugin_reload(source: String) -> void: + var settings := EditorInterface.get_editor_settings() + if settings == null: + return + settings.set_setting( + PENDING_PLUGIN_RELOAD_KEY, + JSON.stringify({"source": source, "success": true}), + ) + + +## Read + clear an EditorSetting JSON-encoded event payload. Returns +## the parsed dict, or ``null`` if the key is absent / empty / +## malformed. Used by ``flush_pending_plugin_reload`` (below) and by +## ``plugin.gd::_flush_pending_self_update_telemetry``. Centralising +## the read-and-clear dance keeps both flush sites symmetric with the +## ``record_pending_*`` writers and prevents the "key gets stuck" +## class of bug if a future flush helper forgets the clear step. +static func _drain_editor_setting_dict(key: String): + var settings := EditorInterface.get_editor_settings() + if settings == null: + return null + if not settings.has_setting(key): + return null + var raw := str(settings.get_setting(key)) + settings.set_setting(key, "") + if raw == "": + return null + var parsed = JSON.parse_string(raw) + if typeof(parsed) != TYPE_DICTIONARY: + return null + return parsed + +var _connection +var _disabled: bool = false +var _pending: Array = [] # of {name: String, data: Dictionary} + +func _init(connection) -> void: + _connection = connection + _disabled = not McpSettings.telemetry_enabled() + ## Subscribe to ``connection_state_changed`` so events buffered before + ## the WebSocket handshake (e.g. ``record_dock_startup`` from + ## ``plugin._enter_tree``) actually leave the editor. Without this, + ## the buffer only drained on the next ``record_event`` call — when + ## that call never came (the common single-session case), the very + ## events we cared about most sat in the queue forever. + if _connection != null and _connection.has_signal("connection_state_changed"): + _connection.connection_state_changed.connect(_on_connection_state_changed) + + +func record_event(name: String, data: Dictionary = {}) -> void: + if _disabled: + return + if not _ALLOWED_EVENTS.has(name): + ## Drop silently — matches the server's behavior for unknown + ## names, and avoids editor yellow-bar noise from third-party + ## callers or stale event names mid-rollout. + return + if _connection != null and _connection.is_connected: + _flush() + _send_one(name, data) + return + ## Pre-handshake: stash in a small bounded buffer. Drained on the + ## first ``connection_state_changed(true)`` after this point (see + ## ``_on_connection_state_changed``). Falling back to "drain on the + ## next record_event" is a footgun: the most useful plugin events + ## (``dock_startup``, pending ``self_update``) fire from + ## ``plugin._enter_tree`` before the handshake, and a single-session + ## editor may never emit a second event — so without the signal- + ## driven flush they sat buffered forever. + if _pending.size() >= _MAX_BUFFER: + _pending.pop_front() + _pending.append({"name": name, "data": data}) + + +func _on_connection_state_changed(is_open: bool) -> void: + if is_open: + _flush() + +func _flush() -> void: + if _pending.is_empty(): + return + var to_send := _pending.duplicate() + _pending.clear() + for entry in to_send: + _send_one(entry["name"], entry["data"]) + +func _send_one(name: String, data: Dictionary) -> void: + if _connection == null: + return + _connection.send_event("plugin_event", {"name": name, "data": data}) + +# --- convenience emitters -------------------------------------------------- + +func record_dock_startup(extra: Dictionary = {}) -> void: + record_event("dock_startup", extra) + +func record_plugin_reload(success: bool, error: String = "") -> void: + var data := {"success": success} + if error != "": + data["error"] = error.substr(0, 200) + record_event("plugin_reload", data) + +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. +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/stub_backtrace.gd b/addons/godot_ai/testing/stub_backtrace.gd new file mode 100644 index 0000000..d15dcd4 --- /dev/null +++ b/addons/godot_ai/testing/stub_backtrace.gd @@ -0,0 +1,38 @@ +@tool +extends RefCounted + +## Minimal duck-typed stand-in for Godot's built-in `ScriptBacktrace` +## class (the type of `script_backtraces[i]` entries inside `_log_error`). +## Mirrors the getter surface `_log_error`'s `script_backtraces` argument +## exposes (`get_frame_count` + per-frame file/line/function), so test +## suites for `editor_logger` and `game_logger` can exercise the +## backtrace-remapping path without a live script execution — Godot +## doesn't expose a constructor for the real ScriptBacktrace. +## +## Defaults to a single frame for existing tests, but can carry multiple +## frames so detail payload tests can verify full stack preservation. + +var _frames: Array[Dictionary] = [] + + +func _init(file: String, line: int, function: String, frames: Array[Dictionary] = []) -> void: + if frames.is_empty(): + _frames = [{"path": file, "line": line, "function": function}] + else: + _frames = frames + + +func get_frame_count() -> int: + return _frames.size() + + +func get_frame_file(idx: int) -> String: + return str(_frames[idx].get("path", "")) + + +func get_frame_line(idx: int) -> int: + return int(_frames[idx].get("line", 0)) + + +func get_frame_function(idx: int) -> String: + return str(_frames[idx].get("function", "")) diff --git a/addons/godot_ai/testing/stub_backtrace.gd.uid b/addons/godot_ai/testing/stub_backtrace.gd.uid new file mode 100644 index 0000000..4cbc92d --- /dev/null +++ b/addons/godot_ai/testing/stub_backtrace.gd.uid @@ -0,0 +1 @@ +uid://d2xpmw5kvtjr7 diff --git a/addons/godot_ai/testing/test_runner.gd b/addons/godot_ai/testing/test_runner.gd new file mode 100644 index 0000000..394c3ae --- /dev/null +++ b/addons/godot_ai/testing/test_runner.gd @@ -0,0 +1,244 @@ +@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. + +var _results: Array[Dictionary] = [] +var _last_run_ms: int = 0 + + +func run_suite(suite: McpTestSuite, test_filter: String = "", exclude_test_filter: String = "") -> void: + 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 + 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, + }) + continue + + suite._reset() + suite.setup() + suite.call(method_name) + suite.teardown() + + ## 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 := EditorInterface.get_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 suite._skipped: + _results.append({ + "suite": name, + "test": method_name, + "passed": true, + "skipped": true, + "message": suite._skip_reason, + "assertion_count": 0, + }) + continue + + 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)" + + _results.append({ + "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: + _results.clear() + var start := Time.get_ticks_msec() + + ## 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 + + 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 := EditorInterface.get_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, + }) + 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, + }) + else: + run_suite(suite, test_filter, exclude_test_filter) + suite.suite_teardown() + + ## 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) + + _last_run_ms = Time.get_ticks_msec() - start + McpLogBuffer.console_echo = _prev_console_echo + return get_results(verbose) + + +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..fe46f6d --- /dev/null +++ b/addons/godot_ai/testing/test_suite.gd @@ -0,0 +1,277 @@ +@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 + + +# ----- 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 = "" + +# ----- 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 = "" + + +func _reset_suite_state() -> void: + _suite_failed = false + _suite_failed_message = "" + _suite_skipped = false + _suite_skipped_reason = "" + + +## Mark the current test as skipped. Use when a precondition isn't met +## (e.g. no scene open, no Node3D in scene) and the test can't run. +## Skipped tests count separately from passed/failed. +func skip(reason: String = "") -> void: + _skipped = true + _skip_reason = reason + + +## Bail out of suite_setup() with a failure. Subsequent tests in this suite +## are not run; the runner reports a single suite-level failure with the +## given reason instead of N zero-assertion noise lines per test. +## +## Example: +## func suite_setup(ctx): +## var arena = preload("res://game/arena.gd").new() +## if arena == null: +## fail_setup("arena.gd failed to instantiate in @tool scope") +## return +func fail_setup(reason: String) -> void: + _suite_failed = true + _suite_failed_message = reason + + +## Bail out of suite_setup() because a precondition isn't met (no scene open, +## no game running, etc.). Subsequent tests are not run and the runner emits +## a single suite-level skip rather than per-test skip noise. +func skip_suite(reason: String) -> void: + _suite_skipped = true + _suite_skipped_reason = reason + + +## Mark the current test as skipped when the running Godot is older than +## `min_version` (a "major.minor" string like "4.4"). Use for tests that +## exercise an engine API or behavior that only exists on newer Godot. +## Returns true when the test was skipped, so callers can `return` from +## the test body. +## +## Example: +## func test_uses_44_only_api() -> void: +## if skip_on_godot_lt("4.4", "Engine.capture_script_backtraces is 4.4+"): +## return +## ... +func skip_on_godot_lt(min_version: String, reason: String = "") -> bool: + var v := Engine.get_version_info() + var current_major := int(v.get("major", 0)) + var current_minor := int(v.get("minor", 0)) + var parts := min_version.split(".") + var want_major := int(parts[0]) if parts.size() > 0 else 0 + var want_minor := int(parts[1]) if parts.size() > 1 else 0 + if ( + current_major < want_major + or (current_major == want_major and current_minor < want_minor) + ): + var msg := reason if not reason.is_empty() else "requires Godot %s+" % min_version + skip(msg + " (running %d.%d)" % [current_major, current_minor]) + return true + return false + + +## 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..262155f --- /dev/null +++ b/addons/godot_ai/tool_catalog.gd @@ -0,0 +1,86 @@ +@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", +] + +## Ordered list of user-toggleable domains. Each entry: +## id: matches the name passed to `--exclude-domains` +## label: human-friendly display (same as id for now, kept separate so +## a future renaming doesn't break the setting) +## count: number of NON-CORE tools in this domain +## tools: flat list of tool names registered by this domain (non-core only) +const DOMAINS := [ + {"id": "animation", "label": "animation", "count": 2, "tools": ["animation_create", "animation_manage"]}, + {"id": "api", "label": "api", "count": 1, "tools": ["api_manage"]}, + {"id": "audio", "label": "audio", "count": 1, "tools": ["audio_manage"]}, + {"id": "autoload", "label": "autoload", "count": 1, "tools": ["autoload_manage"]}, + {"id": "batch", "label": "batch", "count": 1, "tools": ["batch_execute"]}, + {"id": "camera", "label": "camera", "count": 1, "tools": ["camera_manage"]}, + {"id": "client", "label": "client", "count": 1, "tools": ["client_manage"]}, + {"id": "editor", "label": "editor", "count": 4, "tools": ["editor_manage", "editor_reload_plugin", "editor_screenshot", "logs_read"]}, + {"id": "filesystem", "label": "filesystem", "count": 1, "tools": ["filesystem_manage"]}, + {"id": "game", "label": "game", "count": 1, "tools": ["game_manage"]}, + {"id": "input_map", "label": "input_map", "count": 1, "tools": ["input_map_manage"]}, + {"id": "material", "label": "material", "count": 1, "tools": ["material_manage"]}, + {"id": "node", "label": "node", "count": 4, "tools": ["node_create", "node_find", "node_manage", "node_set_property"]}, + {"id": "particle", "label": "particle", "count": 1, "tools": ["particle_manage"]}, + {"id": "project", "label": "project", "count": 2, "tools": ["project_manage", "project_run"]}, + {"id": "resource", "label": "resource", "count": 1, "tools": ["resource_manage"]}, + {"id": "scene", "label": "scene", "count": 3, "tools": ["scene_manage", "scene_open", "scene_save"]}, + {"id": "script", "label": "script", "count": 4, "tools": ["script_attach", "script_create", "script_manage", "script_patch"]}, + {"id": "signal", "label": "signal", "count": 1, "tools": ["signal_manage"]}, + {"id": "testing", "label": "testing", "count": 2, "tools": ["test_manage", "test_run"]}, + {"id": "theme", "label": "theme", "count": 1, "tools": ["theme_manage"]}, + {"id": "ui", "label": "ui", "count": 1, "tools": ["ui_manage"]}, +] + + +## 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() + 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() + for d in DOMAINS: + if excluded.find(d["id"]) == -1: + n += int(d["count"]) + return n + + +## Canonical comma-separated string for a set of domain ids — sorted and +## deduplicated so two equivalent settings (entered in different orders) +## hash to the same EditorSetting value. Matches `excluded_domains()` in +## client_configurator.gd. +static func canonical(excluded: PackedStringArray) -> String: + var seen := PackedStringArray() + for e in excluded: + var t := e.strip_edges() + if not t.is_empty() and seen.find(t) == -1: + seen.append(t) + seen.sort() + return ",".join(seen) diff --git a/addons/godot_ai/tool_catalog.gd.uid b/addons/godot_ai/tool_catalog.gd.uid new file mode 100644 index 0000000..c0c179b --- /dev/null +++ b/addons/godot_ai/tool_catalog.gd.uid @@ -0,0 +1 @@ +uid://d1vqyt4uyo378 diff --git a/addons/godot_ai/update_reload_runner.gd b/addons/godot_ai/update_reload_runner.gd new file mode 100644 index 0000000..7347b17 --- /dev/null +++ b/addons/godot_ai/update_reload_runner.gd @@ -0,0 +1,532 @@ +@tool +extends Node + +## EditorSetting key used to defer a self_update telemetry event across the +## disable -> enable boundary. The runner runs while the plugin is disabled, +## so it can't send WebSocket events directly; it writes the outcome here +## and the re-enabled plugin's `_enter_tree` flushes it. See +## `plugin.gd::_flush_pending_self_update_telemetry`. +const PENDING_SELF_UPDATE_TELEMETRY_KEY := "godot_ai/pending_self_update_event" + +## Self-update runner. Owns the install-and-reload sequence from +## `start(zip_path, temp_dir, detached_dock)` onward: extract files into +## `addons/godot_ai/` with rollback bookkeeping, scan the filesystem, +## re-enable the plugin, and clean up the detached dock. +## +## Single-phase install: writes the full `_new_file_paths + +## _existing_file_paths` set before issuing exactly one +## `EditorFileSystem.scan()`. Godot's scan-time reparse pass then sees one +## consistent v(N+1) snapshot, so new files and existing files can resolve +## each other's same-release API changes regardless of parse order. +## +## Not owned here: HTTP download (in `utils/update_manager.gd`), banner UI +## (in `mcp_dock.gd`), or server stop prep (called by +## `plugin.gd::install_downloaded_update` before this runner starts via +## `_lifecycle.prepare_for_update_reload()`). +## +## This node is deliberately tiny and not parented under the EditorPlugin: +## it survives `set_plugin_enabled(false)`, extracts the downloaded release, +## waits for Godot's filesystem scan, then enables the plugin again. The old +## dock is detached before this runner starts, kept alive while deferred +## Callables drain, and freed only after the new plugin instance is loaded. + +const PLUGIN_CFG_PATH := "res://addons/godot_ai/plugin.cfg" +const PRE_DISABLE_DRAIN_FRAMES := 8 +const POST_DISABLE_DRAIN_FRAMES := 2 +const POST_ENABLE_FREE_FRAMES := 8 +const INSTALL_BASE_PATH := "res://" +const ZIP_ADDON_PREFIX := "addons/godot_ai/" +const TEMP_FILE_SUFFIX := ".godot_ai_update_tmp" +const INSTALL_BACKUP_SUFFIX := ".update_backup" + +## Outcome of `_install_zip_paths`. `OK` means all listed files were replaced. +## `FAILED_CLEAN` means a write/rename failed mid-batch but every previously +## written file was rolled back to its vN content (or removed, if the file +## was new in vN+1). `FAILED_MIXED` means rollback itself failed: the addons +## tree contains a mix of vN and vN+1 files. The runner MUST NOT re-enable +## the plugin in the MIXED case — see issue #297 finding #9 for the data-loss +## scenario this guards against. +enum InstallStatus { OK, FAILED_CLEAN, FAILED_MIXED } + +var _zip_path := "" +var _temp_dir := "" +var _detached_dock = null +var _started := false +var _next_step := "" +var _frames_remaining := 0 +var _waiting_for_scan := false +var _scan_next_step := "" +## Watchdog for `_start_filesystem_scan`: if Godot's `filesystem_changed` +## signal never fires (slow disk, NFS, AV holding the just-extracted addon +## files open), the runner used to hang in `_waiting_for_scan = true` +## forever and the dock stayed disabled. After this timeout we disconnect +## the signal and proceed anyway — worst case the new files aren't visible +## on the first frame, but they get picked up on the next scan. See +## audit-v2 finding #9 (issue #353). Untyped to match the codebase's +## defensive pattern for state that survives `fs.scan()` during update. +const SCAN_WATCHDOG_SECS := 30.0 +var _scan_watchdog_timer = null +## Sticky flag set by `_on_scan_watchdog_timeout`. Subsequent +## `_start_filesystem_scan` calls in the same update bypass connect+scan +## so a delayed `filesystem_changed` emission from the timed-out scan +## can't fire on a freshly-armed listener for the next scan and falsely +## settle it before that scan actually completed. See PR #381 review for +## the cross-scan race this guards against. +var _scan_timed_out := false +## Keep Array fields untyped: this runner survives fs.scan() during update, +## and typed Variant storage is part of the hot-reload crash class. +var _new_file_paths = [] +var _existing_file_paths = [] +## Per-file install records accumulated during install so a later failure +## can roll back files already replaced earlier in the same update. +## Each entry is an untyped Dictionary with target_path / backup_path / +## had_original keys. Cleared by `_finalize_install_success` on full success +## and by `_rollback_paths_written` on failure. +var _paths_written = [] +## Set true if `_install_zip_file`'s inner restore-from-backup couldn't +## complete (backup gone, copy failed). The failed file is NOT recorded in +## `_paths_written` because the function bails at that point — without this +## flag, `_rollback_paths_written` would walk only the prior records, all +## restore cleanly, and report FAILED_CLEAN even though the current target +## is missing or stale on disk. Surfaces FAILED_MIXED so the runner refuses +## to re-enable the plugin against a half-installed tree. +var _restore_failed := false +## Test-only opt-out for the scan-watchdog `push_warning` lines. The +## watchdog unit tests in `test_update_reload_runner.gd` invoke +## `_on_scan_watchdog_timeout()` and the post-timeout +## `_start_filesystem_scan` bypass branch directly to pin their behavior +## — but those code paths' `push_warning` calls then appear as yellow +## console noise in every `test_run`, training reviewers to ignore the +## runner's real production warnings. Tests set this true; production +## leaves it false so genuine scan timeouts during a real self-update +## still surface loudly. See issue #413. +var _suppress_scan_warnings := false + + +func start(zip_path: String, temp_dir: String, detached_dock) -> void: + if _started: + return + _started = true + _zip_path = zip_path + _temp_dir = temp_dir + _detached_dock = detached_dock + _wait_frames(PRE_DISABLE_DRAIN_FRAMES, "_disable_old_plugin") + + +func _process(_delta: float) -> void: + if _frames_remaining <= 0: + set_process(false) + return + + _frames_remaining -= 1 + if _frames_remaining <= 0: + var step := _next_step + _next_step = "" + set_process(false) + call(step) + + +func _wait_frames(frame_count: int, next_step: String) -> void: + _next_step = next_step + _frames_remaining = max(1, frame_count) + set_process(true) + + +func _disable_old_plugin() -> void: + ## Disable before writing or scanning new scripts. This avoids both the + ## Dict/Array field-storage hot-reload crash (#245) and cached handler + ## constructor shape mismatches (#247) for plugin-owned instances. + print("MCP | update runner disabling old plugin") + EditorInterface.set_plugin_enabled(PLUGIN_CFG_PATH, false) + _wait_frames(POST_DISABLE_DRAIN_FRAMES, "_extract_and_scan") + + +func _extract_and_scan() -> void: + if not _read_update_manifest(): + EditorInterface.set_plugin_enabled(PLUGIN_CFG_PATH, true) + _wait_frames(POST_ENABLE_FREE_FRAMES, "_cleanup_and_finish") + return + + var install_paths := [] + install_paths.append_array(_new_file_paths) + install_paths.append_array(_existing_file_paths) + + var status := _install_zip_paths(install_paths) + if status != InstallStatus.OK: + _handle_install_failure(status) + return + + _finalize_install_success() + _cleanup_update_temp() + ## One scan covers both dependency directions: plugin.gd's preloads of + ## new files resolve because those files are already present, and new + ## files' references to new members or static-ness changes on existing + ## load-surface scripts resolve because those existing files are also + ## already at v(N+1). The goal is a consistent snapshot before scan, not + ## a tree-atomic install; per-file writes still use `.tmp` + rename and + ## rollback on failure. + _start_filesystem_scan("_enable_new_plugin") + + +func _start_filesystem_scan(next_step: String = "_enable_new_plugin") -> void: + var fs := EditorInterface.get_resource_filesystem() + var deferred_step := next_step if not next_step.is_empty() else "_enable_new_plugin" + if fs == null: + call_deferred(deferred_step) + return + + ## Bypass: a previous scan in this update already watchdog'd, so the + ## editor's filesystem is unresponsive. Re-arming a `filesystem_changed` + ## listener now would race with a delayed emission from the timed-out + ## scan: that single emission would fire whichever listener is currently + ## connected to the shared signal, falsely settling this scan before it + ## actually completed. Skip the wait; Godot's normal background scan + ## catches up after the plugin re-enables. See PR #381 review. + if _scan_timed_out: + if not _suppress_scan_warnings: + push_warning( + "MCP | skipping filesystem_changed wait after previous timeout (next_step=%s)" + % deferred_step + ) + call_deferred(deferred_step) + return + + _waiting_for_scan = true + _scan_next_step = deferred_step + if not fs.filesystem_changed.is_connected(_on_filesystem_changed): + fs.filesystem_changed.connect(_on_filesystem_changed, CONNECT_ONE_SHOT) + _arm_scan_watchdog() + fs.scan() + + +func _arm_scan_watchdog() -> void: + if _scan_watchdog_timer == null: + _scan_watchdog_timer = Timer.new() + _scan_watchdog_timer.one_shot = true + _scan_watchdog_timer.timeout.connect(_on_scan_watchdog_timeout) + add_child(_scan_watchdog_timer) + _scan_watchdog_timer.start(SCAN_WATCHDOG_SECS) + + +func _stop_scan_watchdog() -> void: + if _scan_watchdog_timer != null: + _scan_watchdog_timer.stop() + + +func _on_scan_watchdog_timeout() -> void: + ## Signal didn't fire within SCAN_WATCHDOG_SECS — most likely the + ## filesystem scan is blocked behind a slow disk / NFS / AV scanner + ## still reading the just-extracted addon files. + ## Set the sticky `_scan_timed_out` flag so any subsequent + ## `_start_filesystem_scan` in this update skips its connect+scan + ## (otherwise a delayed emission from this scan would falsely settle + ## the next scan's listener — see PR #381 review). + ## Disconnect the current listener too, so this scan's listener can't + ## double-call `_finish_scan_wait` if the signal arrives quickly after + ## the timeout fires. `_finish_scan_wait` is idempotent on + ## `_waiting_for_scan == false`. + if not _waiting_for_scan: + return + if not _suppress_scan_warnings: + push_warning( + "MCP | filesystem_changed didn't fire within %ds; proceeding without scan confirmation" + % int(SCAN_WATCHDOG_SECS) + ) + _scan_timed_out = true + var fs := EditorInterface.get_resource_filesystem() + if fs != null and fs.filesystem_changed.is_connected(_on_filesystem_changed): + fs.filesystem_changed.disconnect(_on_filesystem_changed) + _finish_scan_wait() + + +func _read_update_manifest() -> bool: + var zip_path := ProjectSettings.globalize_path(_zip_path) + var install_base := ProjectSettings.globalize_path(INSTALL_BASE_PATH) + + var reader := ZIPReader.new() + if reader.open(zip_path) != OK: + print("MCP | update extract failed: could not open %s" % zip_path) + return false + + _new_file_paths.clear() + _existing_file_paths.clear() + var has_plugin_cfg := false + var has_plugin_script := false + var files := reader.get_files() + for file_path in files: + if not file_path.begins_with(ZIP_ADDON_PREFIX): + continue + var rel_path := file_path.trim_prefix(ZIP_ADDON_PREFIX) + ## Many zip builders (`zip -r` without `-D`, AssetLib uploads, hand- + ## built archives) emit zero-byte directory entries like + ## `addons/godot_ai/`. Skip those before the safety check; the + ## empty-segment guard in `_is_safe_zip_addon_file` would otherwise + ## flag the bare prefix as unsafe and abort the extract. Current + ## release.yml passes `-D` to strip them, but installed runners must + ## still tolerate older or manually built zips. + if rel_path.is_empty() or file_path.ends_with("/"): + continue + if not _is_safe_zip_addon_file(file_path): + print("MCP | update extract failed: unsafe zip path %s" % file_path) + reader.close() + return false + if rel_path == "plugin.cfg": + has_plugin_cfg = true + elif rel_path == "plugin.gd": + has_plugin_script = true + var target_path := install_base.path_join(file_path) + if FileAccess.file_exists(target_path): + _existing_file_paths.append(file_path) + else: + _new_file_paths.append(file_path) + reader.close() + if not has_plugin_cfg: + print("MCP | update extract failed: zip is missing plugin.cfg") + return false + if not has_plugin_script: + print("MCP | update extract failed: zip is missing plugin.gd") + return false + return true + + +func _handle_install_failure(status: int) -> void: + _record_pending_self_update({ + "status": "failed_mixed" if status == InstallStatus.FAILED_MIXED else "failed_clean", + }) + if status == InstallStatus.FAILED_MIXED: + ## Half-installed addon tree on disk: re-enabling the plugin would + ## load a mix of vN and vN+1 files. Print a load-bearing diagnostic + ## and bail without re-enabling — user must restore manually. See + ## issue #297 finding #9 for the data-loss scenario. + push_error( + "MCP | self-update failed mid-install AND rollback could not" + + " restore the previous addons/godot_ai/ contents. The plugin" + + " is left disabled. Inspect addons/godot_ai/ for" + + " *.update_backup / *.godot_ai_update_tmp files and restore" + + " manually before re-enabling the plugin." + ) + print( + "MCP | self-update aborted: addons/godot_ai/ is in a mixed state;" + + " plugin left disabled (manual intervention required)." + ) + _wait_frames(POST_ENABLE_FREE_FRAMES, "_cleanup_and_finish") + return + ## FAILED_CLEAN: rollback restored every previously-written file. Safe + ## to re-enable the previous plugin version. + print("MCP | self-update rolled back; re-enabling previous plugin version") + EditorInterface.set_plugin_enabled(PLUGIN_CFG_PATH, true) + _wait_frames(POST_ENABLE_FREE_FRAMES, "_cleanup_and_finish") + + +func _is_safe_zip_addon_file(file_path: String) -> bool: + if file_path.is_absolute_path() or file_path.contains("\\"): + return false + if not file_path.begins_with(ZIP_ADDON_PREFIX): + return false + var rel_path := file_path.trim_prefix(ZIP_ADDON_PREFIX) + if rel_path.is_empty() or rel_path.ends_with("/"): + return false + for segment in rel_path.split("/", true): + if segment.is_empty() or segment == "." or segment == "..": + return false + return true + + +func _install_zip_paths(paths: Array) -> int: + if paths.is_empty(): + return InstallStatus.OK + + var zip_path := ProjectSettings.globalize_path(_zip_path) + var reader := ZIPReader.new() + if reader.open(zip_path) != OK: + print("MCP | update extract failed: could not reopen %s" % zip_path) + ## Nothing else can be written, but earlier files from this update + ## may have landed on disk; roll those back too. + return _rollback_paths_written() + + var install_base := ProjectSettings.globalize_path(INSTALL_BASE_PATH) + for file_path in paths: + var record := _install_zip_file(reader, String(file_path), install_base) + if record.is_empty(): + reader.close() + return _rollback_paths_written() + _paths_written.append(record) + reader.close() + return InstallStatus.OK + + +func _install_zip_file( + reader: ZIPReader, file_path: String, install_base: String +) -> Dictionary: + var target_path := install_base.path_join(file_path) + var dir := target_path.get_base_dir() + if DirAccess.make_dir_recursive_absolute(dir) != OK: + print("MCP | update extract failed: could not create %s" % dir) + return {} + + var temp_path := target_path + TEMP_FILE_SUFFIX + DirAccess.remove_absolute(temp_path) + var content := reader.read_file(file_path) + var f := FileAccess.open(temp_path, FileAccess.WRITE) + if f == null: + print("MCP | update extract failed: could not write %s (error %d)" % [ + temp_path, + FileAccess.get_open_error(), + ]) + return {} + f.store_buffer(content) + var write_error := f.get_error() + f.close() + if write_error != OK: + print("MCP | update extract failed: write error %d for %s" % [ + write_error, + temp_path, + ]) + DirAccess.remove_absolute(temp_path) + return {} + + ## Back up the original via COPY (not rename) so the source of truth + ## stays in place if a later step fails. Rolled back via + ## `_rollback_paths_written` if a subsequent file in this batch — or a + ## later batch — can't be installed. + var had_original := FileAccess.file_exists(target_path) + var backup_path := target_path + INSTALL_BACKUP_SUFFIX + if had_original: + DirAccess.remove_absolute(backup_path) + if DirAccess.copy_absolute(target_path, backup_path) != OK: + DirAccess.remove_absolute(temp_path) + print("MCP | update extract failed: could not back up %s" % target_path) + return {} + + if DirAccess.rename_absolute(temp_path, target_path) != OK: + ## POSIX and APFS replace atomically. Some filesystems reject + ## rename-over-existing; keep a fallback so the update can still + ## proceed, but the common path never exposes a truncated target. + DirAccess.remove_absolute(target_path) + if DirAccess.rename_absolute(temp_path, target_path) != OK: + DirAccess.remove_absolute(temp_path) + ## Target was removed above; restore from the COPY backup so the + ## addons dir is left in its vN state before we surface failure. + ## Only delete the backup if the restore copy actually succeeded + ## — if it didn't, target_path is missing, and `_restore_failed` + ## tells `_rollback_paths_written` to surface FAILED_MIXED so the + ## runner refuses to re-enable the plugin. Leaving the backup on + ## disk also gives the user a manual recovery path. Without this + ## guard the failed file isn't tracked anywhere (we return `{}`, + ## not appended to `_paths_written`) and the caller would + ## erroneously see FAILED_CLEAN. + if had_original: + if ( + FileAccess.file_exists(backup_path) + and DirAccess.copy_absolute(backup_path, target_path) == OK + ): + DirAccess.remove_absolute(backup_path) + else: + _restore_failed = true + print("MCP | update extract failed: could not replace %s" % target_path) + return {} + return { + "target_path": target_path, + "backup_path": backup_path, + "had_original": had_original, + } + + +## Restore (or remove) every file already touched in this update. Safe to +## call after a partial install — entries are processed in reverse so a +## given target is restored before the next earlier write of the same path +## could resurrect a stale value. Returns FAILED_CLEAN if every entry was +## restored AND no in-flight `_install_zip_file` left a target stranded +## (`_restore_failed`); FAILED_MIXED otherwise. The caller MUST NOT +## re-enable the plugin in the MIXED case. +func _rollback_paths_written() -> int: + var any_failed := false + var i := _paths_written.size() - 1 + while i >= 0: + var record = _paths_written[i] + var target := String(record.get("target_path", "")) + var backup := String(record.get("backup_path", "")) + var had_original := bool(record.get("had_original", false)) + if had_original: + if not FileAccess.file_exists(backup): + print("MCP | update rollback failed: backup missing for %s" % target) + any_failed = true + else: + DirAccess.remove_absolute(target) + if DirAccess.copy_absolute(backup, target) != OK: + print("MCP | update rollback failed: could not restore %s" % target) + any_failed = true + else: + DirAccess.remove_absolute(backup) + else: + if FileAccess.file_exists(target): + if DirAccess.remove_absolute(target) != OK: + print( + "MCP | update rollback failed: could not delete %s" % target + ) + any_failed = true + i -= 1 + _paths_written.clear() + if any_failed or _restore_failed: + return InstallStatus.FAILED_MIXED + return InstallStatus.FAILED_CLEAN + + +## Discard accumulated backups after the combined install succeeds. Backups +## are best-effort: a failure here doesn't compromise the new install, just +## leaves stray *.update_backup files for the user to clean up. +func _finalize_install_success() -> void: + for record in _paths_written: + if record.get("had_original", false): + DirAccess.remove_absolute(String(record.get("backup_path", ""))) + _paths_written.clear() + _record_pending_self_update({"status": "success"}) + + +## Persist a self_update event description so the re-enabled plugin can +## emit it once its WebSocket is connected. Survives the disable -> enable +## window where the runner cannot send anything itself. +func _record_pending_self_update(data: Dictionary) -> void: + var settings := EditorInterface.get_editor_settings() + if settings == null: + return + settings.set_setting(PENDING_SELF_UPDATE_TELEMETRY_KEY, JSON.stringify(data)) + + +func _cleanup_update_temp() -> void: + DirAccess.remove_absolute(ProjectSettings.globalize_path(_zip_path)) + DirAccess.remove_absolute(ProjectSettings.globalize_path(_temp_dir)) + + +func _on_filesystem_changed() -> void: + _finish_scan_wait() + + +func _finish_scan_wait() -> void: + if not _waiting_for_scan: + return + _waiting_for_scan = false + _stop_scan_watchdog() + var next_step := _scan_next_step + _scan_next_step = "" + set_process(false) + if next_step.is_empty(): + next_step = "_enable_new_plugin" + call_deferred(next_step) + + +func _enable_new_plugin() -> void: + print("MCP | update runner enabling new plugin") + EditorInterface.set_plugin_enabled(PLUGIN_CFG_PATH, true) + _wait_frames(POST_ENABLE_FREE_FRAMES, "_cleanup_and_finish") + + +func _cleanup_and_finish() -> void: + _cleanup_detached_dock() + queue_free() + + +func _cleanup_detached_dock() -> void: + if _detached_dock != null and is_instance_valid(_detached_dock): + _detached_dock.queue_free() + _detached_dock = null diff --git a/addons/godot_ai/update_reload_runner.gd.uid b/addons/godot_ai/update_reload_runner.gd.uid new file mode 100644 index 0000000..9cc6615 --- /dev/null +++ b/addons/godot_ai/update_reload_runner.gd.uid @@ -0,0 +1 @@ +uid://cu6c75n3x2pik diff --git a/addons/godot_ai/utils/class_introspection.gd b/addons/godot_ai/utils/class_introspection.gd new file mode 100644 index 0000000..c43d61c --- /dev/null +++ b/addons/godot_ai/utils/class_introspection.gd @@ -0,0 +1,239 @@ +@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") + +const DEFAULT_SECTIONS := ["properties", "methods", "signals", "enums", "constants"] +const KNOWN_SECTIONS := ["properties", "methods", "signals", "enums", "constants", "inheritors"] +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 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/editor_log_buffer.gd b/addons/godot_ai/utils/editor_log_buffer.gd new file mode 100644 index 0000000..54007e4 --- /dev/null +++ b/addons/godot_ai/utils/editor_log_buffer.gd @@ -0,0 +1,104 @@ +@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() + + +func _init() -> void: + super._init(MAX_LINES) + + +func append(level: String, text: String, path: String = "", line: int = 0, function: String = "", details: Dictionary = {}) -> void: + var entry := { + "source": "editor", + "level": _coerce_level(level), + "text": text, + "path": path, + "line": line, + "function": function, + } + if not details.is_empty(): + entry["details"] = details.duplicate(true) + _mutex.lock() + _append_entry(entry) + _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 clear() -> int: + _mutex.lock() + var n := _total_count_unlocked() + _clear_storage() + _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..43ccaa7 --- /dev/null +++ b/addons/godot_ai/utils/error_codes.gd @@ -0,0 +1,84 @@ +@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" +# game_eval failure codes (#490) — keep in sync with protocol/errors.py +const EVAL_COMPILE_ERROR := "EVAL_COMPILE_ERROR" +const EVAL_RUNTIME_ERROR := "EVAL_RUNTIME_ERROR" +## #518: the play session is up (EditorInterface.is_playing_scene() is true, so +## editor_handler's EDITOR_NOT_READY "game is not running" gate already passed) +## but the game-side _mcp_game_helper autoload never registered its debugger +## capture within EVAL_READY_WAIT_SEC. Carved out of INTERNAL_ERROR so this +## boot-window / missing-autoload race stops masquerading as the opaque "eval +## hung" 10s timeout in telemetry — the same split #490 made for compile/runtime +## errors. NOT a hang: it fires fast (~3s) and is caller-actionable (let the game +## finish booting and retry, or check the autoload is enabled). +const EVAL_GAME_NOT_READY := "EVAL_GAME_NOT_READY" +## 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" + + +## Build a standard error response dictionary. +static func make(code: String, message: String) -> Dictionary: + return {"status": "error", "error": {"code": code, "message": message}} + + +## 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..268aa17 --- /dev/null +++ b/addons/godot_ai/utils/game_log_buffer.gd @@ -0,0 +1,50 @@ +@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 each time clear_for_new_run() fires (called on the game's +## mcp:hello boot beacon), giving agents a stable cursor for "lines since +## this play started". +## +## 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 := "" + + +func _init() -> void: + super._init(MAX_LINES) + + +func append(level: String, text: String, details: Dictionary = {}) -> void: + var entry := {"source": "game", "level": _coerce_level(level), "text": text} + if not details.is_empty(): + entry["details"] = details.duplicate(true) + _append_entry(entry) + + +## Rotate the run identifier and drop all buffered entries. Called when the +## game-side autoload sends its mcp:hello beacon, marking a fresh play cycle. +## Returns the new run_id. +func clear_for_new_run() -> String: + _clear_storage() + _run_id = _generate_run_id() + return _run_id + + +func run_id() -> String: + return _run_id + + +static func _generate_run_id() -> String: + ## Opaque to agents — they only check equality. Time-based is plenty + ## unique within a single editor session and avoids the RNG-seed + ## reproducibility footgun. + return "r%d" % Time.get_ticks_msec() 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/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..7da9547 --- /dev/null +++ b/addons/godot_ai/utils/log_buffer.gd @@ -0,0 +1,63 @@ +@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 + + +func log(msg: String) -> void: + var line := "MCP | %s" % msg + if enabled and console_echo: + print(line) + _lines.append(line) + if _lines.size() > MAX_LINES: + _lines = _lines.slice(-MAX_LINES) + _total_logged += 1 + + +func get_recent(count: int = 50) -> Array[String]: + var start := maxi(0, _lines.size() - count) + var result: Array[String] = [] + result.assign(_lines.slice(start)) + return result + + +func clear() -> void: + _lines.clear() + ## Reset the monotonic counter so a viewer's `seq < _last_seq` shrink + ## detection still recognizes the clear. Callers that want a cumulative + ## ever-produced count across clears can wrap their own counter. + _total_logged = 0 + + +func total_count() -> int: + return _lines.size() + + +## Monotonic sequence — number of lines ever appended via `log()` since +## the last `clear()`. Strictly increases per append, even once the ring +## has filled and `total_count()` is pinned at MAX_LINES. See `_total_logged` +## for rationale. +func total_logged() -> int: + return _total_logged diff --git a/addons/godot_ai/utils/log_buffer.gd.uid b/addons/godot_ai/utils/log_buffer.gd.uid new file mode 100644 index 0000000..58341ca --- /dev/null +++ b/addons/godot_ai/utils/log_buffer.gd.uid @@ -0,0 +1 @@ +uid://ddkslse7511e6 diff --git a/addons/godot_ai/utils/mcp_adoption_label.gd b/addons/godot_ai/utils/mcp_adoption_label.gd new file mode 100644 index 0000000..3114037 --- /dev/null +++ b/addons/godot_ai/utils/mcp_adoption_label.gd @@ -0,0 +1,23 @@ +@tool +class_name McpAdoptionLabel +extends RefCounted + +## Outcome flag for `McpServerLifecycleManager.adopt_compatible_server`. +## Distinguishes a same-version managed adoption (we own the PID, can +## restart it) from an external compatible adoption (some other plugin +## instance / dev server owns the process; we just rendezvoused with it). +## +## Was a free-form string in PR 5; promoted to constants here because +## the seam now spans `server_lifecycle.gd`, `plugin.gd`'s log helper, +## the dock's restart-button gating, and the test suite. Stable strings +## keep log scrapes and characterization fixtures unaffected. + +## We have a PID we spawned (or re-acquired by reading the managed +## record + verifying liveness). `force_restart_server` and +## `prepare_for_update_reload` may target this PID. +const MANAGED := "managed" + +## A compatible godot-ai server is on the port but we don't own its +## PID — likely another plugin instance's spawn, or a developer-run +## `godot-ai --reload` server. We reuse it but won't kill it on stop. +const EXTERNAL := "external" diff --git a/addons/godot_ai/utils/mcp_adoption_label.gd.uid b/addons/godot_ai/utils/mcp_adoption_label.gd.uid new file mode 100644 index 0000000..aee58fe --- /dev/null +++ b/addons/godot_ai/utils/mcp_adoption_label.gd.uid @@ -0,0 +1 @@ +uid://klhsu1cuhcue diff --git a/addons/godot_ai/utils/mcp_client_refresh_state.gd b/addons/godot_ai/utils/mcp_client_refresh_state.gd new file mode 100644 index 0000000..28d2c3b --- /dev/null +++ b/addons/godot_ai/utils/mcp_client_refresh_state.gd @@ -0,0 +1,98 @@ +@tool +class_name McpClientRefreshState +extends RefCounted + +## State machine for the dock's client-status refresh sweep. Single +## source of truth — supersedes the seven booleans + deadline previously +## scattered across `mcp_dock.gd` (`_client_status_refresh_in_flight`, +## `_client_status_refresh_pending`, `_client_status_refresh_pending_force`, +## `_client_status_refresh_timed_out`, `_client_status_refresh_started_msec`, +## `_client_status_refresh_deferred_until_filesystem_ready`, +## `_client_status_refresh_deferred_force`, +## `_client_status_refresh_deferred_initial`, +## `_client_status_refresh_shutdown_requested`). +## +## The ints are stable for tests; reordering is a breaking change. + +## No worker running, no pending request. Default state. +const IDLE := 0 +## A refresh request landed but the editor filesystem is busy +## (`EditorInterface.get_resource_filesystem().is_scanning()` is true); +## the dock parks the request and retries on the next `_process` after +## the scan settles. Held alongside two flags (force / initial) for +## what kind of refresh to retry; those live next to the state, not +## inside it, because they're requests not state. +const DEFERRED_FOR_FILESYSTEM := 1 +## Worker thread is alive and probing client status off-main. The +## dock paints "(checking...)" in the clients summary and accepts +## additional requests as `pending`. +const RUNNING := 2 +## Worker has been alive past CLIENT_STATUS_REFRESH_TIMEOUT_MSEC. The +## dock paints "(client probe still running)" and a forced refresh is +## allowed to abandon the worker into the orphan list and start a new +## sweep. The state stays RUNNING after a forced abandon-and-restart. +const RUNNING_TIMED_OUT := 3 +## `_exit_tree` / `_install_update` is draining workers. New refresh +## requests are rejected outright. Set once and not cleared (the dock +## instance is being torn down). +const SHUTTING_DOWN := 4 + +const _NAMES := { + IDLE: "idle", + DEFERRED_FOR_FILESYSTEM: "deferred_for_filesystem", + RUNNING: "running", + RUNNING_TIMED_OUT: "running_timed_out", + SHUTTING_DOWN: "shutting_down", +} + + +static func name_of(state: int) -> String: + return _NAMES.get(state, "unknown(%d)" % state) + + +## True when a worker thread should be alive in this state. Combined +## state — RUNNING or RUNNING_TIMED_OUT both have a worker running, but +## the timed-out flavor allows a force-refresh to abandon it. +static func has_worker_alive(state: int) -> bool: + return state == RUNNING or state == RUNNING_TIMED_OUT + + +## True when the dock should reject new refresh spawns. Used by the +## focus-in / manual button / cooldown-timer entrypoints. +static func is_blocked_for_spawn(state: int) -> bool: + return state == SHUTTING_DOWN + + +## True when the summary label should show the in-flight badge. +static func should_show_checking_badge(state: int) -> bool: + return state == RUNNING or state == RUNNING_TIMED_OUT + + +## Transition table. Same shape as McpServerState — illegal transitions +## return false; callers `push_warning` and no-op. +static func can_transition(from: int, to: int) -> bool: + if from == to: + return true + ## Shutdown is sticky. + if from == SHUTTING_DOWN: + return false + ## Anything → SHUTTING_DOWN is legal (drain on _exit_tree / install). + if to == SHUTTING_DOWN: + return true + match from: + IDLE: + return to == RUNNING or to == DEFERRED_FOR_FILESYSTEM + DEFERRED_FOR_FILESYSTEM: + ## When the filesystem scan settles we either spawn a worker + ## (RUNNING) or roll back to IDLE if no rows need probing. + return to == RUNNING or to == IDLE + RUNNING: + ## Worker finishes -> IDLE. Worker outlives budget -> + ## RUNNING_TIMED_OUT. Forced respawn after orphan abandon + ## stays in RUNNING (covered by from == to above). + return to == IDLE or to == RUNNING_TIMED_OUT + RUNNING_TIMED_OUT: + ## Late-arriving worker result drops back to IDLE; forced + ## abandon-and-respawn drops back to RUNNING. + return to == IDLE or to == RUNNING + return false diff --git a/addons/godot_ai/utils/mcp_client_refresh_state.gd.uid b/addons/godot_ai/utils/mcp_client_refresh_state.gd.uid new file mode 100644 index 0000000..9e4129c --- /dev/null +++ b/addons/godot_ai/utils/mcp_client_refresh_state.gd.uid @@ -0,0 +1 @@ +uid://dv4tukg6eioww diff --git a/addons/godot_ai/utils/mcp_server_state.gd b/addons/godot_ai/utils/mcp_server_state.gd new file mode 100644 index 0000000..efbba8f --- /dev/null +++ b/addons/godot_ai/utils/mcp_server_state.gd @@ -0,0 +1,189 @@ +@tool +class_name McpServerState +extends RefCounted + +## State machine for the plugin's server-spawn / adopt / version-verify +## lifecycle. Single source of truth — supersedes the boolean-flag thicket +## (`_server_started_this_session`, `_awaiting_server_version`, +## `_server_version_deadline_ms`, `_connection_blocked`, +## `_can_recover_incompatible`, `_refresh_retried`, +## `_adoption_watch_deadline_ms`) and the older terminal-only +## McpSpawnState string union. +## +## The integer values matter — they're what `get_server_status()` +## surfaces, what the dock pattern-matches on, and what the test suites +## assert against. Reordering the enum is a breaking change. +## +## The transitions are documented in `can_transition()`. The lifecycle +## manager calls `set_state()` which: +## 1. Validates the transition (logs a warning + no-ops on illegal). +## 2. Preserves first-writer-wins among terminal diagnoses so a late +## CRASHED from the watch loop can't clobber an earlier +## PORT_EXCLUDED from the proactive Windows reservation check. + +## Fresh plugin instance, `_start_server` has not run yet. Default state. +const UNINITIALIZED := 0 +## Process spawned via OS.create_process; watch loop is observing the +## SPAWN_GRACE_MS window. Transitions directly to READY (handshake_ack +## verifies a compatible version), CRASHED (process died early), or +## INCOMPATIBLE (handshake reported a mismatch). +const SPAWNING := 1 +## (slot 2 reserved — keep wire-compat for clients pattern-matching +## numeric `editor_state.state` values; do not reuse.) +## Server is healthy and version-verified. Happy path. Includes both +## "spawned fresh" and "adopted compatible existing server" flavors — +## adoption flavor is recorded separately via `McpAdoptionLabel`. +const READY := 3 +## Live server on the HTTP port returned a version that doesn't match +## what this plugin expects, OR returned no `handshake_ack` inside the +## timeout. Connection is blocked; recovery requires a kill+respawn +## click via `recover_incompatible_server`. +const INCOMPATIBLE := 4 +## Spawned process exited inside the SPAWN_GRACE_MS window. Python +## traceback went to Godot's output log. Terminal — reload the plugin +## or restart the editor to retry. +const CRASHED := 5 +## No server command resolved: no `.venv` Python, no `uvx` on PATH, no +## system `godot-ai`. Terminal — install guidance shown in dock. +const NO_COMMAND := 6 +## Windows reserved the HTTP port via Hyper-V / WSL2 / Docker exclusion +## range. Caught proactively before bind. Terminal — port picker shown. +const PORT_EXCLUDED := 7 +## HTTP port held by a process we didn't spawn (no matching managed +## record). Plugin armed an adoption-confirmation watcher; if the foreign +## occupant turns out to be a compatible godot-ai server, +## `handle_server_version_verified` transitions to READY. If the +## adoption deadline expires without a connection, the watcher self- +## disarms but the state stays at FOREIGN_PORT — the dock keeps showing +## "port held by another process" until the user reloads. The version- +## check seam (separate from the adoption deadline) is what fires +## INCOMPATIBLE on a positive-but-mismatched handshake. +const FOREIGN_PORT := 8 +## Static re-entrancy guard fired (`_server_started_this_session` was +## already true). The plugin is being re-enabled within the same editor +## session; the previous instance still owns the spawn. Terminal — does +## NOT block READY paths, just records that this enable cycle no-op'd. +const GUARDED := 9 +## stop_server / prepare_for_update_reload in progress. Transitional — +## next state is STOPPED. +const STOPPING := 10 +## stop_server completed; `_server_pid` reset to -1, port may or may +## not be free. From here a fresh `start_server` call moves back through +## SPAWNING / READY. +const STOPPED := 11 + +const _NAMES := { + UNINITIALIZED: "uninitialized", + SPAWNING: "spawning", + READY: "ready", + INCOMPATIBLE: "incompatible", + CRASHED: "crashed", + NO_COMMAND: "no_command", + PORT_EXCLUDED: "port_excluded", + FOREIGN_PORT: "foreign_port", + GUARDED: "guarded", + STOPPING: "stopping", + STOPPED: "stopped", +} + + +## Human-readable label. Used in startup-trace logs and transition +## warnings. Falls back to `unknown()` for unrecognised values so +## a future enum addition won't crash the formatter. +static func name_of(state: int) -> String: + return _NAMES.get(state, "unknown(%d)" % state) + + +## True for any state the dock should render as a non-OK diagnostic +## panel. Used as the "should we hide the spawn-failure panel?" gate. +static func is_terminal_diagnosis(state: int) -> bool: + return ( + state == CRASHED + or state == NO_COMMAND + or state == PORT_EXCLUDED + or state == INCOMPATIBLE + or state == FOREIGN_PORT + ) + + +## True only for READY. Other "ok-ish" states (SPAWNING) are still in +## flight; READY is the only state where the plugin can treat the server +## as fully healthy. +static func is_healthy(state: int) -> bool: + return state == READY + + +## True when the dock should consider the server unsuitable for client +## health checks (incompatible tool surface). Currently just INCOMPATIBLE +## — FOREIGN_PORT is transitional and may resolve to READY if the +## foreign occupant turns out to speak our handshake. +static func blocks_client_health(state: int) -> bool: + return state == INCOMPATIBLE + + +## Transition validation table. Returns true when `from -> to` is a +## legal transition the lifecycle manager should accept. Illegal +## transitions are silently no-op'd at the call site (with a +## `push_warning` log) — this preserves the first-writer-wins contract +## that prevents a late CRASHED from the watch loop overwriting an +## earlier PORT_EXCLUDED diagnosis. +static func can_transition(from: int, to: int) -> bool: + if from == to: + return true + ## Stop is always legal — teardown / install reload short-circuits + ## any in-flight state. + if to == STOPPING: + return true + if to == STOPPED and from == STOPPING: + return true + ## STOPPED can also be reached directly when `_server_pid <= 0` and + ## stop_server early-returns; treat it as legal from any state to + ## keep the teardown path forgiving. + if to == STOPPED: + return true + ## STOPPED -> any (re-arm via restart paths). + if from == STOPPED: + return true + ## GUARDED is sticky for the rest of this enable cycle; only stop is + ## legal out of it. Already covered by the stop checks above. + if from == GUARDED: + return false + ## Terminal diagnoses freeze further forward transitions. Recovery + ## goes through STOPPING (covered above), so any other target is + ## rejected — this is the first-writer-wins contract. + if ( + from == CRASHED + or from == NO_COMMAND + or from == PORT_EXCLUDED + or from == INCOMPATIBLE + ): + return false + ## UNINITIALIZED is the boot state — any target except STOPPING is + ## reachable directly (start_server's early branches set + ## terminal states without going through SPAWNING). + if from == UNINITIALIZED: + return true + ## In-flight forward transitions. + match from: + SPAWNING: + return ( + to == READY + or to == CRASHED + or to == FOREIGN_PORT + or to == INCOMPATIBLE + ) + FOREIGN_PORT: + return to == READY or to == INCOMPATIBLE + READY: + ## Late incompatibility detection (e.g. version verifier + ## re-arms after a foreign-port reconnect that turns out + ## to be incompatible after all). + return to == INCOMPATIBLE or to == CRASHED + STOPPING: + ## Recovery rollback: kill-then-respawn paths that fail to + ## free the port re-latch INCOMPATIBLE (so the dock keeps + ## the diagnostic UI) or fall back to UNINITIALIZED (clean + ## baseline for a follow-up `_set_incompatible_server`). + ## STOPPING -> STOPPED is handled by the early checks above. + return to == INCOMPATIBLE or to == UNINITIALIZED + return false diff --git a/addons/godot_ai/utils/mcp_server_state.gd.uid b/addons/godot_ai/utils/mcp_server_state.gd.uid new file mode 100644 index 0000000..dc5bef9 --- /dev/null +++ b/addons/godot_ai/utils/mcp_server_state.gd.uid @@ -0,0 +1 @@ +uid://d3ial4erjonlq diff --git a/addons/godot_ai/utils/mcp_startup_path.gd b/addons/godot_ai/utils/mcp_startup_path.gd new file mode 100644 index 0000000..130b8e1 --- /dev/null +++ b/addons/godot_ai/utils/mcp_startup_path.gd @@ -0,0 +1,34 @@ +@tool +class_name McpStartupPath +extends RefCounted + +## Branch-tag enum for `McpServerLifecycleManager.start_server`. Records +## which arm of the spawn / adopt / drift / recover decision tree the +## current `_enter_tree` walked. Surfaced via the startup trace log so +## a Windows port-reservation issue or a stale-record kill can be +## reconstructed from the editor output. +## +## Single-file constants, not an int enum, because the values land in +## startup-trace text and the strings are stable across releases (the +## CLAUDE.md "tool surface" entry references them by name). + +const UNSET := "" +## Re-entrancy guard fired; this enable cycle did not spawn or adopt. +const GUARDED := "guarded" +## Adopted a compatible existing server (managed or external). +const ADOPTED := "adopted" +## Spawned a fresh server process. +const SPAWNED := "spawned" +## OS.create_process returned -1 or proactive Windows reservation +## detected. Either way the spawn never produced a live process. +const CRASHED := "crashed" +## Windows port-exclusion check fired — port is blocked at the OS layer. +const RESERVED := "reserved" +## Server-command discovery returned an empty list — no .venv, no uvx, +## no system godot-ai. +const NO_COMMAND := "no_command" +## Drift-recovery kill fell through; we set INCOMPATIBLE and stayed. +const INCOMPATIBLE := "incompatible" +## Port was free at start; this is the prelude to SPAWNED but kept as +## a distinct path so adopt-vs-spawn is unambiguous in the trace. +const FREE := "free" diff --git a/addons/godot_ai/utils/mcp_startup_path.gd.uid b/addons/godot_ai/utils/mcp_startup_path.gd.uid new file mode 100644 index 0000000..fd01066 --- /dev/null +++ b/addons/godot_ai/utils/mcp_startup_path.gd.uid @@ -0,0 +1 @@ +uid://cikdvq2x4vs4x diff --git a/addons/godot_ai/utils/path_validator.gd b/addons/godot_ai/utils/path_validator.gd new file mode 100644 index 0000000..afc1564 --- /dev/null +++ b/addons/godot_ai/utils/path_validator.gd @@ -0,0 +1,171 @@ +@tool +class_name McpPathValidator +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Validates `res://`-rooted paths against directory-traversal escape. +## +## Issue #347 (audit-v2 #3): handlers were accepting `res://../etc/passwd.gd` +## because the only check was `path.begins_with("res://")`. LLM-driven path +## generation (prompt injection, agent typos, untrusted issue/PR text in +## context) can produce traversal payloads for the write tools that produce +## arbitrary disk content (`script_create`, `filesystem_write_text`, +## `patch_script`) and for the matching reads (info disclosure surface). +## +## Two entry points: +## * `validate_resource_path` — for paths that name a `res://` disk file the +## plugin will read or (with `for_write`) write. This is the strict one. +## * `validate_loadable_path` — for paths handed to `ResourceLoader`, which +## also accepts `uid://` (an opaque resource-DB id that cannot express +## traversal) and `user://` (the per-project user data sandbox). Load +## handlers must use this so `uid://` references copied out of `.tscn` +## ExtResource / `.uid` sidecars and `user://` runtime assets keep loading. +## +## Error wrapping: callers should use `path_error` / `loadable_error`, which +## return a ready `ErrorCodes.make(VALUE_OUT_OF_RANGE, …)` dict (or null). A +## bad path is a value-domain error, and funneling every site through one +## wrapper keeps the error code consistent across all handlers. +## +## Known limitation: containment is lexical (`globalize_path` + `simplify_path` +## prefix match). It does NOT resolve symlinks — GDScript exposes no realpath. +## A symlink *inside* the project that points outside it can therefore defeat +## the under-root check. This matches the engine's own `res://` resolution and +## is accepted; the loopback trust boundary is the primary control. + + +# Cached project / user roots. `globalize_path` is stable across the editor's +# lifetime — caching avoids redundant resolution on every call. Matters most +# for `reimport`, which loops the validator over each path in a batch. +# Lazy-init on first call so static-load timing can't see a half-initialised +# ProjectSettings. +static var _cached_res_root: String = "" +static var _cached_user_root: String = "" + + +static func _res_root() -> String: + if _cached_res_root.is_empty(): + _cached_res_root = ProjectSettings.globalize_path("res://").simplify_path() + return _cached_res_root + + +static func _user_root() -> String: + if _cached_user_root.is_empty(): + _cached_user_root = ProjectSettings.globalize_path("user://").simplify_path() + return _cached_user_root + + +## Returns "" when the path is a safe `res://`-rooted reference inside the +## project root. Returns a human-readable error message otherwise. +## Prefer `path_error` over calling this directly — it wraps the message in the +## canonical error code. +## +## Pass `for_write = true` for any handler that creates/overwrites the file +## (write_file, create_script, patch_script, ResourceSaver-backed saves, +## scene saves). Write callers additionally refuse the project manifest and +## startup override, plus the `.godot/` metadata dir. Reads default to +## `for_write = false`, which permits inspecting those files. +static func validate_resource_path(path: String, for_write: bool = false) -> String: + if path.is_empty(): + return "Missing required param: path" + ## Guard the sentinel: on builds where String.chr(0) yields "" (some engines + ## normalize embedded nulls away, e.g. 4.3), contains("") would be true and + ## reject every path. A String that can't hold a null can't smuggle one. + var nul := String.chr(0) + if not nul.is_empty() and path.contains(nul): + return "Path must not contain null bytes" + if not path.begins_with("res://"): + return "Path must start with res://" + var confine_err := _confine_under(path, _res_root(), "res://") + if not confine_err.is_empty(): + return confine_err + if for_write: + return _reject_sensitive_write(path) + return "" + + +## Returns "" when `path` is safe to hand to `ResourceLoader.load` / `.exists`. +## Accepts, in addition to confined `res://` paths: +## * `uid://` — an opaque 64-bit resource id; it cannot express a path +## and the engine only ever resolves it to a resource already in the +## project, so there is nothing to confine. +## * `user://…` — the per-project user data dir, confined under its root the +## same way `res://` is (so `user://../…` can't escape the sandbox). +static func validate_loadable_path(path: String) -> String: + if path.is_empty(): + return "Missing required param: path" + ## Guard the sentinel: on builds where String.chr(0) yields "" (some engines + ## normalize embedded nulls away, e.g. 4.3), contains("") would be true and + ## reject every path. A String that can't hold a null can't smuggle one. + var nul := String.chr(0) + if not nul.is_empty() and path.contains(nul): + return "Path must not contain null bytes" + if path.begins_with("uid://"): + return "" + if path.begins_with("user://"): + return _confine_under(path, _user_root(), "user://") + if path.begins_with("res://"): + return _confine_under(path, _res_root(), "res://") + return "Path must start with res://, uid://, or user://" + + +## Shared traversal + under-root containment. `root` must already be simplified. +static func _confine_under(path: String, root: String, label: String) -> String: + if ".." in path: + return "Path must not contain '..' (path traversal not allowed)" + var globalized := ProjectSettings.globalize_path(path).simplify_path() + # Append a separator so `/proj_evil/...` can't pretend to be inside `/proj` + # via prefix match. `globalized == root` covers the bare `res://` / `user://`. + if globalized != root and not globalized.begins_with(root + "/"): + return "Path must resolve under %s root" % label + return "" + + +## Refuse writes that would clobber project-critical files. The path is already +## confirmed `res://`-rooted and traversal-free by the caller. +## +## Comparisons are case-folded: macOS (APFS) and Windows (NTFS) are +## case-insensitive by default, so `res://Project.godot` resolves to the real +## `project.godot` and must be refused too. +## +## `.import` sidecars are deliberately NOT blocked — editing an asset's import +## options then re-importing is a legitimate, recoverable workflow (the file is +## source-controlled). The blocked set is the startup-execution surface only: +## the manifest, its `override.cfg` shadow, and the `.godot/` cache dir. +static func _reject_sensitive_write(path: String) -> String: + var file_lower := path.get_file().to_lower() + if file_lower == "project.godot": + return "Refusing to write res://project.godot (project manifest)" + if file_lower == "override.cfg": + return "Refusing to write res://override.cfg (startup config override)" + # Reject the `.godot/` editor-metadata dir at any depth. Split drops empty + # segments so a trailing slash can't hide a segment from the check. + for segment in path.trim_prefix("res://").split("/", false): + if segment.to_lower() == ".godot": + return "Refusing to write under res://.godot/ (editor metadata)" + return "" + + +## Validate a write/read `res://` path and return a ready error dict, or null +## when the path is fine. The single wrapper every handler should use so the +## error code (VALUE_OUT_OF_RANGE — a bad path is a value-domain error) stays +## consistent. `param_name` is prefixed onto the message for context. +static func path_error(path: String, param_name: String = "path", for_write: bool = false) -> Variant: + if path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % param_name) + var err := validate_resource_path(path, for_write) + if err.is_empty(): + return null + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "%s: %s" % [param_name, err]) + + +## Same as `path_error` but for paths handed to `ResourceLoader` (allows +## `uid://` / `user://`). Returns a ready error dict or null. An empty path is +## reported as MISSING_REQUIRED_PARAM rather than a value error. +static func loadable_error(path: String, param_name: String = "path") -> Variant: + if path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % param_name) + var err := validate_loadable_path(path) + if err.is_empty(): + return null + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "%s: %s" % [param_name, err]) diff --git a/addons/godot_ai/utils/path_validator.gd.uid b/addons/godot_ai/utils/path_validator.gd.uid new file mode 100644 index 0000000..8ed4acf --- /dev/null +++ b/addons/godot_ai/utils/path_validator.gd.uid @@ -0,0 +1 @@ +uid://blxntmd65ljyu diff --git a/addons/godot_ai/utils/port_resolver.gd b/addons/godot_ai/utils/port_resolver.gd new file mode 100644 index 0000000..f4b4ba0 --- /dev/null +++ b/addons/godot_ai/utils/port_resolver.gd @@ -0,0 +1,315 @@ +@tool +class_name McpPortResolver +extends RefCounted + +## Pure-static port discovery / OS-specific scrapers. No instance state, +## no editor dependencies. plugin.gd has thin instance shims that wrap +## these and increment the cold-start trace counters. + +## Canonical pid-file path. plugin.gd::SERVER_PID_FILE re-exports this so +## external readers and tests can use either name. +const SERVER_PID_FILE := "user://godot_ai_server.pid" +const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd") + + +static func can_bind_local_port(port: int) -> bool: + var server := TCPServer.new() + var err := server.listen(port, "127.0.0.1") + if err == OK: + server.stop() + return true + return false + + +## True when `port` is bound on 127.0.0.1. Probes via TCPServer first, +## falls back to OS scraping. Callers that want to bracket the slow +## scrape with a trace counter should call `is_port_in_use_via_scrape` +## after their own `can_bind_local_port` probe. +static func is_port_in_use(port: int) -> bool: + if can_bind_local_port(port): + ## On POSIX, an IPv6 wildcard listener can coexist with a + ## successful 127.0.0.1 bind probe. Confirm with lsof so startup + ## sees the same listener set that shutdown/recovery would see. + if OS.get_name() != "Windows": + return is_port_in_use_via_scrape(port) + return false + return is_port_in_use_via_scrape(port) + + +static func is_port_in_use_via_scrape(port: int) -> bool: + var output: Array = [] + if OS.get_name() == "Windows": + var exit_code := OS.execute("netstat", ["-ano"], output, true) + if exit_code == 0 and output.size() > 0: + return parse_windows_netstat_listening(str(output[0]), port) + return false + var exit_code := OS.execute("lsof", ["-ti:%d" % port, "-sTCP:LISTEN"], output, true) + return exit_code == 0 and output.size() > 0 and not output[0].strip_edges().is_empty() + + +## Return the PID currently listening on the given TCP port, or 0 if +## the port is free. Thin convenience wrapper around `find_all_pids_on_port` +## — the per-OS scraping logic lives in one place. +static func find_pid_on_port(port: int, trace: Callable = Callable()) -> int: + var pids := find_all_pids_on_port(port, trace) + return pids[0] if not pids.is_empty() else 0 + + +## Returns every PID bound LISTEN on `port`. Used by the kill paths so +## both the uvicorn reloader parent AND its worker child are caught when +## both bind the same port. +## +## `trace` is an optional Callable that fires once per OS invocation with +## a counter name (`"netstat"` / `"powershell"` / `"lsof"`) so the plugin +## can keep its cold-start trace accurate. The Windows path may fall +## through netstat → PowerShell, and a wrapping caller can't see which +## scraper actually ran without the hook. +static func find_all_pids_on_port(port: int, trace: Callable = Callable()) -> Array[int]: + if OS.get_name() == "Windows": + var output: Array = [] + _trace(trace, "netstat") + var exit_code := OS.execute("netstat", ["-ano"], output, true) + if exit_code == 0 and not output.is_empty(): + var netstat_pids := parse_windows_netstat_pids(str(output[0]), port) + if not netstat_pids.is_empty(): + return netstat_pids + _trace(trace, "powershell") + return find_listener_pids_windows(port) + var output: Array = [] + _trace(trace, "lsof") + var exit_code := OS.execute("lsof", ["-ti:%d" % port, "-sTCP:LISTEN"], output, true) + if exit_code != 0 or output.is_empty(): + var empty: Array[int] = [] + return empty + return parse_lsof_pids(str(output[0])) + + +static func _trace(trace: Callable, counter: String) -> void: + if trace.is_valid(): + trace.call(counter) + + +static func find_listener_pids_windows(port: int) -> Array[int]: + var script := ( + "Get-NetTCPConnection -LocalPort %d -State Listen " + + "-ErrorAction SilentlyContinue | " + + "Select-Object -ExpandProperty OwningProcess" + ) % port + var output: Array = [] + var exit_code := execute_windows_powershell(script, output) + return windows_listener_pids_from_execute_result(exit_code, output) + + +static func execute_windows_powershell(script: String, output: Array) -> int: + var args := ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script] + for exe in windows_powershell_candidates(): + output.clear() + var exit_code := OS.execute(exe, args, output, true) + if exit_code == 0: + return exit_code + return -1 + + +static func windows_powershell_candidates() -> Array[String]: + var candidates: Array[String] = [] + var system_root := OS.get_environment("SystemRoot") + if system_root.is_empty(): + system_root = "C:/Windows" + system_root = system_root.replace("\\", "/").trim_suffix("/") + candidates.append(system_root + "/System32/WindowsPowerShell/v1.0/powershell.exe") + candidates.append("powershell.exe") + candidates.append("pwsh.exe") + return candidates + + +static func windows_listener_pids_from_execute_result(exit_code: int, output: Array) -> Array[int]: + var empty: Array[int] = [] + if exit_code == 0 and not output.is_empty(): + return parse_pid_lines(str(output[0])) + return empty + + +static func windows_listener_execute_result_in_use(exit_code: int, output: Array) -> bool: + return not windows_listener_pids_from_execute_result(exit_code, output).is_empty() + + +## Pure parser for `lsof -ti` output — newline-separated decimal PIDs. +## Empty lines and non-numeric tokens are dropped. Duplicates pass +## through (uvicorn reloader + worker can produce the same PID twice +## across runs but typically two distinct PIDs). +static func parse_lsof_pids(raw: String) -> Array[int]: + var pids: Array[int] = [] + for line in raw.strip_edges().split("\n", false): + var stripped := line.strip_edges() + if stripped.is_valid_int(): + pids.append(int(stripped)) + return pids + + +static func parse_pid_lines(raw: String) -> Array[int]: + var pids: Array[int] = [] + for line in raw.strip_edges().split("\n", false): + var stripped := line.strip_edges() + if stripped.is_valid_int(): + var pid := int(stripped) + if pid > 0 and not pids.has(pid): + pids.append(pid) + return pids + + +## Parse a Windows `netstat -ano` dump and return PIDs of rows whose +## local address ends with `:port` AND state is `LISTENING`. Substring +## matching the whole dump is wrong: a remote address containing +## `:port` would false-positive against an unrelated ESTABLISHED row. +static func parse_windows_netstat_pid(stdout: String, port: int) -> int: + var pids := parse_windows_netstat_pids(stdout, port) + return pids[0] if not pids.is_empty() else 0 + + +static func parse_windows_netstat_pids(stdout: String, port: int) -> Array[int]: + var pids: Array[int] = [] + var port_suffix := ":%d" % port + for line in stdout.split("\n"): + var s := line.strip_edges() + if s.is_empty(): + continue + var fields := split_on_whitespace(s) + if fields.size() < 5: # proto, local, remote, state, pid + continue + if fields[3] != "LISTENING": + continue + if not fields[1].ends_with(port_suffix): + continue + var pid_str := fields[fields.size() - 1] + if pid_str.is_valid_int(): + var pid := int(pid_str) + if pid > 0 and not pids.has(pid): + pids.append(pid) + return pids + + +static func parse_windows_netstat_listening(stdout: String, port: int) -> bool: + return parse_windows_netstat_pid(stdout, port) > 0 + + +## `String.split(" ", false)` only splits on single spaces; netstat +## columns are separated by runs of spaces / tabs. Collapse manually. +static func split_on_whitespace(s: String) -> PackedStringArray: + var out: PackedStringArray = [] + var cur := "" + for i in s.length(): + var c := s.substr(i, 1) + if c == " " or c == "\t": + if not cur.is_empty(): + out.append(cur) + cur = "" + else: + cur += c + if not cur.is_empty(): + out.append(cur) + return out + + +static func read_pid_file() -> int: + if not FileAccess.file_exists(SERVER_PID_FILE): + return 0 + var f := FileAccess.open(SERVER_PID_FILE, FileAccess.READ) + if f == null: + return 0 + var content := f.get_as_text().strip_edges() + f.close() + if content.is_empty() or not content.is_valid_int(): + return 0 + var pid := int(content) + return pid if pid > 0 else 0 + + +static func clear_pid_file() -> void: + if FileAccess.file_exists(SERVER_PID_FILE): + DirAccess.remove_absolute(ProjectSettings.globalize_path(SERVER_PID_FILE)) + + +## `kill -0` returns 0 for both running and zombie processes; Godot +## never `waitpid`s on `OS.create_process` children, so a fast-failing +## uvx launcher lingers as a zombie forever and `kill -0` would block +## the spawn-failure branch in check_server_health from firing. Use +## `ps -o stat=` instead. State codes: R/S/D/I/T (live), Z (zombie). #172. +static func pid_alive(pid: int) -> bool: + if pid <= 0: + return false + if OS.get_name() == "Windows": + var output: Array = [] + var exit_code := OS.execute("tasklist", ["/FI", "PID eq %d" % pid, "/NH", "/FO", "CSV"], output, true) + if exit_code != 0 or output.is_empty(): + return false + for line in output: + if str(line).find("\"%d\"" % pid) >= 0: + return true + return false + var output: Array = [] + var exit_code := OS.execute("ps", ["-p", str(pid), "-o", "stat="], output, true) + if exit_code != 0 or output.is_empty(): + return false + var stat := str(output[0]).strip_edges() + return not stat.is_empty() and not stat.begins_with("Z") + + +## Poll until the given port is no longer bound, or the timeout elapses. +## Used after `OS.kill` so we don't race the port-in-use check on rebind. +static func wait_for_port_free(port: int, timeout_s: float) -> void: + var deadline := Time.get_ticks_msec() + int(timeout_s * 1000.0) + while is_port_in_use(port): + if Time.get_ticks_msec() >= deadline: + push_warning("MCP | port %d still in use after %.1fs — proceeding anyway" % [port, timeout_s]) + return + OS.delay_msec(100) + + +## Choose a non-Windows-reserved WS port. Returns `configured` when free; +## otherwise the first non-excluded port within `span` of it. Optional +## `log_buffer` is a duck-typed sink (`log(String)`) that gets the +## remap notice so users see why the port shifted. +static func resolve_ws_port(configured: int, max_port: int, log_buffer = null) -> int: + var resolved := WindowsPortReservation.suggest_non_excluded_port( + configured, + 2048, + max_port + ) + if resolved != configured: + var message := "WebSocket port %d is reserved by Windows; using %d" % [configured, resolved] + print("MCP | %s" % message) + if log_buffer != null: + log_buffer.log(message) + return resolved + + +## Trust the cached ws_port from the managed record only when the record +## is current ownership proof — i.e. record version matches the installed +## plugin. Otherwise a stale record from an older install (e.g. a 9500 +## value pre-Windows-reservation collision) would mislead the +## compatibility check into killing an unrelated external process. #259. +static func resolved_ws_port_for_existing_server( + record_ws_port: int, + record_version: String, + current_version: String, + fresh_resolved: int +) -> int: + if record_ws_port <= 0: + return fresh_resolved + if current_version.is_empty() or record_version != current_version: + return fresh_resolved + return record_ws_port + + +static func resolve_ws_port_from_output( + configured_port: int, + netsh_output: String, + max_port: int, + span: int = 2048 +) -> int: + return WindowsPortReservation.suggest_non_excluded_port_from_output( + netsh_output, + configured_port, + span, + max_port + ) diff --git a/addons/godot_ai/utils/port_resolver.gd.uid b/addons/godot_ai/utils/port_resolver.gd.uid new file mode 100644 index 0000000..54a3d73 --- /dev/null +++ b/addons/godot_ai/utils/port_resolver.gd.uid @@ -0,0 +1 @@ +uid://pk0212qfh61x diff --git a/addons/godot_ai/utils/resource_io.gd b/addons/godot_ai/utils/resource_io.gd new file mode 100644 index 0000000..00263c4 --- /dev/null +++ b/addons/godot_ai/utils/resource_io.gd @@ -0,0 +1,131 @@ +@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. + + +## 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] + ) + + 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 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} + + +## 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} 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..cea9c3d --- /dev/null +++ b/addons/godot_ai/utils/scene_path.gd @@ -0,0 +1,146 @@ +@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 + + ## /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.) intentionally don't carry + # this payload because there's no useful caller hint to give. + var err := ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "No scene open") + err["error"]["data"] = { + "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/server_lifecycle.gd b/addons/godot_ai/utils/server_lifecycle.gd new file mode 100644 index 0000000..8d06ada --- /dev/null +++ b/addons/godot_ai/utils/server_lifecycle.gd @@ -0,0 +1,904 @@ +@tool +class_name McpServerLifecycleManager +extends RefCounted + +## Server spawn / stop / respawn / adopt / recover orchestration plus the +## update-reload handoff. Owns the server-state machine +## (`McpServerState`), version-check seam (`McpServerVersionCheck`), +## adoption metadata, and connection-blocked / dev-mismatch flags. +## +## State previously lived on plugin.gd; PR 6 (#297) moved it here so +## PR 7 (UpdateManager extraction) can absorb the same encapsulation +## pattern. The plugin still owns the physical editor surfaces +## (Connection, Dock, Timer, EditorSettings I/O) and exposes them via +## `_host.()` shims; the test fixtures override those shims to +## drive the manager without touching the editor. +## +## `_host` is untyped to honor the self-update field-storage policy +## plugin.gd calls out near `_connection`. +var _host + +const UvCacheCleanup := preload("res://addons/godot_ai/utils/uv_cache_cleanup.gd") +const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd") +const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd") +const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd") +const McpServerStateScript := preload("res://addons/godot_ai/utils/mcp_server_state.gd") +const McpStartupPathScript := preload("res://addons/godot_ai/utils/mcp_startup_path.gd") +const McpAdoptionLabelScript := preload("res://addons/godot_ai/utils/mcp_adoption_label.gd") +const McpServerVersionCheckScript := preload("res://addons/godot_ai/utils/server_version_check.gd") + +# ---- State (owned here, was on plugin.gd through PR 5) --------------- + +## Single source of truth for the server-spawn/adopt/version lifecycle. +## See `McpServerState` for the transition table. +var _server_state: int = McpServerStateScript.UNINITIALIZED + +## OS-level state populated only when WE spawned the process. +var _server_pid: int = -1 +var _server_spawn_ms: int = 0 +var _server_exit_ms: int = 0 + +## Version metadata. `expected_version` is what the plugin shipped with; +## `actual_version` is what the live server reported via handshake_ack. +var _server_expected_version: String = "" +var _server_actual_version: String = "" +var _server_actual_name: String = "" + +## Diagnostic + recovery flags surfaced to the dock via `get_status()`. +var _server_status_message: String = "" +var _can_recover_incompatible: bool = false +var _connection_blocked: bool = false + +## One-shot guard for the stale-uvx-index recovery (#172). Reset at the +## top of `start_server` so each fresh spawn attempt gets its own +## refresh budget. +var _refresh_retried: bool = false + +## Bounded deadline for the foreign-port adoption-confirmation watcher. +## Zero when disarmed. +var _adoption_watch_deadline_ms: int = 0 + +## Branch-tag from the most recent `start_server` walk. See +## `McpStartupPath`. Drives the startup-trace log. +var _startup_path: String = McpStartupPathScript.UNSET + +## Version-check seam. Lazily constructed on `arm_version_check` so +## tests that exercise the manager without a connection don't have to +## stub it out. +var _version_check + + +func _init(host) -> void: + _host = host + + +# ---- Public state accessors -------------------------------------------- + +func get_state() -> int: + return _server_state + + +func get_status_dict() -> Dictionary: + return { + "state": _server_state, + "exit_ms": _server_exit_ms, + "actual_name": _server_actual_name, + "actual_version": _server_actual_version, + "expected_version": _server_expected_version, + "message": _server_status_message, + "can_recover_incompatible": _can_recover_incompatible, + "connection_blocked": _connection_blocked, + } + + +func get_server_pid() -> int: + return _server_pid + + +func get_startup_path() -> String: + return _startup_path + + +func get_adoption_watch_deadline_ms() -> int: + return _adoption_watch_deadline_ms + + +func is_awaiting_server_version() -> bool: + return _version_check != null and _version_check.is_active() + + +func is_connection_blocked() -> bool: + return _connection_blocked + + +# ---- State-machine entry points --------------------------------------- + +## Validated transition. Returns true on success; false (and logs a +## warning) when the transition is illegal under `McpServerState`'s +## table. Callers that need first-writer-wins among terminal diagnoses +## use `set_terminal_diagnosis` instead — that helper silently no-ops +## without warning when the diagnosis would be a regression. +func transition_state(target: int) -> bool: + if _server_state == target: + return true + if not McpServerStateScript.can_transition(_server_state, target): + push_warning( + "MCP | rejected illegal state transition %s -> %s" + % [ + McpServerStateScript.name_of(_server_state), + McpServerStateScript.name_of(target), + ] + ) + return false + _server_state = target + return true + + +## First-writer-wins mutator for terminal diagnoses (CRASHED, +## NO_COMMAND, PORT_EXCLUDED, INCOMPATIBLE, FOREIGN_PORT). Used during +## spawn to make sure a late watch-loop CRASHED doesn't clobber an +## earlier proactive PORT_EXCLUDED. Silent no-op when the current state +## is already a terminal diagnosis — the existing diagnosis is kept. +func set_terminal_diagnosis(target: int) -> bool: + if not McpServerStateScript.is_terminal_diagnosis(target): + push_warning( + "MCP | set_terminal_diagnosis called with non-terminal %s" + % McpServerStateScript.name_of(target) + ) + return false + if McpServerStateScript.is_terminal_diagnosis(_server_state): + return false + _server_state = target + return true + + +# ---- Adoption confirmation watcher ------------------------------------- + +## Arm the FOREIGN_PORT adoption-confirmation watcher. SPAWN_GRACE_MS +## ahead of `now`; `tick_adoption_watch` self-disarms after this expires +## so per-frame cost drops back to zero on a permanent foreign occupant. +func arm_adoption_watch() -> void: + _adoption_watch_deadline_ms = ( + Time.get_ticks_msec() + int(_host.SPAWN_GRACE_MS) + ) + + +func disarm_adoption_watch() -> void: + _adoption_watch_deadline_ms = 0 + + +func tick_adoption_watch(now_msec: int) -> void: + if _adoption_watch_deadline_ms > 0 and now_msec >= _adoption_watch_deadline_ms: + _adoption_watch_deadline_ms = 0 + + +# ---- Server version-check seam ---------------------------------------- + +func arm_version_check(connection, expected_version: String) -> void: + if _version_check == null: + _version_check = McpServerVersionCheckScript.new(self) + var expected := _resolve_expected_version(expected_version) + _server_expected_version = expected + _version_check.arm(connection, expected) + + +func disarm_version_check() -> void: + if _version_check != null: + _version_check.disarm() + + +func get_version_check(): + return _version_check + + +## Resolves a possibly-empty expected version to the plugin's shipping +## version. Manager methods that are called via test fixtures may +## receive an empty string when the test never seeded +## `_server_expected_version`, so this is the one place that fallback +## lives. +func _resolve_expected_version(supplied: String) -> String: + if not supplied.is_empty(): + return supplied + return _expected_server_version() + + +func _expected_server_version() -> String: + return ClientConfigurator.get_plugin_version() + + +## Called by McpServerVersionCheck when handshake_ack carries a version +## string. Decides compatible vs incompatible and transitions the state. +func handle_server_version_verified(expected_version: String, version: String) -> void: + _server_actual_name = "godot-ai" + _server_actual_version = version + var expected := _resolve_expected_version(expected_version) + _server_expected_version = expected + var compatibility := _server_version_compatibility(version, expected) + if compatibility.get("compatible", false): + _can_recover_incompatible = false + ## Foreign-port and post-spawn handshakes both clear to READY + ## on a successful handshake. Late re-arms from READY also land + ## here and self-confirm. + transition_state(McpServerStateScript.READY) + _host._update_process_enabled() + return + var live := {"version": version, "status_code": 200, "name": "godot-ai"} + _set_incompatible_server(live, expected, ClientConfigurator.http_port()) + if _host._connection != null: + _host._connection.connect_blocked = true + _host._connection.connect_block_reason = _server_status_message + _host._connection.disconnect_from_server() + _host._update_process_enabled() + + +func handle_server_version_unverified(expected_version: String) -> void: + var expected := _resolve_expected_version(expected_version) + _server_expected_version = expected + var live := {"version": "", "status_code": 0, "error": "missing_handshake_ack"} + _set_incompatible_server(live, expected, ClientConfigurator.http_port()) + if _host._connection != null: + _host._connection.connect_blocked = true + _host._connection.connect_block_reason = _server_status_message + _host._connection.disconnect_from_server() + _host._update_process_enabled() + + +# ---- Compatibility / version helpers (pure) --------------------------- + +## Plugin and server speak a single, version-coupled protocol — new commands +## and response fields are added together. Treating dev-mode mismatches as +## "compatible" silently adopts a stale server whose code may differ from the +## live source tree (e.g. another worktree on a different branch holding +## port 8000). Strict match in all modes routes mismatches through +## `recover_strong_port_occupant`, which kills the branded port-holder and +## lets `start_server` spawn fresh against the current source. +static func _server_version_compatibility( + actual_version: String, + expected_version: String +) -> Dictionary: + if actual_version.is_empty(): + return {"compatible": false, "reason": "unknown"} + if actual_version == expected_version: + return {"compatible": true, "reason": "exact"} + return {"compatible": false, "reason": "version_mismatch"} + + +static func _server_status_compatibility( + actual_version: String, + expected_version: String, + actual_ws_port: int, + expected_ws_port: int, +) -> Dictionary: + var version_result := _server_version_compatibility(actual_version, expected_version) + if not bool(version_result.get("compatible", false)): + return version_result + if actual_ws_port != expected_ws_port: + return {"compatible": false, "reason": "ws_port_mismatch"} + return version_result + + +static func _managed_record_has_version_drift(record_version: String, current_version: String) -> bool: + return not record_version.is_empty() and record_version != current_version + + +# ---- Incompatible-server bookkeeping ---------------------------------- + +func _set_incompatible_server(live: Dictionary, expected_version: String, port: int) -> void: + ## Latches the incompatible diagnosis into manager state and asks + ## the dock to re-sweep client rows so they don't show stale green. + ## Threads the caller's `live` snapshot through the recovery proof + ## helper so we don't double-probe the port (~500ms each). + transition_state(McpServerStateScript.INCOMPATIBLE) + _connection_blocked = true + _server_expected_version = expected_version + _server_actual_name = str(live.get("name", "")) + _server_actual_version = _live_version_for_message(live) + _server_status_message = _incompatible_server_message( + live, expected_version, port, int(_host._resolved_ws_port) + ) + var proof: Dictionary = _host._evaluate_recovery_port_occupant_proof(port, live) + var proof_name := str(proof.get("proof", "")) + _can_recover_incompatible = not proof_name.is_empty() + print("MCP | proof: %s" % (proof_name if _can_recover_incompatible else "(none)")) + _host._refresh_dock_client_statuses() + + +static func _incompatible_server_message( + live: Dictionary, + expected_version: String, + port: int, + expected_ws_port: int +) -> String: + var version := _live_version_for_message(live) + var actual_ws_port := _live_ws_port_for_message(live) + ## `package_path` is a v2.4.4+ field — older servers omit it. Suffix + ## the message with "(loaded from )" when present so the user + ## can tell *which* `src/godot_ai/` is serving the port without + ## walking the process tree. See #416. + var package_path := _live_package_path_for_message(live) + var path_suffix := " (loaded from %s)" % package_path if not package_path.is_empty() else "" + if not version.is_empty(): + if actual_ws_port > 0 and actual_ws_port != expected_ws_port: + return ( + "Port %d is occupied by godot-ai server v%s using WS port %d%s; " + + "plugin expects v%s with WS port %d. Stop the old server or " + + "change both HTTP and WS ports." + ) % [port, version, actual_ws_port, path_suffix, expected_version, expected_ws_port] + return ( + "Port %d is occupied by godot-ai server v%s%s; plugin expects v%s. " + + "Stop the old server or change both HTTP and WS ports." + ) % [port, version, path_suffix, expected_version] + var status_code := int(live.get("status_code", 0)) + if status_code > 0: + return ( + "Port %d is occupied by an unverified server (status endpoint returned HTTP %d); " + + "plugin expects godot-ai v%s. Stop the other server or change both HTTP and WS ports." + ) % [port, status_code, expected_version] + return ( + "Port %d is occupied by another process; plugin expects godot-ai v%s. " + + "Stop the other process or change both HTTP and WS ports." + ) % [port, expected_version] + + +static func _live_status_identifies_godot_ai(live: Dictionary) -> bool: + return str(live.get("name", "")) == "godot-ai" + + +static func _live_version_for_message(live: Dictionary) -> String: + if live.has("name") and str(live.get("name", "")) != "godot-ai": + return "" + return str(live.get("version", "")) + + +static func _live_ws_port_for_message(live: Dictionary) -> int: + if live.has("name") and str(live.get("name", "")) != "godot-ai": + return 0 + return int(live.get("ws_port", 0)) + + +static func _live_package_path_for_message(live: Dictionary) -> String: + ## Only trust the path when the live snapshot confirms a godot-ai + ## server — a probe of some unrelated HTTP service could in theory + ## return a `package_path` JSON field, and we don't want to mislabel + ## that as "godot-ai loaded from …" in the incompatible banner. + if live.has("name") and str(live.get("name", "")) != "godot-ai": + return "" + return str(live.get("package_path", "")) + + +# ---- start_server / spawn watch / respawn ----------------------------- + + +## Sets GODOT_AI_DISABLE_TELEMETRY in the process environment for the +## upcoming OS.create_process call if: (a) neither GODOT_AI_DISABLE_TELEMETRY +## nor DISABLE_TELEMETRY is already set to a *truthy* value (a falsey "0" does +## NOT count — it must not suppress a dock UI opt-out), and (b) the effective +## McpSettings.telemetry_enabled() is false. Returns true if the var was +## injected so the caller can unset it after spawning. +func _inject_telemetry_env() -> bool: + ## If telemetry is already disabled by a *truthy* env var, leave the env as + ## the user/CI set it — the post-spawn cleanup unsets what we inject, so + ## injecting here would strip their own var from the editor process. A + ## *falsey* value (e.g. DISABLE_TELEMETRY=0) must NOT count as "handled": + ## fall through so a dock UI opt-out still reaches the spawned server. The + ## truthy test mirrors McpSettings.telemetry_enabled() and the Python server. + if McpSettings.env_truthy("GODOT_AI_DISABLE_TELEMETRY") or McpSettings.env_truthy("DISABLE_TELEMETRY"): + return false + if not McpSettings.telemetry_enabled(): + OS.set_environment("GODOT_AI_DISABLE_TELEMETRY", "true") + return true + return false + + +## Set GODOT_AI_OWNER_PID to this editor's PID for the next OS.create_process, +## so the spawned server can self-reap if this editor crashes. Returns true if +## set (caller must unset right after spawning — keep it out of the persistent +## editor env). No-op on Windows, where the server's reaper is disabled. +func _set_owner_pid_env() -> bool: + if OS.get_name() == "Windows": + return false + OS.set_environment("GODOT_AI_OWNER_PID", str(OS.get_process_id())) + return true + + +## Branch table (recorded version is the "is this ours?" signal — uvx +## launcher PIDs go stale; #135/#137): +## port free -> spawn fresh, record PID +## port in use, record matches + live ok -> adopt port owner (heals PID) +## port in use, record drifts -> kill owner + respawn +## port in use, no verified live match -> block adoption + warn +func start_server() -> void: + if _host._server_started_this_session: + ## Static flag persists across disable/enable cycles in one editor + ## session — re-entrant spawn guard for plugin-reload-during-update. + _startup_path = McpStartupPathScript.GUARDED + transition_state(McpServerStateScript.GUARDED) + return + + _refresh_retried = false + + var port := ClientConfigurator.http_port() + var ws_port := ClientConfigurator.ws_port() + var current_version := _expected_server_version() + _server_expected_version = current_version + + if bool(_host._is_port_in_use(port)): + var record: Dictionary = _host._read_managed_server_record() + var record_version := str(record.get("version", "")) + var record_ws_port := int(record.get("ws_port", 0)) + _host._set_resolved_ws_port(PortResolver.resolved_ws_port_for_existing_server( + record_ws_port, + record_version, + current_version, + int(_host._resolve_ws_port()) + )) + ws_port = int(_host._resolved_ws_port) + var live: Dictionary = _host._probe_live_server_status_for_port(port) + var live_version := str(_host._verified_status_version(live)) + var live_ws_port := int(_host._verified_status_ws_port(live)) + var compatibility: Dictionary = _server_status_compatibility( + live_version, + current_version, + live_ws_port, + ws_port, + ) + if compatibility.get("compatible", false): + _server_actual_name = "godot-ai" + _server_actual_version = live_version + _can_recover_incompatible = false + var owner := int(_host._find_managed_pid(port)) + var owner_label := adopt_compatible_server(record_version, current_version, owner) + _host._server_started_this_session = true + _startup_path = McpStartupPathScript.ADOPTED + transition_state(McpServerStateScript.READY) + print(_compatible_adoption_log_message( + owner_label, + int(_server_pid), + owner, + str(_server_actual_version), + live_ws_port, + current_version + )) + return + if bool(_managed_record_has_version_drift(record_version, current_version)): + print("MCP | managed server v%s does not match plugin v%s, restarting" + % [record_version, current_version]) + ## Forward `live` so the recovery proof helper reuses our snapshot. + ## The kill invalidates it, so the failure arm re-probes below. + if not recover_strong_port_occupant(port, 3.0, live): + _host._server_started_this_session = true + var post_recovery_live: Dictionary = _host._probe_live_server_status_for_port(port) + _set_incompatible_server(post_recovery_live, current_version, port) + _startup_path = McpStartupPathScript.INCOMPATIBLE + push_warning(str(_server_status_message)) + return + else: + _startup_path = McpStartupPathScript.FREE + + _host._set_resolved_ws_port(_host._resolve_ws_port()) + ws_port = _host._resolved_ws_port + + _host._startup_trace_count("server_command_discovery") + var server_cmd := ClientConfigurator.get_server_command() + if server_cmd.is_empty(): + set_terminal_diagnosis(McpServerStateScript.NO_COMMAND) + _startup_path = McpStartupPathScript.NO_COMMAND + push_warning("MCP | could not find server command") + return + + var cmd: String = server_cmd[0] + var args: Array[String] = [] + args.assign(server_cmd.slice(1)) + args.append_array(_host._build_server_flags(port, ws_port)) + + ## Wipe any stale pid-file so a failed launch can't leave last + ## session's PID for `_find_managed_pid` to read. + _host._clear_pid_file() + + ## Proactive Windows port-reservation check (#146) — bind would + ## fail silently with WinError 10013 inside a Hyper-V / WSL2 / + ## Docker exclusion range; netstat shows nothing. + if WindowsPortReservation.is_port_excluded(port): + _host._server_started_this_session = true + set_terminal_diagnosis(McpServerStateScript.PORT_EXCLUDED) + _startup_path = McpStartupPathScript.RESERVED + push_warning("MCP | port %d is reserved by Windows (Hyper-V / WSL2 / Docker)" % port) + return + + var injected_telemetry_env := _inject_telemetry_env() + + ## PYTHONPATH handling for dev checkouts: when the editor is launched + ## against a worktree whose `src/godot_ai/__version__` differs from the + ## root repo's editable install, the dev-venv python's `sitecustomize` + ## adds the *root repo's* `src/` to `sys.path`. The spawned server then + ## reports the root repo's version, the plugin's compatibility check + ## flags it as incompatible, and the user gets a Restart-Server loop + ## with no exit. `start_dev_server` already prepends the worktree's + ## `src/` for its --reload spawn; mirror that here for the auto-spawn + ## path so the same worktree-vs-root version skew is impossible. Gated + ## on `is_dev_checkout()` so production user installs (no nearby `src/`) + ## are untouched. See #418. + var worktree_src := "" + var prev_pythonpath := "" + var pythonpath_set := false + if ClientConfigurator.is_dev_checkout(): + worktree_src = ClientConfigurator.find_worktree_src_dir( + ProjectSettings.globalize_path("res://") + ) + if not worktree_src.is_empty(): + prev_pythonpath = OS.get_environment("PYTHONPATH") + var sep := ";" if OS.get_name() == "Windows" else ":" + var new_pp := ( + worktree_src + if prev_pythonpath.is_empty() + else worktree_src + sep + prev_pythonpath + ) + OS.set_environment("PYTHONPATH", new_pp) + pythonpath_set = true + + ## Tell the spawned server which editor owns it so it can self-reap if we + ## die without a clean stop_server (crash / hard-kill). Passed via env, not + ## a CLI flag, so an older server (staggered user-mode upgrade) silently + ## ignores an unknown var instead of failing argparse. Scoped tightly around + ## create_process and unset right after (like PYTHONPATH below): the child + ## inherits it, but it must NOT linger in the editor env, or a later + ## non-reload `godot-ai` subprocess (dev server, future spawn) would inherit + ## it and wrongly arm a reaper keyed to this editor. + ## Skipped on Windows: the server's reaper is POSIX-only for now (Windows + ## process-liveness/self-shutdown isn't live-validated yet). The server + ## gates on this too. + var owner_env_set := _set_owner_pid_env() + + _server_pid = OS.create_process(cmd, args) + var spawned_pid := int(_server_pid) + + if owner_env_set: + OS.unset_environment("GODOT_AI_OWNER_PID") + + ## Restore PYTHONPATH immediately — the spawned child has already + ## copied the env, so the editor's own process state returns to + ## baseline. Leaving it set would leak to any later OS.create_process + ## from unrelated paths. + if pythonpath_set: + if prev_pythonpath.is_empty(): + OS.unset_environment("PYTHONPATH") + else: + OS.set_environment("PYTHONPATH", prev_pythonpath) + + if injected_telemetry_env: + OS.unset_environment("GODOT_AI_DISABLE_TELEMETRY") + + if spawned_pid > 0: + _server_spawn_ms = Time.get_ticks_msec() + _server_exit_ms = 0 + _host._server_started_this_session = true + transition_state(McpServerStateScript.SPAWNING) + ## Record the launcher PID so same-session + ## prepare_for_update_reload has something to kill. The next + ## editor start's adopt branch heals it to the real port owner. + _host._write_managed_server_record(spawned_pid, current_version) + _startup_path = McpStartupPathScript.SPAWNED + ## Log "PYTHONPATH prefix=" rather than "PYTHONPATH=" so the line + ## isn't misleading when an existing PYTHONPATH was present — + ## we prepended `worktree_src`, not replaced. Keeps the log + ## compact (worktree_src is the actionable piece; the full + ## prev_pythonpath can be 5+ entries long on dev machines). + var suffix := " (PYTHONPATH prefix=%s)" % worktree_src if not worktree_src.is_empty() else "" + print("MCP | started server (PID %d, v%s): %s %s%s" % [spawned_pid, current_version, cmd, " ".join(args), suffix]) + _host._start_server_watch() + else: + set_terminal_diagnosis(McpServerStateScript.CRASHED) + _startup_path = McpStartupPathScript.CRASHED + push_warning("MCP | failed to start server") + + +## Watch-loop callback (1 Hz, capped by SERVER_WATCH_MS). +## `--pid-file` is the source of truth on Windows / uvx where the +## launcher PID dies quickly after spawning the real interpreter. +func check_server_health() -> void: + if int(_server_pid) <= 0: + _host._stop_server_watch() + return + var elapsed := Time.get_ticks_msec() - int(_server_spawn_ms) + var real_pid := PortResolver.read_pid_file() + var spawn_pid := int(_server_pid) + if real_pid > 0 and real_pid != spawn_pid and PortResolver.pid_alive(real_pid): + _server_pid = real_pid + elif not PortResolver.pid_alive(spawn_pid): + if elapsed >= int(_host.SPAWN_GRACE_MS) and not McpServerStateScript.is_terminal_diagnosis(_server_state): + if bool(_host._should_retry_with_refresh()): + _refresh_retried = true + respawn_with_refresh() + return + _server_exit_ms = elapsed + set_terminal_diagnosis(McpServerStateScript.CRASHED) + disarm_version_check() + _host._update_process_enabled() + _host._log_buffer.log("server exited after %dms — see Godot output log" % int(_server_exit_ms)) + _host._stop_server_watch() + return + if elapsed >= int(_host.SERVER_WATCH_MS): + ## Survived startup — mid-session crashes surface via WebSocket disconnect. + _host._stop_server_watch() + + +## Retry the spawn with uvx `--refresh` prepended (PyPI index can lag a +## fresh publish ~10 min — #172). One-shot per session via _refresh_retried. +func respawn_with_refresh() -> void: + _host._startup_trace_count("server_command_discovery") + var server_cmd := ClientConfigurator.get_server_command(true) + if server_cmd.is_empty(): + return + var cmd: String = server_cmd[0] + var args: Array[String] = [] + args.assign(server_cmd.slice(1)) + args.append_array(_host._build_server_flags(ClientConfigurator.http_port(), int(_host._resolved_ws_port))) + _host._clear_pid_file() + _host._log_buffer.log("retrying with --refresh (PyPI index may be stale)") + var injected_telemetry_env := _inject_telemetry_env() + ## Set owner PID for THIS spawn too (don't rely on it lingering from + ## start_server) — and unset right after, same scoping as start_server. + var owner_env_set := _set_owner_pid_env() + _server_pid = OS.create_process(cmd, args) + if owner_env_set: + OS.unset_environment("GODOT_AI_OWNER_PID") + if injected_telemetry_env: + OS.unset_environment("GODOT_AI_DISABLE_TELEMETRY") + var spawn_pid := int(_server_pid) + if spawn_pid > 0: + _server_spawn_ms = Time.get_ticks_msec() + _server_exit_ms = 0 + var current_version := _expected_server_version() + _host._write_managed_server_record(spawn_pid, current_version) + print("MCP | retried server (PID %d, v%s): %s %s" % [spawn_pid, current_version, cmd, " ".join(args)]) + else: + ## OS.create_process returned -1 on the retry — surface CRASHED + ## rather than loop. `_refresh_retried` is already true. + set_terminal_diagnosis(McpServerStateScript.CRASHED) + disarm_version_check() + _host._update_process_enabled() + _host._log_buffer.log("refresh retry failed to spawn — see Godot output log") + _host._stop_server_watch() + + +func adopt_compatible_server(record_version: String, current_version: String, owner: int) -> String: + _server_actual_name = "godot-ai" + _can_recover_incompatible = false + if record_version == current_version and owner > 0: + _server_pid = owner + _host._write_managed_server_record(owner, current_version) + return McpAdoptionLabelScript.MANAGED + _server_pid = -1 + _host._clear_managed_server_record() + _host._clear_pid_file() + return McpAdoptionLabelScript.EXTERNAL + + +static func _compatible_adoption_log_message( + owner_label: String, + owned_pid: int, + observed_owner_pid: int, + live_version: String, + live_ws_port: int, + current_version: String +) -> String: + if owner_label == McpAdoptionLabelScript.MANAGED: + return "MCP | adopted managed server (PID %d, live v%s, WS %d, plugin v%s)" % [ + owned_pid, + live_version, + live_ws_port, + current_version + ] + return "MCP | adopted external server owner_pid=%d (live v%s, WS %d, plugin v%s)" % [ + observed_owner_pid, + live_version, + live_ws_port, + current_version + ] + + +## `pre_kill_live` is forwarded into the proof helper so it doesn't +## re-probe a port the caller already probed. The kill invalidates the +## snapshot — callers MUST re-probe before consuming live-status data +## after this returns. +func recover_strong_port_occupant(port: int, wait_s: float, pre_kill_live: Dictionary = {}) -> bool: + var proof: Dictionary = _host._evaluate_strong_port_occupant_proof(port, pre_kill_live) + var targets: Array[int] = [] + targets.assign(proof.get("pids", [])) + if targets.is_empty(): + return false + + print("MCP | strong proof: %s" % str(proof.get("proof", ""))) + var killed: Array = _host._kill_processes_and_windows_spawn_children(targets) + if not killed.is_empty(): + print("MCP | killed pids %s on port %d" % [str(killed), port]) + _host._wait_for_port_free(port, wait_s) + if bool(_host._is_port_in_use(port)): + return false + + _host._clear_managed_server_record() + _host._clear_pid_file() + return true + + +func stop_server() -> void: + _host._stop_server_watch() + if int(_server_pid) <= 0: + transition_state(McpServerStateScript.STOPPED) + return + transition_state(McpServerStateScript.STOPPING) + ## Kill the tracked PID AND the real Python PID — they differ for the + ## uvx tier (the launcher exits before its child) and on Windows + ## `OS.kill` is `TerminateProcess` which doesn't walk the child tree. + var port := ClientConfigurator.http_port() + var killed: Array = [] + var candidates: Array[int] = [int(_server_pid)] + var real_pid := int(_host._find_managed_pid(port)) + ## Add the real Python PID only if it isn't already tracked and proves out + ## as ours — re-appending an already-present PID just produces a duplicate + ## kill candidate. + if real_pid > 0 and not candidates.has(real_pid) and _host._pid_cmdline_is_godot_ai_for_proof(real_pid): + candidates.append(real_pid) + var listener_pids: Array = _host._find_all_pids_on_port(port) + for pid in listener_pids: + var listener_pid := int(pid) + if candidates.has(listener_pid): + continue + if _host._pid_cmdline_is_godot_ai_for_proof(listener_pid): + candidates.append(listener_pid) + killed = _host._kill_processes_and_windows_spawn_children(candidates) + if not killed.is_empty(): + print("MCP | stopped server (PID %s)" % str(killed)) + _server_pid = -1 + _host._wait_for_port_free(port, 2.0) + ## Preserve record/pid-file when port is still held — the drift + ## branch on the next start_server retries the kill (#159 follow-up). + _host._finalize_stop_if_port_free(port) + transition_state(McpServerStateScript.STOPPED) + + ## Server's `_pydantic_core.pyd` hard-link is now released — sweep + ## stale uvx builds before they trip the next `uvx mcp-proxy`. + UvCacheCleanup.purge_stale_builds() + + +## Kill the server, reset the re-entrancy guard so the re-enabled plugin +## spawns fresh (#132). User-mode only kills via strong proof. +func prepare_for_update_reload() -> void: + stop_server() + _host._server_started_this_session = false + if ClientConfigurator.is_dev_checkout(): + return + + var port := ClientConfigurator.http_port() + if not bool(_host._is_port_in_use(port)): + return + + var proof: Dictionary = _host._evaluate_strong_port_occupant_proof(port) + var targets: Array[int] = [] + targets.assign(proof.get("pids", [])) + if targets.is_empty(): + return + + _host._kill_processes_and_windows_spawn_children(targets) + _host._wait_for_port_free(port, 3.0) + if not bool(_host._is_port_in_use(port)): + _host._clear_managed_server_record() + _host._clear_pid_file() + + +# ---- Recovery click ---------------------------------------------------- + +## Returns true when a pure-state probe says recovery is allowed: +## current state is INCOMPATIBLE, the port is still held, and we have +## proof of ownership over the occupant. Pure-state in the sense that +## nothing is killed — that's `recover_incompatible_server`. +func can_recover_incompatible_server() -> bool: + if _server_state != McpServerStateScript.INCOMPATIBLE: + return false + var port := ClientConfigurator.http_port() + if not bool(_host._is_port_in_use(port)): + return false + var proof: Dictionary = _host._evaluate_recovery_port_occupant_proof(port) + return not str(proof.get("proof", "")).is_empty() + + +func recover_incompatible_server() -> bool: + if _server_state != McpServerStateScript.INCOMPATIBLE: + return false + + var port := ClientConfigurator.http_port() + var proof: Dictionary = _host._evaluate_recovery_port_occupant_proof(port) + var targets: Array[int] = [] + targets.assign(proof.get("pids", [])) + if targets.is_empty(): + return false + print("MCP | proof: %s" % str(proof.get("proof", ""))) + + ## Move into STOPPING so the post-kill respawn passes the + ## first-writer-wins guards. + transition_state(McpServerStateScript.STOPPING) + var killed: Array = _host._kill_processes_and_windows_spawn_children(targets) + if not killed.is_empty(): + print("MCP | killed pids %s on port %d" % [str(killed), port]) + _host._wait_for_port_free(port, 5.0) + if _host._is_port_in_use(port): + ## Kill failed; re-latch INCOMPATIBLE so the dock keeps the + ## diagnostic UI. + transition_state(McpServerStateScript.INCOMPATIBLE) + return false + + UvCacheCleanup.purge_stale_builds() + _host._clear_managed_server_record() + _host._clear_pid_file() + transition_state(McpServerStateScript.STOPPED) + _connection_blocked = false + _server_status_message = "" + _server_actual_version = "" + _server_actual_name = "" + _can_recover_incompatible = false + _host._server_started_this_session = false + _server_pid = -1 + start_server() + return true + + +## Restart authorisation — a live PID means we spawned/adopted, a +## non-empty managed record is the cross-session proof used by the +## drift branch. +func can_restart_managed_server() -> bool: + if _server_pid > 0: + return true + var record: Dictionary = _host._read_managed_server_record() + return not str(record.get("version", "")).is_empty() + + +func has_managed_server() -> bool: + return _server_pid > 0 + + +## Reset state for a force-restart. Drops the managed record, clears +## the pid-file, and resets the spawn guard so the follow-up +## `start_server()` walks the spawn arm. +func reset_for_force_restart() -> void: + _host._clear_managed_server_record() + _host._clear_pid_file() + _host._server_started_this_session = false + _server_pid = -1 + transition_state(McpServerStateScript.UNINITIALIZED) + + +## Ownership-checked kill of the port occupant + respawn. Driven from +## the dock's "Restart Server" button when the plugin adopted a foreign +## server whose version drifted from the plugin. +func force_restart_server() -> void: + if not can_restart_managed_server(): + push_warning("MCP | refusing to kill server on port %d without managed-server ownership proof" + % ClientConfigurator.http_port()) + return + var port := ClientConfigurator.http_port() + ## Kill every LISTENER on the port, not just the first one. A dev + ## server run via `uvicorn --reload` owns port 8000 through both a + ## reloader parent AND a worker child — killing only one (or zero, + ## if the single-pid parse fell over on multi-line lsof output) leaves + ## the other holding the port past `_wait_for_port_free`'s window. + transition_state(McpServerStateScript.STOPPING) + _host._kill_processes_and_windows_spawn_children(_host._find_all_pids_on_port(port)) + _host._wait_for_port_free(port, 5.0) + if _host._is_port_in_use(port): + ## Kill failed; clean baseline for the follow-up + ## `_set_incompatible_server`. + transition_state(McpServerStateScript.UNINITIALIZED) + _set_incompatible_server( + _host._probe_live_server_status_for_port(port), + _expected_server_version(), + port + ) + return + ## Same rationale as `stop_server`: the server child python just + ## released its `pydantic_core` mapping, so this is the only window in + ## which the hard-linked copies under `builds-v0\.tmp*` are deletable. + ## Sweep before respawning so the upcoming `uvx mcp-proxy` build doesn't + ## inherit the same cleanup-failure path that triggered the restart. + UvCacheCleanup.purge_stale_builds() + reset_for_force_restart() + start_server() diff --git a/addons/godot_ai/utils/server_lifecycle.gd.uid b/addons/godot_ai/utils/server_lifecycle.gd.uid new file mode 100644 index 0000000..8e62667 --- /dev/null +++ b/addons/godot_ai/utils/server_lifecycle.gd.uid @@ -0,0 +1 @@ +uid://bwfx8b0w2mgf6 diff --git a/addons/godot_ai/utils/server_version_check.gd b/addons/godot_ai/utils/server_version_check.gd new file mode 100644 index 0000000..db9c839 --- /dev/null +++ b/addons/godot_ai/utils/server_version_check.gd @@ -0,0 +1,136 @@ +@tool +class_name McpServerVersionCheck +extends RefCounted + +## Standalone polling seam for the post-connection server-version +## handshake gate. Extracted from `plugin.gd` so the lifecycle manager +## stays focused on spawn/adopt/stop and the version-verify dance has +## its own home. +## +## The seam itself does NOT transition `McpServerState` on arm/disarm — +## the version check runs concurrently with whatever spawn-state the +## caller had latched (typically FOREIGN_PORT during adoption +## confirmation, or no-op directly to READY for a fresh spawn). Result +## transitions land on the manager via `handle_server_version_verified` +## (READY / INCOMPATIBLE) or `handle_server_version_unverified` +## (INCOMPATIBLE on deadline expiry); arm() leaves the state alone so a +## FOREIGN_PORT diagnosis isn't accidentally cleared before the +## handshake actually arrives. +## +## Owns the deadline timer (`_deadline_ms`) and requires the manager to +## feed it `tick(now_msec)` from the plugin's `_process` while +## `is_active()` is true. +## +## Decoupled from the connection's signal surface: `tick()` polls +## `_connection.is_connected` and `_connection.server_version` directly. +## A same-release signal addition plus a new consumer is shape-coupled work +## for old two-phase runners; they can parse the consumer while the +## McpConnection Script object still reflects v(N). We still null-check +## `_connection` because `disarm()` releases it. + +## How long to wait after the WebSocket opens before declaring the +## handshake_ack overdue. Mirrors `plugin.gd::SERVER_HANDSHAKE_VERSION_TIMEOUT_MS` +## — kept at this layer so the version-check seam is self-contained. +const TIMEOUT_MS := 5 * 1000 + +## Untyped on purpose for the same self-update field-storage reason +## plugin.gd's fields are untyped. `_connection` is the live +## `McpConnection`; `_manager` is `McpServerLifecycleManager`. +## `_connection` is null between disarm() and the next arm() — the +## seam can spend most of the plugin's life dormant and we don't want +## to pin a Node that may be queue_freed in `_exit_tree`. `_manager` is +## set once at construction and held for the seam's lifetime (the +## manager owns this instance, so the cycle is short). +var _connection +var _manager +var _active: bool = false +var _deadline_ms: int = 0 +var _expected_version: String = "" + + +func _init(manager) -> void: + _manager = manager + + +## Arm the version-check. Marks the seam active, (re)attaches the +## connection it should poll, and starts watching for +## `_connection.server_version`. Does NOT transition manager state — +## the version check runs concurrently with whatever spawn-state was +## latched (e.g. FOREIGN_PORT during adoption confirmation, READY for +## a fresh spawn). Result transitions land on the manager via +## `handle_server_version_verified` / `_unverified` once the handshake +## (or its deadline) lands. +## +## The deadline starts the moment the connection actually opens, not at +## arm-time, because uvx cold-starts can take ~30s to bind the +## WebSocket and we don't want to count that against the handshake. +func arm(connection, expected_version: String) -> void: + _active = true + _deadline_ms = 0 + _expected_version = expected_version + _connection = connection + + +## Disarm without firing a verdict. Used when the manager moves on +## (e.g. recovery click → STOPPING). Releases the connection / +## manager references so the seam doesn't pin them past the active +## window — the plugin can spend most of its life with the version +## check disarmed, and `_connection` is a Node that may be queue_free'd +## by `_exit_tree`. Caller has already transitioned state, so we don't +## touch the manager. +func disarm() -> void: + _active = false + _deadline_ms = 0 + _connection = null + + +## True while the version-check needs `_process` ticks. Plugin uses +## this to gate `set_process(true)`. +func is_active() -> bool: + return _active + + +## Per-frame tick from the plugin's `_process`. No-op when disarmed. +## Returns true when the check finished this tick (verified or +## unverified) so the plugin can re-evaluate `set_process` enable. +func tick(now_msec: int) -> bool: + if not _active: + return false + if _connection == null: + return false + if not bool(_connection.is_connected): + return false + if _deadline_ms == 0: + _deadline_ms = now_msec + TIMEOUT_MS + var server_version := str(_connection.server_version) + if not server_version.is_empty(): + _complete_with_version(server_version) + return true + if now_msec >= _deadline_ms: + _complete_unverified() + return true + return false + + +## Invoked when `_on_connection_established` notices that we transitioned +## out of FOREIGN_PORT — the server may yet prove itself compatible. +## Re-arming is idempotent: if already active, no-op; otherwise the +## caller's connection + last-known expected version are reused. +func rearm_for_foreign_port_recovery(connection) -> void: + if _active: + return + arm(connection, _expected_version) + + +func _complete_with_version(version: String) -> void: + _active = false + _deadline_ms = 0 + if _manager != null: + _manager.handle_server_version_verified(_expected_version, version) + + +func _complete_unverified() -> void: + _active = false + _deadline_ms = 0 + if _manager != null: + _manager.handle_server_version_unverified(_expected_version) diff --git a/addons/godot_ai/utils/server_version_check.gd.uid b/addons/godot_ai/utils/server_version_check.gd.uid new file mode 100644 index 0000000..7baacfd --- /dev/null +++ b/addons/godot_ai/utils/server_version_check.gd.uid @@ -0,0 +1 @@ +uid://ciqldbuaq8i8u diff --git a/addons/godot_ai/utils/settings.gd b/addons/godot_ai/utils/settings.gd new file mode 100644 index 0000000..8140580 --- /dev/null +++ b/addons/godot_ai/utils/settings.gd @@ -0,0 +1,39 @@ +@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" + + +## Returns true if the string value is truthy +## ("1", "true", "yes", "on", case-insensitive, whitespace-trimmed). +static func truthy(value: String) -> bool: + return value.strip_edges().to_lower() in ["1", "true", "yes", "on"] + + +## Returns true if the named environment variable is set to a truthy value. +static func env_truthy(var_name: String) -> bool: + return truthy(OS.get_environment(var_name)) + + +## Returns true if telemetry should be active, checking in priority order: +## 1. GODOT_AI_DISABLE_TELEMETRY / DISABLE_TELEMETRY env vars +## 2. The godot_ai/telemetry_enabled EditorSetting written by the dock UI +## Defaults to true when neither source has set a preference. +static func telemetry_enabled() -> bool: + if env_truthy("GODOT_AI_DISABLE_TELEMETRY") or env_truthy("DISABLE_TELEMETRY"): + return false + var es := EditorInterface.get_editor_settings() + if es != null and es.has_setting(SETTING_TELEMETRY_ENABLED): + return bool(es.get_setting(SETTING_TELEMETRY_ENABLED)) + return true 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/update_manager.gd b/addons/godot_ai/utils/update_manager.gd new file mode 100644 index 0000000..79ba21e --- /dev/null +++ b/addons/godot_ai/utils/update_manager.gd @@ -0,0 +1,443 @@ +@tool +class_name McpUpdateManager +extends Node + +## Self-update manager for pre-runner work. Owns release checks, HTTP ZIP +## download, the install-in-flight gate, and install state signals back to +## the dock. Once `_install_zip()` calls +## `plugin.gd::install_downloaded_update(...)`, ownership transfers to +## `update_reload_runner.gd`, which owns extract, scan, plugin re-enable, +## and detached-dock cleanup. +## +## The dock owns banner rendering and forwards button clicks. The split +## exists because the dock script is one of the files overwritten on disk +## during install — keeping pipeline state on a separate Node lets the dock +## tear down cleanly without losing the in-flight gate that other dock spawn +## paths consult. +## +## `class_name McpUpdateManager` is retained because it shipped in a +## published release. If this class is ever retired, follow CLAUDE.md's +## never-delete-published-class_name shim policy instead of deleting the +## declaration. +## +## `_plugin` and `_dock` are deliberately untyped: the same self-update +## window that overwrites this script also overwrites the dock and plugin +## scripts, and a static-typed reference into a script being hot-reloaded +## crashes inside `GDScriptFunction::call`. `server_lifecycle.gd` follows +## the same convention. + +const RELEASES_URL := ( + "https://api.github.com/repos/hi-godot/godot-ai/releases/latest" +) +const RELEASES_PAGE := "https://github.com/hi-godot/godot-ai/releases/latest" +const UPDATE_TEMP_DIR := "user://godot_ai_update/" +const UPDATE_TEMP_ZIP := "user://godot_ai_update/update.zip" +const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd") + +## Emitted after `check_for_updates()` resolves a newer remote version. +## Payload mirrors the Dictionary returned by `parse_releases_response`: +## {has_update, version, forced, label_text, download_url} +signal update_check_completed(result: Dictionary) + +## Emitted at every UI-relevant step of the install pipeline. Payload +## keys are all optional and apply on top of the current banner state: +## label_text: String ## banner label override +## button_text: String ## update button text override +## button_disabled: bool ## update button disabled state +## banner_visible: bool ## banner visibility override +## outcome: String ## "success" -> dock paints green +signal install_state_changed(state: Dictionary) + +var _plugin +var _dock + +var _http_request: HTTPRequest +var _download_request: HTTPRequest +var _latest_download_url: String = "" + +## Set for the duration of `_install_zip` — extract-overwrite of plugin +## scripts on disk would crash any worker mid-`GDScriptFunction::call` +## (confirmed via SIGABRT in the dock's refresh worker). Dock spawn paths +## consult this via `is_install_in_flight()`; in-flight workers are +## drained before any disk write. +var _install_in_flight: bool = false + + +# ---- Setup ------------------------------------------------------------- + +func setup(plugin, dock) -> void: + _plugin = plugin + _dock = dock + + +# ---- Public API --------------------------------------------------------- + +## Kick off the GitHub Releases API check. No-ops in dev checkouts — +## `addons/godot_ai/` is a symlink into canonical `plugin/` source there, +## and an extract would clobber tracked files (#116). `is_dev_checkout()` +## honours the mode override (dock dropdown > GODOT_AI_MODE env), so +## testers can force `user` to exercise the AssetLib flow from a dev tree; +## `_install_zip` still gates on the physical symlink check so a forced- +## user mode can never clobber source. +func check_for_updates() -> void: + if ClientConfigurator.is_dev_checkout(): + return + if _http_request == null: + _http_request = HTTPRequest.new() + _http_request.request_completed.connect(_on_update_check_completed) + add_child(_http_request) + _http_request.request(RELEASES_URL, ["Accept: application/vnd.github+json"]) + + +## Cancel any in-flight check. The dock calls this before re-issuing a +## check after a mode-override flip — without the cancel, `request()` +## returns ERR_BUSY and the dropdown change silently fails to repaint. +func cancel_check() -> void: + if _http_request != null: + _http_request.cancel_request() + + +## Reset the cached download URL. The dock calls this on mode-override +## flips so a fresh check paints over a clean banner. +func clear_pending_download() -> void: + _latest_download_url = "" + + +## True when the running Godot can self-update in place. Godot < 4.4 takes +## the `_install_zip_inline` extract-then-restart path, and that engine's +## stricter `GDScript::reload()` (`!p_keep_state && has_instances` -> +## `ERR_ALREADY_IN_USE`) turns the extract-over-live-scripts into a reload +## error flood plus a SIGSEGV in `EditorDockManager::remove_dock` / +## `SceneTree::finalize` on the restart/quit (#475). So on < 4.4 we don't +## run the in-editor pipeline at all — the user updates manually. +## Guards `major` too so a future Godot 5.x (minor 0) isn't misclassified. +func _can_self_update() -> bool: + var v := Engine.get_version_info() + return _version_can_self_update(int(v.get("major", 0)), int(v.get("minor", 0))) + + +## Pure version predicate, split out so it's testable without faking the +## running engine. In-editor self-update needs Godot >= 4.4. +static func _version_can_self_update(major: int, minor: int) -> bool: + return major > 4 or (major == 4 and minor >= 4) + + +## Banner guidance for the gated (< 4.4) path. Shown up-front at check time +## (with the available version) and again on click, so the user understands +## the manual-update flow before they press anything. Single source of truth +## so check-time and click-time text never drift. +static func _manual_update_label(version: String) -> String: + var prefix := "Update available" + if not version.is_empty(): + prefix = "Update v%s available" % version + return ( + prefix + + " — in-editor update needs Godot 4.4+. Open the download page, then " + + "replace addons/godot_ai/ manually and relaunch." + ) + + +## Driven by the dock's Update button. On Godot < 4.4 (see `_can_self_update`) +## the in-editor install is disabled — we open the release page for a manual +## download instead, never entering the extract pipeline that crashes those +## engines. With no resolved download URL — either the check never completed, +## or the release didn't ship a matching asset — also falls back to opening +## the release page. Otherwise kicks off the download → extract → reload +## pipeline. +func start_install() -> void: + if not _can_self_update(): + ## Only claim success + lock the button if the browser actually opened. + ## On failure (no handler, headless) keep the button enabled so the + ## user can retry. Either way, leave the version-bearing guidance label + ## from check time in place — don't re-emit label_text. + if OS.shell_open(RELEASES_PAGE) == OK: + install_state_changed.emit({ + "button_text": "Opened download page", + "button_disabled": true, + }) + else: + install_state_changed.emit({ + "button_text": "Couldn't open browser — retry", + "button_disabled": false, + }) + return + + if _latest_download_url.is_empty(): + OS.shell_open(RELEASES_PAGE) + return + + install_state_changed.emit({ + "button_text": "Downloading...", + "button_disabled": true, + }) + + if _download_request != null: + _download_request.queue_free() + _download_request = HTTPRequest.new() + var global_zip := ProjectSettings.globalize_path(UPDATE_TEMP_ZIP) + var global_dir := ProjectSettings.globalize_path(UPDATE_TEMP_DIR) + DirAccess.make_dir_recursive_absolute(global_dir) + _download_request.download_file = global_zip + _download_request.max_redirects = 10 + _download_request.request_completed.connect(_on_download_completed) + add_child(_download_request) + var err := _download_request.request(_latest_download_url) + if err != OK: + ## `request_completed` never fires when `request()` itself errors, + ## so cleanup (queue_free + null + drop the staged zip) has to land + ## inline — otherwise the HTTPRequest stays parented under the + ## manager until the next click. + _download_request.queue_free() + _download_request = null + DirAccess.remove_absolute(global_zip) + install_state_changed.emit({ + "button_text": "Request failed", + "button_disabled": false, + }) + + +## Consulted by the dock's spawn paths (focus-in refresh, manual button, +## deferred initial refresh) — true while plugin scripts are being +## overwritten. A worker mid-`GDScriptFunction::call` into a half- +## overwritten script SIGABRTs the editor. +func is_install_in_flight() -> bool: + return _install_in_flight + + +# ---- Releases-API parse (pure, testable) ------------------------------- + +## Parses the GitHub Releases API JSON response. Returns: +## has_update: bool ## true if remote tag > local version +## version: String ## remote tag minus leading "v" +## forced: bool ## mode_override() == "user" (banner-only hint) +## label_text: String ## "Update available: vX.Y.Z" + " (forced)" +## download_url: String ## matching `godot-ai-plugin.zip` asset URL +## +## Static so tests drive it without instancing the manager. +static func parse_releases_response( + result: int, response_code: int, body: PackedByteArray +) -> Dictionary: + var out := { + "has_update": false, + "version": "", + "forced": false, + "label_text": "", + "download_url": "", + } + if result != HTTPRequest.RESULT_SUCCESS or response_code != 200: + return out + var parsed = JSON.parse_string(body.get_string_from_utf8()) + if parsed == null or not (parsed is Dictionary): + return out + var json: Dictionary = parsed + var tag: String = String(json.get("tag_name", "")) + if tag.is_empty(): + return out + var remote_version := tag.trim_prefix("v") + var local_version := ClientConfigurator.get_plugin_version() + if not _is_newer(remote_version, local_version): + return out + + var url := "" + var assets: Array = json.get("assets", []) + for asset in assets: + var asset_dict: Dictionary = asset + if String(asset_dict.get("name", "")) == "godot-ai-plugin.zip": + url = String(asset_dict.get("browser_download_url", "")) + break + + var forced := ClientConfigurator.mode_override() == "user" + var label_text := "Update available: v%s" % remote_version + if forced: + ## Forced-user mode (dropdown or env) is the only way the banner + ## lights up in a dev tree; suffix so the operator notices. + label_text += " (forced)" + + out["has_update"] = true + out["version"] = remote_version + out["forced"] = forced + out["label_text"] = label_text + out["download_url"] = url + return out + + +static func _is_newer(remote: String, local: String) -> bool: + var r := remote.split(".") + var l := local.split(".") + for i in range(max(r.size(), l.size())): + var rv := int(r[i]) if i < r.size() else 0 + var lv := int(l[i]) if i < l.size() else 0 + if rv > lv: + return true + if rv < lv: + return false + return false + + +# ---- HTTPRequest callbacks (instance-side) ----------------------------- + +func _on_update_check_completed( + result: int, + response_code: int, + _headers: PackedStringArray, + body: PackedByteArray +) -> void: + var parsed := parse_releases_response(result, response_code, body) + if not bool(parsed.get("has_update", false)): + return + _latest_download_url = String(parsed.get("download_url", "")) + update_check_completed.emit(parsed) + ## On engines that can't self-update (Godot < 4.4, #475), surface the + ## full manual-update guidance AND relabel the button up-front — before + ## any click — so the user knows what the button does and why. + if not _can_self_update(): + install_state_changed.emit({ + "button_text": "Open download page", + "label_text": _manual_update_label(String(parsed.get("version", ""))), + }) + + +func _on_download_completed( + result: int, + response_code: int, + _headers: PackedStringArray, + _body: PackedByteArray +) -> void: + if _download_request != null: + _download_request.queue_free() + _download_request = null + + if result != HTTPRequest.RESULT_SUCCESS or response_code != 200: + print("MCP | update download failed: result=%d code=%d" % [result, response_code]) + install_state_changed.emit({ + "button_text": "Download failed (%d)" % response_code, + "button_disabled": false, + }) + return + + install_state_changed.emit({"button_text": "Installing..."}) + # Deferred so the HTTPRequest callback returns before the extract starts. + _install_zip.call_deferred() + + +# ---- Install orchestration --------------------------------------------- + +func _install_zip() -> void: + ## Symlinked addons dir means an extract would clobber canonical + ## `plugin/` source through the link. Symlink detection is independent + ## of the mode override: even forced-user aborts here. See #116. + if ClientConfigurator.addons_dir_is_symlink(): + install_state_changed.emit({ + "button_text": "Dev checkout — update via git", + "button_disabled": true, + "banner_visible": false, + }) + return + + ## Drain in-flight workers + block new ones BEFORE any disk write. + ## Without this, focus-in landing in the extract→reload window spawns + ## a worker that walks into a partially-overwritten script and + ## SIGABRTs in `GDScriptFunction::call`. + _install_in_flight = true + _drain_dock_workers() + + var version := Engine.get_version_info() + var has_runner: bool = ( + _plugin != null + and _plugin.has_method("install_downloaded_update") + ) + ## Same major-aware predicate as the _can_self_update() gate, so a future + ## Godot 5.x (minor 0) takes the runner path the gate promised — not the + ## pre-4.4 inline extract. A bare `minor >= 4` here would route 5.0 to the + ## crash-prone inline path even though the gate let it in. + if _version_can_self_update(int(version.get("major", 0)), int(version.get("minor", 0))) and has_runner: + install_state_changed.emit({"button_text": "Reloading..."}) + ## Runner takes over: plugin tears down, runner extracts + scans + + ## re-enables. `install_downloaded_update` calls + ## `prepare_for_update_reload()` internally (kills the server, + ## resets the spawn guard) — see plugin.gd::install_downloaded_update. + _plugin.install_downloaded_update(UPDATE_TEMP_ZIP, UPDATE_TEMP_DIR, _dock) + return + + _install_zip_inline(version) + + +func _install_zip_inline(version: Dictionary) -> void: + ## Pre-4.4 fallback. EditorInterface.set_plugin_enabled off/on is + ## re-entry-unsafe on older Godot; we extract in-process and ask the + ## user to restart. + var zip_path := ProjectSettings.globalize_path(UPDATE_TEMP_ZIP) + var install_base := ProjectSettings.globalize_path("res://") + + var reader := ZIPReader.new() + if reader.open(zip_path) != OK: + _install_in_flight = false + install_state_changed.emit({ + "button_text": "Extract failed", + "button_disabled": false, + }) + return + + var files := reader.get_files() + for file_path in files: + if not file_path.begins_with("addons/godot_ai/"): + continue + if file_path.ends_with("/"): + DirAccess.make_dir_recursive_absolute(install_base.path_join(file_path)) + else: + var dir := file_path.get_base_dir() + DirAccess.make_dir_recursive_absolute(install_base.path_join(dir)) + var content := reader.read_file(file_path) + var f := FileAccess.open(install_base.path_join(file_path), FileAccess.WRITE) + if f != null: + f.store_buffer(content) + f.close() + + reader.close() + + DirAccess.remove_absolute(zip_path) + DirAccess.remove_absolute(ProjectSettings.globalize_path(UPDATE_TEMP_DIR)) + + ## Kill the old server before the reload so the re-enabled plugin spawns + ## a fresh one against the new plugin version (#132). + if _plugin != null and _plugin.has_method("prepare_for_update_reload"): + _plugin.prepare_for_update_reload() + + if _version_can_self_update(int(version.get("major", 0)), int(version.get("minor", 0))): + install_state_changed.emit({"button_text": "Scanning..."}) + ## Filesystem scan must complete before plugin reload — otherwise + ## plugin.gd re-parses against a ClassDB that hasn't seen the new + ## files yet, parse errors, dock tears down silently. See #127. + var fs := EditorInterface.get_resource_filesystem() + if fs != null: + fs.filesystem_changed.connect( + _on_filesystem_scanned_for_update, CONNECT_ONE_SHOT + ) + fs.scan() + else: + _reload_after_update.call_deferred() + else: + ## Pre-4.4: no plugin reload; refreshes resume on the old dock + ## instance until the user restarts. + _install_in_flight = false + install_state_changed.emit({ + "button_text": "Restart editor to apply", + "button_disabled": true, + "label_text": "Updated! Restart the editor.", + "outcome": "success", + }) + + +func _on_filesystem_scanned_for_update() -> void: + install_state_changed.emit({"button_text": "Reloading..."}) + _reload_after_update.call_deferred() + + +func _reload_after_update() -> void: + EditorInterface.set_plugin_enabled("res://addons/godot_ai/plugin.cfg", false) + EditorInterface.set_plugin_enabled("res://addons/godot_ai/plugin.cfg", true) + + +func _drain_dock_workers() -> void: + if _dock != null and _dock.has_method("prepare_for_self_update_drain"): + _dock.prepare_for_self_update_drain() diff --git a/addons/godot_ai/utils/update_manager.gd.uid b/addons/godot_ai/utils/update_manager.gd.uid new file mode 100644 index 0000000..4089a72 --- /dev/null +++ b/addons/godot_ai/utils/update_manager.gd.uid @@ -0,0 +1 @@ +uid://cegiyw3fjcwev diff --git a/addons/godot_ai/utils/update_mixed_state.gd b/addons/godot_ai/utils/update_mixed_state.gd new file mode 100644 index 0000000..96d0024 --- /dev/null +++ b/addons/godot_ai/utils/update_mixed_state.gd @@ -0,0 +1,140 @@ +@tool +extends RefCounted + +## Scanner that detects whether `addons/godot_ai/` is in a half-installed +## state left behind by a self-update whose rollback couldn't restore the +## previous addon contents (`UpdateReloadRunner.InstallStatus.FAILED_MIXED`). +## +## Without this surface the user sees "plugin won't start" with no actionable +## context, re-runs the update, and compounds the mismatch (issue #354 / +## audit-v2 #10). The dock paints a banner from `diagnose()` and +## `editor_handler.gd::get_editor_state` includes the same Dictionary so an +## MCP agent can see and report the state. + +const ADDON_DIR := "res://addons/godot_ai/" +## Producer is `update_reload_runner.gd::INSTALL_BACKUP_SUFFIX`. Inlined as a +## literal because old two-phase runners can parse this diagnostic script +## against stale runner Script-object content during their mixed-snapshot +## scan. `test_update_backup_suffix_stays_in_sync` guards against drift. +const BACKUP_SUFFIX := ".update_backup" +## Cap so a runaway addons tree (someone parented the wrong dir, an old +## crashed install left thousands of artifacts) can't blow the +## `editor_state` payload size or freeze the editor on first paint. +const MAX_BACKUP_RESULTS := 200 +## TTL for the `diagnose()` cache. `editor_state` is one of the highest- +## traffic MCP tools (agents poll it constantly) and a recursive +## `DirAccess` walk on every call would put I/O on the 4ms `_process()` +## budget. Mixed-state is rare and persistent across editor restarts, so +## a few seconds of staleness is acceptable; the dock's Re-scan button +## bypasses the cache via `force=true` for immediate feedback. +const CACHE_TTL_MSEC := 5000 + +static var _cache_value: Dictionary = {} +static var _cache_timestamp_msec: int = -1 + + +## Walk `dir` recursively and return every `res://`-relative path that ends +## in `.update_backup`, sorted ascending. Truncates at `MAX_BACKUP_RESULTS` +## — the truncation flag is exposed via `diagnose()`. +## +## Walk order is deterministic: entries within each directory are sorted +## alphabetically, subdirs pushed reverse-sorted so DFS pops them in +## ascending order. Without this two scans of the same mixed tree could +## return different 200-file slices when truncation kicks in (Godot's +## `list_dir` order isn't guaranteed stable across filesystems). +static func find_backups(dir: String = ADDON_DIR) -> Array: + var results: Array = [] + var stack: Array = [dir] + while not stack.is_empty(): + if results.size() >= MAX_BACKUP_RESULTS: + break + var current: String = stack.pop_back() + var d := DirAccess.open(current) + ## Missing dir, permission error, or unreadable junction — skip + ## silently. A missing addons dir is the bare-clone case; mid-walk + ## errors stay quiet so a single permission glitch can't block the + ## diagnostic the rest of the scan would have produced. + if d == null: + continue + var entries: Array = [] + d.list_dir_begin() + while true: + var entry := d.get_next() + if entry.is_empty(): + break + if entry == "." or entry == "..": + continue + entries.append({"name": entry, "is_dir": d.current_is_dir()}) + d.list_dir_end() + entries.sort_custom(func(a, b): return a["name"] < b["name"]) + ## Push subdirs reverse-sorted so the next outer iteration pops + ## them in ascending order — see method docstring for why this + ## determinism matters for the truncated case. + for i in range(entries.size() - 1, -1, -1): + var entry: Dictionary = entries[i] + if entry["is_dir"]: + stack.append(current.path_join(entry["name"])) + for entry in entries: + if entry["is_dir"]: + continue + if not String(entry["name"]).ends_with(BACKUP_SUFFIX): + continue + results.append(current.path_join(entry["name"])) + if results.size() >= MAX_BACKUP_RESULTS: + break + results.sort() + return results + + +## Build the structured diagnostic Dictionary surfaced via `editor_state` +## and the dock banner. Empty when the addons tree is clean — callers +## gate banner visibility / response field on `is_empty()`. +## +## Cached for `CACHE_TTL_MSEC` when scanning the default `ADDON_DIR` so +## per-`editor_state` polls don't re-walk the addons tree every frame. +## Tests passing a custom `dir` always see a fresh scan (cache only +## tracks the production path). `force=true` bypasses the cache — used +## by the dock's Re-scan button so a manual fix is reflected immediately. +static func diagnose(dir: String = ADDON_DIR, force: bool = false) -> Dictionary: + var use_cache := dir == ADDON_DIR and not force + if use_cache and _cache_timestamp_msec >= 0: + if Time.get_ticks_msec() - _cache_timestamp_msec < CACHE_TTL_MSEC: + return _cache_value.duplicate(true) + + var backups := find_backups(dir) + var result: Dictionary = {} + if not backups.is_empty(): + ## Most commonly produced by `_rollback_paths_written` returning + ## FAILED_MIXED, but `_finalize_install_success` removes backups on + ## a best-effort basis so a successful install can also leave them + ## behind if the cleanup `remove_absolute` hit a permission error. + ## The recovery action — delete the *.update_backup files — is the + ## same in both cases, so the message acknowledges both + ## possibilities rather than asserting the alarming one. + result = { + "addon_dir": dir, + "backup_files": backups, + "backup_count": backups.size(), + "truncated": backups.size() >= MAX_BACKUP_RESULTS, + "message": ( + "Found .update_backup files in addons/godot_ai/. This usually" + + " means a self-update rollback couldn't restore the previous" + + " addon contents (FAILED_MIXED) — the plugin may load a mix" + + " of old and new files. Restore the addon from your VCS or a" + + " fresh release ZIP, then delete the listed *.update_backup" + + " files. If the plugin runs without issues these are likely" + + " stale from a successful install and safe to delete." + ), + } + if use_cache: + _cache_value = result.duplicate(true) + _cache_timestamp_msec = Time.get_ticks_msec() + return result + + +## Reset the `diagnose()` cache. Tests that flip the addons-tree state +## between calls use this to avoid TTL-bound flakiness; the dock's +## Re-scan button uses `force=true` instead. +static func clear_cache() -> void: + _cache_value = {} + _cache_timestamp_msec = -1 diff --git a/addons/godot_ai/utils/update_mixed_state.gd.uid b/addons/godot_ai/utils/update_mixed_state.gd.uid new file mode 100644 index 0000000..6d608d9 --- /dev/null +++ b/addons/godot_ai/utils/update_mixed_state.gd.uid @@ -0,0 +1 @@ +uid://dd5rti52vgs71 diff --git a/addons/godot_ai/utils/uv_cache_cleanup.gd b/addons/godot_ai/utils/uv_cache_cleanup.gd new file mode 100644 index 0000000..eaa82ed --- /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 Claude Desktop's MCP launcher invokes `uvx mcp-proxy ...` to talk to +## a running godot-ai server, uv builds an ephemeral venv under +## `builds-v0\.tmpXXXXXX\`. To save disk it hard-links shared C extensions +## (notably `pydantic_core/_pydantic_core.cp313-win_amd64.pyd`) from +## `archive-v0\\Lib\site-packages\...` into the build venv. +## +## If the godot-ai server's own Python child has that same `.pyd` mapped via +## `LoadLibrary` (it does — godot-ai imports pydantic), the file is locked +## under BOTH paths because hard links share the inode and Windows tracks +## handles per-file, not per-path. uv's post-install cleanup of the build +## venv then dies with: +## +## Failed to install: pywin32-311-cp313-cp313-win_amd64.whl (pywin32==311) +## Caused by: failed to remove directory `...\.tmpXXXXXX\Lib\site-packages\pywin32-311.data` +## 다른 프로세스가 파일을 사용 중이기 때문에 ... (os error 32) +## +## (the `pywin32` mention is incidental — the actual lock is on the earlier +## hard-linked `_pydantic_core.pyd`; pywin32 is just the last install step +## in the wheel-resolution order that triggers the cleanup pass). +## +## What this does +## -------------- +## After the plugin stops/restarts the managed server — i.e. the moment when +## the archive-v0 `.pyd` mappings drop and the hard-linked builds-v0 copy +## becomes deletable — sweep `builds-v0\` for `.tmp*` orphans: +## +## 1. Rename each `.tmpXXX` to `_dead_.tmpXXX`. Rename succeeds even when +## AV scanners hold the file open without `FILE_SHARE_DELETE` (Defender +## and Softcamp SDS both do this), so this step always advances. +## 2. Recursively remove the renamed dir, swallowing per-file +## access-denied. Anything still genuinely locked is left for the next +## sweep — uv won't reuse the renamed name, so no future build collides. +## +## No-op on non-Windows (uv's hard-link strategy only causes this lock +## pattern on NTFS) and when the cache directory doesn't exist. + +const DEAD_PREFIX := "_dead_" +const TMP_PREFIX := ".tmp" + + +## Live entrypoint. Resolves `%LOCALAPPDATA%\uv\cache\builds-v0` and runs +## the sweep. Returns the same counts the testable `purge_directory` returns, +## or all zeros on non-Windows / missing cache. +static func purge_stale_builds() -> Dictionary: + if OS.get_name() != "Windows": + return _empty_result() + var local_appdata := OS.get_environment("LOCALAPPDATA") + if local_appdata.is_empty(): + return _empty_result() + var builds_root := local_appdata.replace("\\", "/").path_join("uv/cache/builds-v0") + return purge_directory(builds_root) + + +## Pure-ish entrypoint that takes a directory path. Returns +## `{ "scanned": int, "renamed": int, "deleted": int, "remaining": int }`. +## - `scanned`: how many `.tmp*` subdirs we saw on entry. +## - `renamed`: how many we successfully renamed to `_dead_*`. +## - `deleted`: how many we then fully removed. +## - `remaining`: how many `_dead_*` dirs are still on disk after the sweep +## (left for the next call to retry). +## +## Errors are swallowed — the caller is on a server-stop hot path and +## must not raise. +static func purge_directory(builds_root: String) -> Dictionary: + var result := _empty_result() + if not DirAccess.dir_exists_absolute(builds_root): + return result + var dir := DirAccess.open(builds_root) + if dir == null: + return result + dir.include_hidden = true + + ## Pass 1: collect names. Iterating + renaming in the same walk would + ## confuse DirAccess's internal cursor on NTFS. + var tmp_names: Array[String] = [] + var dead_names: Array[String] = [] + dir.list_dir_begin() + var entry := dir.get_next() + while entry != "": + if dir.current_is_dir() and not (entry == "." or entry == ".."): + if entry.begins_with(TMP_PREFIX): + tmp_names.append(entry) + elif entry.begins_with(DEAD_PREFIX): + dead_names.append(entry) + entry = dir.get_next() + dir.list_dir_end() + result.scanned = tmp_names.size() + + ## Pass 2: rename `.tmp*` → `_dead_.tmp*`. Rename works even on + ## AV-locked files (Defender opens without FILE_SHARE_DELETE, but rename + ## doesn't need delete share). Any rename failure is non-fatal. + for name in tmp_names: + var src := builds_root.path_join(name) + var dst := builds_root.path_join(DEAD_PREFIX + name) + if dir.rename(src, dst) == OK: + result.renamed += 1 + dead_names.append(DEAD_PREFIX + name) + + ## Pass 3: best-effort recursive delete of every `_dead_*`, including + ## ones left over from earlier sweeps that couldn't be cleaned then. + for name in dead_names: + var path := builds_root.path_join(name) + if _remove_recursive(path): + result.deleted += 1 + + ## Final pass: count `_dead_*` survivors so the caller (and tests) can + ## see how many genuinely-locked dirs we couldn't reach. + var dir2 := DirAccess.open(builds_root) + if dir2 != null: + dir2.include_hidden = true + dir2.list_dir_begin() + var e := dir2.get_next() + while e != "": + if dir2.current_is_dir() and e.begins_with(DEAD_PREFIX): + result.remaining += 1 + e = dir2.get_next() + dir2.list_dir_end() + + return result + + +## Recursive `rm -rf` that swallows access-denied per-file. Returns true +## only when the target directory itself was removed. +static func _remove_recursive(path: String) -> bool: + var dir := DirAccess.open(path) + if dir == null: + ## Already gone, or unreadable — try a direct remove just in case + ## (an empty dir handle-leak path) and report based on existence. + DirAccess.remove_absolute(path) + return not DirAccess.dir_exists_absolute(path) + dir.include_hidden = true + dir.list_dir_begin() + var entry := dir.get_next() + while entry != "": + if entry == "." or entry == "..": + entry = dir.get_next() + continue + var child := path.path_join(entry) + if dir.current_is_dir(): + _remove_recursive(child) + else: + DirAccess.remove_absolute(child) + entry = dir.get_next() + dir.list_dir_end() + ## Remove the (hopefully now empty) dir itself. If a hard-linked .pyd is + ## still mapped by a surviving process, this fails silently and the + ## caller sees `remaining > 0` so it can retry on the next sweep. + DirAccess.remove_absolute(path) + return not DirAccess.dir_exists_absolute(path) + + +static func _empty_result() -> Dictionary: + return { "scanned": 0, "renamed": 0, "deleted": 0, "remaining": 0 } diff --git a/addons/godot_ai/utils/uv_cache_cleanup.gd.uid b/addons/godot_ai/utils/uv_cache_cleanup.gd.uid new file mode 100644 index 0000000..321659d --- /dev/null +++ b/addons/godot_ai/utils/uv_cache_cleanup.gd.uid @@ -0,0 +1 @@ +uid://d33ukg65qf7q0 diff --git a/addons/godot_ai/utils/variant_serializer.gd b/addons/godot_ai/utils/variant_serializer.gd new file mode 100644 index 0000000..c365370 --- /dev/null +++ b/addons/godot_ai/utils/variant_serializer.gd @@ -0,0 +1,71 @@ +@tool +extends RefCounted + +## Converts Godot Variants into values that can be encoded as JSON. + + +static func serialize(value: Variant) -> Variant: + if value == null: + return null + match typeof(value): + TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING: + return value + TYPE_STRING_NAME: + return str(value) + TYPE_VECTOR2, TYPE_VECTOR2I: + return {"x": value.x, "y": value.y} + TYPE_VECTOR3, TYPE_VECTOR3I: + return {"x": value.x, "y": value.y, "z": value.z} + TYPE_VECTOR4, TYPE_VECTOR4I, TYPE_QUATERNION: + return {"x": value.x, "y": value.y, "z": value.z, "w": value.w} + TYPE_COLOR: + return {"r": value.r, "g": value.g, "b": value.b, "a": 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": value.d} + TYPE_BASIS: + return { + "x": serialize(value.x), + "y": serialize(value.y), + "z": serialize(value.z), + } + TYPE_TRANSFORM2D: + return { + "x": serialize(value.x), + "y": serialize(value.y), + "origin": serialize(value.origin), + } + TYPE_TRANSFORM3D: + return { + "basis": serialize(value.basis), + "origin": serialize(value.origin), + } + TYPE_PROJECTION: + return { + "x": serialize(value.x), + "y": serialize(value.y), + "z": serialize(value.z), + "w": serialize(value.w), + } + TYPE_NODE_PATH: + return str(value) + TYPE_ARRAY, TYPE_PACKED_BYTE_ARRAY, TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY, TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY, TYPE_PACKED_STRING_ARRAY, TYPE_PACKED_VECTOR2_ARRAY, TYPE_PACKED_VECTOR3_ARRAY, TYPE_PACKED_VECTOR4_ARRAY, TYPE_PACKED_COLOR_ARRAY: + var arr: Array = [] + for item in value: + arr.append(serialize(item)) + return arr + TYPE_DICTIONARY: + var out := {} + for key in value: + out[str(key)] = serialize(value[key]) + return out + TYPE_OBJECT: + if value is Resource and value.resource_path: + return value.resource_path + return str(value) + _: + return str(value) diff --git a/addons/godot_ai/utils/variant_serializer.gd.uid b/addons/godot_ai/utils/variant_serializer.gd.uid new file mode 100644 index 0000000..b8e0a28 --- /dev/null +++ b/addons/godot_ai/utils/variant_serializer.gd.uid @@ -0,0 +1 @@ +uid://cte37mtbd61n3 diff --git a/addons/godot_ai/utils/windows_port_reservation.gd b/addons/godot_ai/utils/windows_port_reservation.gd new file mode 100644 index 0000000..c77631f --- /dev/null +++ b/addons/godot_ai/utils/windows_port_reservation.gd @@ -0,0 +1,166 @@ +@tool +class_name McpWindowsPortReservation +extends RefCounted + +## Detects whether Windows has reserved a TCP port range that covers the +## plugin's server port. Hyper-V, WSL2, Docker Desktop, and Windows +## Sandbox all grab port ranges at boot via the winnat service. When a +## user's chosen port sits inside a reserved range, bind(2) fails with +## WinError 10013 ("forbidden by its access permissions") rather than +## 10048 ("address in use") — `netstat` shows nothing because no process +## owns the port, making the failure invisible. See issue #146. + +const NETSH_ARGS := ["interface", "ipv4", "show", "excludedportrange", "protocol=tcp"] +const NETSH_CACHE_TTL_MS := 2000 + +static var _netsh_cache_text := "" +static var _netsh_cache_msec := 0 +static var _netsh_cache_valid := false +static var _netsh_query_count := 0 + + +## Returns true if `port` falls inside a currently-reserved range on this +## Windows host. No-op on non-Windows (returns false). +static func is_port_excluded(port: int) -> bool: + if OS.get_name() != "Windows": + return false + var now_ms := Time.get_ticks_msec() + var cached := _get_cached_excluded_output(now_ms) + if bool(cached.get("hit", false)): + return parse_excluded(str(cached.get("text", "")), port) + var output: Array = [] + var exit_code := _execute_netsh_excluded_ranges(output) + if exit_code != 0 or output.is_empty(): + return false + var text := str(output[0]) + _store_excluded_output(text, now_ms) + return parse_excluded(text, port) + + +static func _store_excluded_output(text: String, now_ms: int) -> void: + _netsh_cache_text = text + _netsh_cache_msec = now_ms + _netsh_cache_valid = true + + +static func _get_cached_excluded_output(now_ms: int) -> Dictionary: + if not _netsh_cache_valid: + return {"hit": false, "text": ""} + if now_ms - _netsh_cache_msec > NETSH_CACHE_TTL_MS: + return {"hit": false, "text": ""} + return {"hit": true, "text": _netsh_cache_text} + + +static func _clear_cache_for_tests() -> void: + _netsh_cache_text = "" + _netsh_cache_msec = 0 + _netsh_cache_valid = false + + +static func netsh_query_count() -> int: + return _netsh_query_count + + +static func _execute_netsh_excluded_ranges(output: Array) -> int: + _netsh_query_count += 1 + return OS.execute("netsh", NETSH_ARGS, output, true) + + +## Parse the `netsh` excluded-port-range output and return true if `port` +## sits inside any reserved range. Exposed for testing; the live check +## uses `is_port_excluded`. Expected input format: +## +## Protocol tcp Port Exclusion Ranges +## +## Start Port End Port +## ---------- -------- +## 80 80 +## 5040 5040 +## 8000 8099 +## +## * - Administered port exclusions. +static func parse_excluded(text: String, port: int) -> bool: + return _ranges_contain(parse_excluded_ranges(text), port) + + +## Parse the `netsh` excluded-port-range output once into inclusive ranges. +static func parse_excluded_ranges(text: String) -> Array[Vector2i]: + var ranges: Array[Vector2i] = [] + for line in text.split("\n"): + var trimmed := line.strip_edges() + if trimmed.is_empty() or trimmed.begins_with("-") or trimmed.begins_with("*"): + continue + var parts: PackedStringArray = trimmed.split(" ", false) + if parts.size() < 2: + continue + if not parts[0].is_valid_int() or not parts[1].is_valid_int(): + continue + var start_p := int(parts[0]) + var end_p := int(parts[1]) + ranges.append(Vector2i(start_p, end_p)) + return ranges + + +static func _ranges_contain(ranges: Array[Vector2i], port: int) -> bool: + for r in ranges: + if port >= r.x and port <= r.y: + return true + return false + + +## Return the first port in `start`..`start+span-1` that is not excluded by +## Windows' port reservation table. Runs `netsh` once, unlike probing every +## candidate with `is_port_excluded`, which keeps fallback port selection cheap +## when Hyper-V / WSL2 / Docker reserve many adjacent ranges. +static func suggest_non_excluded_port(start: int, span: int = 2048, max_port: int = 65535) -> int: + if OS.get_name() != "Windows": + return start + var now_ms := Time.get_ticks_msec() + var cached := _get_cached_excluded_output(now_ms) + if bool(cached.get("hit", false)): + return suggest_non_excluded_port_from_output(str(cached.get("text", "")), start, span, max_port) + var output: Array = [] + var exit_code := _execute_netsh_excluded_ranges(output) + if exit_code != 0 or output.is_empty(): + return start + var text := str(output[0]) + _store_excluded_output(text, now_ms) + return suggest_non_excluded_port_from_output(text, start, span, max_port) + + +## Pure parser-backed helper for tests and for `suggest_non_excluded_port`. +static func suggest_non_excluded_port_from_output(text: String, start: int, span: int = 2048, max_port: int = 65535) -> int: + var ranges := parse_excluded_ranges(text) + var limit := mini(start + span - 1, max_port) + var p := start + while p <= limit: + var advanced := false + for r in ranges: + if p >= r.x and p <= r.y: + p = r.y + 1 + advanced = true + break + if not advanced: + return p + return start + + +## User-facing hint for the proactive port-reservation detection path — +## rendered when `is_port_excluded(port)` returns true *before* we even +## try to bind. Same copy as the post-crash WinError-10013 branch in +## `hint_from_output` so the two entry points agree. +static func port_excluded_hint(port: int) -> String: + return "Port %d is reserved by Windows (often Hyper-V / WSL2 / Docker Desktop). In an admin PowerShell: `net stop winnat; net start winnat`, then click Reconnect." % port + + +## Scan captured server output for known failure signatures and return a +## short, user-facing hint. Empty string means no match. +static func hint_from_output(lines: PackedStringArray, port: int) -> String: + var joined := "\n".join(lines).to_lower() + if joined.find("winerror 10013") >= 0 or joined.find("forbidden by its access permissions") >= 0: + return port_excluded_hint(port) + if joined.find("errno 98") >= 0 or joined.find("winerror 10048") >= 0 or joined.find("address already in use") >= 0: + return "Port %d is already in use by another process. Stop the conflicting process, then click Reconnect." % port + if joined.find("modulenotfounderror") >= 0 or joined.find("no module named") >= 0: + return "The `godot-ai` Python package didn't load. Try `uv cache clean`, then Reconnect." + return "" diff --git a/addons/godot_ai/utils/windows_port_reservation.gd.uid b/addons/godot_ai/utils/windows_port_reservation.gd.uid new file mode 100644 index 0000000..ffc5746 --- /dev/null +++ b/addons/godot_ai/utils/windows_port_reservation.gd.uid @@ -0,0 +1 @@ +uid://bt7mxpjcdrobq diff --git a/assets/characters/Bob.glb b/assets/characters/Bob.glb index 6e8d0ed2ac9a3feabfcdb910c630ad3d41e8d423..3484576f233fa99763144f5363b8a4a0578f8807 100644 GIT binary patch delta 105 zcmezK$o}smd*1Y%5H}_U28P;?{0xN~d2dT|I%gynB_@}o7B#<@ZhtS$2*ga=-%B%3 r;b?DBVFqFrAZ7((HXvpPVvsl|5OV=BHxTmxF)tAFZEsQG|8WKYBBLeF delta 105 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