573 lines
26 KiB
GDScript
Vendored
573 lines
26 KiB
GDScript
Vendored
@tool
|
|
extends RefCounted
|
|
|
|
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
|
|
|
## Handles project settings and filesystem search commands.
|
|
|
|
const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd")
|
|
const RUN_READY_WAIT_SEC := 3.0
|
|
|
|
## ProjectSettings keys that decide what the engine EXECUTES at project
|
|
## startup. `McpPathValidator._reject_sensitive_write` already refuses direct
|
|
## writes to `res://project.godot`, `res://override.cfg` and `res://.godot/`
|
|
## for exactly this reason — but `set_project_setting` writes that same
|
|
## manifest through the settings API, so without this list the guard is
|
|
## trivially side-steppable:
|
|
##
|
|
## settings_set key="autoload/Boot" value="*res://../../evil.gd"
|
|
##
|
|
## would land arbitrary code on the next project open, and a `project.godot`
|
|
## diff is easy to miss in review. Autoloads have a validated route of their
|
|
## own (`autoload_handler.add_autoload`, which runs the path through
|
|
## `McpPathValidator`); the rest are refused outright because there is no
|
|
## legitimate agent workflow for repointing the engine's startup execution.
|
|
##
|
|
## Deliberately NARROW. A denylist that over-blocks turns settings_set into a
|
|
## tool agents can't use, so only keys that actually carry code (or a command
|
|
## line) are listed — not their whole section. `application/run/` is an exact
|
|
## entry for `main_scene` precisely because its siblings (`max_fps`,
|
|
## `low_processor_mode`, …) are inert and must stay writable; likewise
|
|
## `application/boot_splash/image` is data, not code, and is NOT blocked.
|
|
##
|
|
## Prefix entries match the key and anything beneath it, and are used only
|
|
## where every key under the prefix is code-bearing. Exact entries match only
|
|
## themselves. Comparison is case-folded — ProjectSettings keys are
|
|
## case-sensitive, but a case variant that Godot would reject is still a
|
|
## clearer error coming from here than from a half-applied save.
|
|
const STARTUP_EXECUTION_KEY_PREFIXES: Array[String] = [
|
|
"autoload/", ## every key under it is a script path
|
|
"editor_plugins/", ## enabled-plugin list; each entry is loaded as code
|
|
]
|
|
const STARTUP_EXECUTION_KEYS_EXACT: Array[String] = [
|
|
"application/run/main_scene", ## the scene the game boots into
|
|
"editor/script/templates_search_path", ## where the editor loads templates from
|
|
"editor/run/main_run_args", ## command line for the run
|
|
]
|
|
|
|
|
|
## Returns "" when `key` may be written via set_project_setting, or a
|
|
## human-readable refusal reason otherwise. Static so it is unit-testable
|
|
## without instancing the handler.
|
|
static func startup_execution_key_refusal(key: String) -> String:
|
|
var lowered := key.strip_edges().to_lower()
|
|
for prefix in STARTUP_EXECUTION_KEY_PREFIXES:
|
|
if lowered.begins_with(prefix):
|
|
if prefix == "autoload/":
|
|
return (
|
|
"Refusing to set '%s' — autoloads run code at project startup. " % key
|
|
+ "Use autoload_manage(op='add'), which validates the script path."
|
|
)
|
|
return (
|
|
"Refusing to set '%s' — keys under '%s' are loaded as code " % [key, prefix]
|
|
+ "at project startup."
|
|
)
|
|
for exact in STARTUP_EXECUTION_KEYS_EXACT:
|
|
if lowered == exact:
|
|
return (
|
|
"Refusing to set '%s' — this key controls what the engine loads " % key
|
|
+ "or executes at project startup."
|
|
)
|
|
return ""
|
|
|
|
var _connection: McpConnection
|
|
var _debugger_plugin
|
|
var _editor_log_buffer
|
|
|
|
|
|
func _init(connection: McpConnection = null, debugger_plugin = null, editor_log_buffer = null) -> void:
|
|
_connection = connection
|
|
_debugger_plugin = debugger_plugin
|
|
_editor_log_buffer = editor_log_buffer
|
|
|
|
|
|
func get_project_setting(params: Dictionary) -> Dictionary:
|
|
var key: String = params.get("key", "")
|
|
if key.is_empty():
|
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: key")
|
|
|
|
if not ProjectSettings.has_setting(key):
|
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Setting not found: %s" % key)
|
|
|
|
var value = ProjectSettings.get_setting(key)
|
|
return {
|
|
"data": {
|
|
"key": key,
|
|
"value": NodeHandler._serialize_value(value),
|
|
"type": type_string(typeof(value)),
|
|
}
|
|
}
|
|
|
|
|
|
func set_project_setting(params: Dictionary) -> Dictionary:
|
|
var key: String = params.get("key", "")
|
|
if key.is_empty():
|
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: key")
|
|
|
|
if not params.has("value"):
|
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: value")
|
|
|
|
## Refuse the startup-execution surface before touching ProjectSettings —
|
|
## see STARTUP_EXECUTION_KEY_PREFIXES for why this guard exists here and
|
|
## not only in McpPathValidator.
|
|
var refusal := startup_execution_key_refusal(key)
|
|
if not refusal.is_empty():
|
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, refusal)
|
|
|
|
var value = params.get("value")
|
|
var had_setting := ProjectSettings.has_setting(key)
|
|
var old_value = ProjectSettings.get_setting(key) if had_setting else null
|
|
# JSON has no distinct int type: Godot parses `1920` as float. If the
|
|
# existing setting is TYPE_INT, coerce whole-number floats back to int so
|
|
# we don't silently flip typed-int settings (viewport_width, etc.) to
|
|
# floats on disk. See issue #31.
|
|
if had_setting and typeof(old_value) == TYPE_INT and typeof(value) == TYPE_FLOAT and float(int(value)) == value:
|
|
value = int(value)
|
|
ProjectSettings.set_setting(key, value)
|
|
var err := ProjectSettings.save()
|
|
if err != OK:
|
|
if had_setting:
|
|
ProjectSettings.set_setting(key, old_value)
|
|
else:
|
|
ProjectSettings.clear(key)
|
|
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to save project settings (error %d)" % err)
|
|
|
|
return {
|
|
"data": {
|
|
"key": key,
|
|
"value": NodeHandler._serialize_value(value),
|
|
"old_value": NodeHandler._serialize_value(old_value),
|
|
"type": type_string(typeof(value)),
|
|
"undoable": false,
|
|
"reason": "ProjectSettings changes are saved to disk",
|
|
}
|
|
}
|
|
|
|
|
|
func run_project(params: Dictionary) -> Dictionary:
|
|
var mode: String = params.get("mode", "main")
|
|
var autosave: bool = params.get("autosave", true)
|
|
# Idempotent: a project that's already running satisfies the caller's intent.
|
|
# Returning INVALID_PARAMS here punished agents that legitimately called run
|
|
# to ensure the project is playing (87+ installs/day hit the matching
|
|
# stop-not-running case in telemetry). Surface state via was_already_running
|
|
# so a caller wanting a *different* scene can detect and stop+restart.
|
|
if EditorInterface.is_playing_scene():
|
|
return _run_project_current_liveness_response(
|
|
_run_project_base_data(
|
|
mode,
|
|
str(params.get("scene", "")),
|
|
autosave,
|
|
true,
|
|
"Project was already running; no action taken"
|
|
)
|
|
)
|
|
|
|
var validation_error: Variant = null
|
|
if mode == "custom":
|
|
var custom_scene: String = params.get("scene", "")
|
|
if custom_scene.is_empty():
|
|
validation_error = ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: scene (required when mode='custom')")
|
|
else:
|
|
## play_custom_scene() was the last path-taking op in the plugin
|
|
## with no containment check; every sibling that accepts a scene
|
|
## path validates it (scene_handler.open_scene,
|
|
## node_handler.create_node's scene_path).
|
|
validation_error = McpPathValidator.loadable_error(custom_scene, "scene")
|
|
elif mode != "main" and mode != "current":
|
|
validation_error = ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid mode '%s' — use 'main', 'current', or 'custom'" % mode)
|
|
if validation_error != null:
|
|
return validation_error
|
|
|
|
# play_*_scene internally triggers try_autosave() → _save_scene_with_preview()
|
|
# which renders a preview thumbnail and calls frame processing. If our
|
|
# WebSocket connection's _process() re-enters during that render, the
|
|
# engine crashes (SIGABRT in _save_scene_with_preview). Pause processing
|
|
# around the play call — same pattern as SceneHandler.save_scene.
|
|
if _connection:
|
|
_connection.pause_processing = true
|
|
|
|
# try_autosave() reads run/auto_save/save_before_running every call, so
|
|
# toggling it off around the play call suppresses the save without
|
|
# touching the user's persisted preference. Issue #81.
|
|
var autosave_key := "run/auto_save/save_before_running"
|
|
var editor_settings: EditorSettings = null
|
|
if not autosave:
|
|
editor_settings = EditorInterface.get_editor_settings()
|
|
var prior_autosave: bool = true
|
|
var restore_setting := false
|
|
if editor_settings != null and editor_settings.has_setting(autosave_key):
|
|
prior_autosave = bool(editor_settings.get_setting(autosave_key))
|
|
editor_settings.set_setting(autosave_key, false)
|
|
restore_setting = true
|
|
|
|
if _debugger_plugin != null:
|
|
_debugger_plugin.begin_game_run(_editor_log_cursor(), _game_helper_autoload_expected())
|
|
|
|
match mode:
|
|
"main":
|
|
EditorInterface.play_main_scene()
|
|
"current":
|
|
EditorInterface.play_current_scene()
|
|
"custom":
|
|
var scene_path: String = params.get("scene", "")
|
|
EditorInterface.play_custom_scene(scene_path)
|
|
|
|
if restore_setting:
|
|
editor_settings.set_setting(autosave_key, prior_autosave)
|
|
|
|
if _connection:
|
|
_connection.pause_processing = false
|
|
|
|
var base_data := _run_project_base_data(
|
|
mode,
|
|
str(params.get("scene", "")),
|
|
autosave,
|
|
false,
|
|
"Play/stop is a runtime action"
|
|
)
|
|
var request_id: String = params.get("_request_id", "")
|
|
if _connection != null and _debugger_plugin != null and not request_id.is_empty():
|
|
_finish_run_project_deferred(request_id, base_data, _connection, _debugger_plugin)
|
|
return McpDispatcher.DEFERRED_RESPONSE
|
|
|
|
return _run_project_current_liveness_response(base_data)
|
|
|
|
|
|
func _editor_log_cursor() -> int:
|
|
return _editor_log_buffer.appended_total() if _editor_log_buffer != null else 0
|
|
|
|
|
|
func _game_helper_autoload_expected() -> bool:
|
|
return ProjectSettings.has_setting("autoload/_mcp_game_helper")
|
|
|
|
|
|
static func _run_project_base_data(
|
|
mode: String,
|
|
scene: String,
|
|
autosave: bool,
|
|
was_already_running: bool,
|
|
reason: String
|
|
) -> Dictionary:
|
|
return {
|
|
"mode": mode,
|
|
"scene": scene,
|
|
"autosave": autosave,
|
|
"was_already_running": was_already_running,
|
|
"undoable": false,
|
|
"reason": reason,
|
|
}
|
|
|
|
|
|
func _run_project_current_liveness_response(base_data: Dictionary) -> Dictionary:
|
|
if _debugger_plugin == null:
|
|
return {"data": base_data}
|
|
var status: Dictionary = _debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC)
|
|
## One-shot read — force a Debugger-tab scan so boot errors that landed
|
|
## after the last gated scan are in this response (#641).
|
|
var errors_info: Dictionary = _debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)), true)
|
|
return _run_project_response(base_data, _run_project_liveness_decision(status, errors_info))
|
|
|
|
|
|
## `static` is load-bearing (#712, same rationale as the script/filesystem
|
|
## handlers and editor_handler._do_reload_plugin): this coroutine awaits
|
|
## across frames, and a plugin reload frees this RefCounted handler
|
|
## mid-await — resuming an instance coroutine on a freed object errors.
|
|
## `connection` and `debugger_plugin` are parameterized explicitly and
|
|
## re-validated after every await; the response is dropped silently when
|
|
## either died (the server's command timeout surfaces the failure).
|
|
static func _finish_run_project_deferred(
|
|
request_id: String, base_data: Dictionary, connection, debugger_plugin
|
|
) -> void:
|
|
var tree: SceneTree = connection.get_tree()
|
|
while true:
|
|
await tree.process_frame
|
|
if not is_instance_valid(connection) or not is_instance_valid(debugger_plugin):
|
|
return
|
|
var pre_status: Dictionary = debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC)
|
|
if (
|
|
not EditorInterface.is_playing_scene()
|
|
and int(pre_status.get("elapsed_msec", 0)) > 100
|
|
and str(pre_status.get("status", "stopped")) == "launching"
|
|
):
|
|
debugger_plugin.end_game_run()
|
|
var status: Dictionary = debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC)
|
|
var errors_info: Dictionary = debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)))
|
|
var decision := _run_project_liveness_decision(status, errors_info)
|
|
if not bool(decision.get("resolve", false)):
|
|
continue
|
|
## #641: the loop above polls with gated (cheap) scans; boot parse
|
|
## errors can land in the Errors tab in the same frames the run goes
|
|
## live. Re-gather once with a forced scan before replying so the
|
|
## response reports them instead of leaving them to a later
|
|
## logs_read. Rebuilding the decision with strictly-more errors can
|
|
## only keep it resolved (errors never un-resolve a decision).
|
|
errors_info = debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)), true)
|
|
decision = _run_project_liveness_decision(status, errors_info)
|
|
connection.send_deferred_response(request_id, _run_project_response(base_data, decision))
|
|
return
|
|
|
|
|
|
static func _run_project_response(base_data: Dictionary, decision: Dictionary) -> Dictionary:
|
|
var data := base_data.duplicate(true)
|
|
var game_status: Dictionary = decision.get("game_status", {})
|
|
data["game_status"] = game_status
|
|
data["helper_live"] = bool(game_status.get("helper_live", false))
|
|
data["session_active"] = bool(game_status.get("session_active", false))
|
|
if bool(data.get("was_already_running", false)):
|
|
data["reason"] = _run_project_already_running_message(decision)
|
|
else:
|
|
data["reason"] = decision.get("message", data.get("reason", "Play/stop is a runtime action"))
|
|
data["recent_errors"] = decision.get("recent_errors", [])
|
|
data["recent_errors_scope"] = decision.get("recent_errors_scope", "none")
|
|
data["recent_errors_may_predate_run"] = decision.get("recent_errors_may_predate_run", false)
|
|
data["recent_errors_truncated"] = decision.get("recent_errors_truncated", false)
|
|
data.merge(McpDebuggerPlugin.split_errors_by_scope(data["recent_errors"], data["recent_errors_scope"]), true)
|
|
return {"data": data}
|
|
|
|
|
|
static func _run_project_already_running_message(decision: Dictionary) -> String:
|
|
var state := str(decision.get("liveness_status", "unknown"))
|
|
match state:
|
|
"live":
|
|
var live_errors: Array = decision.get("recent_errors", [])
|
|
if not live_errors.is_empty() and str(decision.get("recent_errors_scope", "none")) == "run":
|
|
return (
|
|
"Project was already running; the Godot AI game helper is live, but %d editor error%s surfaced during this run (first: %s). Check logs_read(source='editor', include_details=true)."
|
|
% [live_errors.size(), "s" if live_errors.size() != 1 else "", _format_editor_error_summary(live_errors[0])]
|
|
)
|
|
return "Project was already running; the Godot AI game helper is live."
|
|
"not_live":
|
|
var errors: Array = decision.get("recent_errors", [])
|
|
var scope := str(decision.get("recent_errors_scope", "none"))
|
|
if not errors.is_empty() and scope == "run":
|
|
return "Project was already running but failed to load before the Godot AI game helper registered: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(errors[0])
|
|
if not errors.is_empty():
|
|
return "Project was already running but is not responding. A recent editor error may be related, but may predate this run: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(errors[0])
|
|
return "Project was already running but did not become live before the helper-ready window elapsed. Check logs_read(source='editor', include_details=true) and poll editor_state."
|
|
"break":
|
|
var break_errors: Array = decision.get("recent_errors", [])
|
|
if not break_errors.is_empty() and str(decision.get("recent_errors_scope", "none")) == "run":
|
|
return "Project was already running but the game is parked at a debugger break: %s. Call project_manage(op='stop') to end the run, fix the error, and relaunch." % _format_editor_error_summary(break_errors[0])
|
|
if not break_errors.is_empty():
|
|
return "Project was already running but the game is parked at a debugger break. A recent editor error may be related, but may predate this run: %s. Call project_manage(op='stop') to end the run." % _format_editor_error_summary(break_errors[0])
|
|
return "Project was already running but the game is parked at a debugger break. Call project_manage(op='stop') to end the run; the break reason is in the editor's Debugger panel."
|
|
"no_helper":
|
|
return "Project was already running, but no _mcp_game_helper autoload is expected. Headless or custom-main-loop projects cannot confirm helper liveness."
|
|
"launching":
|
|
return "Project was already running and is still waiting for the Godot AI game helper to register. Poll editor_state shortly."
|
|
"stopped":
|
|
return "Project was already marked playing by the editor, but no active game liveness run exists."
|
|
_:
|
|
return "Project was already running; current liveness status is %s." % state
|
|
|
|
|
|
## Static (with the rest of the deferred-finisher chain) so the #712
|
|
## load-bearing-static coroutines above can call it after their owner
|
|
## handler was freed. Uses no instance state.
|
|
static func _run_project_liveness_decision(status: Dictionary, errors_info: Dictionary = {}) -> Dictionary:
|
|
var enriched_status := McpDebuggerPlugin.with_liveness_flags(status)
|
|
var state := str(status.get("status", "stopped"))
|
|
var recent_errors: Array = errors_info.get("errors", [])
|
|
var errors_scope := str(errors_info.get("scope", "none"))
|
|
var truncated := bool(errors_info.get("truncated", false))
|
|
## Clear errors that predate this run's window — they don't belong in
|
|
## a successful launch response and only add noise. They remain
|
|
## reachable via logs_read(source='editor') and the retained buffer so
|
|
## failed-run debugging is unaffected.
|
|
## #635 tradeoff: a genuine in-run error whose Errors-tab row carries
|
|
## an empty or byte-identical time text can be misclassified as
|
|
## retained_recent and will be dropped here. Still reachable via
|
|
## logs_read.
|
|
if state == "live" and errors_scope == "retained_recent":
|
|
recent_errors = []
|
|
errors_scope = "none"
|
|
var correlated_error := not recent_errors.is_empty() and errors_scope == "run"
|
|
var elapsed_msec := int(status.get("elapsed_msec", 0))
|
|
var ready_wait_msec := int(status.get("ready_wait_msec", int(RUN_READY_WAIT_SEC * 1000.0)))
|
|
var decision := {
|
|
"resolve": false,
|
|
"game_status": enriched_status,
|
|
"liveness_status": state,
|
|
"recent_errors": recent_errors,
|
|
"recent_errors_scope": errors_scope,
|
|
"recent_errors_may_predate_run": errors_scope == "retained_recent",
|
|
"recent_errors_truncated": truncated,
|
|
"message": "",
|
|
}
|
|
if state == "live":
|
|
decision["resolve"] = true
|
|
if correlated_error:
|
|
## #641: "live" only means the helper autoload registered — scripts
|
|
## can still have failed to parse or load during boot (a broken
|
|
## node script does not stop the game from running). Surface those
|
|
## errors in the success message so agents don't read a clean
|
|
## launch into a run that silently lost scripts.
|
|
decision["message"] = (
|
|
"Game launched and the Godot AI game helper is live, but %d editor error%s surfaced during startup (first: %s) — likely a script that failed to parse or load. Check logs_read(source='editor', include_details=true)."
|
|
% [recent_errors.size(), "s" if recent_errors.size() != 1 else "", _format_editor_error_summary(recent_errors[0])]
|
|
)
|
|
if truncated:
|
|
decision["message"] += " Editor logs since this run may be truncated; showing retained errors."
|
|
else:
|
|
decision["message"] = "Game launched and the Godot AI game helper is live."
|
|
elif state == "break":
|
|
## #645: the game process is parked in a remote-debugger break. A
|
|
## boot-time parse error (GDScriptLanguage::debug_break_parse) produces
|
|
## no Errors-tab row, no Logger entry, and no game-log line — the
|
|
## synthesized break record is the only evidence, and it lands a
|
|
## moment after the break signal (stack frames arrive async). Wait for
|
|
## it (correlated_error) before resolving; the ready window is the
|
|
## fallback if synthesis never lands.
|
|
var break_info: Dictionary = status.get("break", {})
|
|
var break_reason := str(break_info.get("reason", ""))
|
|
if bool(break_info.get("pre_live", true)):
|
|
decision["resolve"] = correlated_error or elapsed_msec >= ready_wait_msec
|
|
var summary := break_reason
|
|
if correlated_error:
|
|
summary = _format_editor_error_summary(recent_errors[0])
|
|
if summary.is_empty():
|
|
summary = "script parse/load error (reason not captured)"
|
|
decision["message"] = "Game hit a script error during startup and is frozen at a debugger break before the Godot AI game helper registered: %s. The run cannot continue; call project_manage(op='stop'), fix the error, and relaunch. Check logs_read(source='editor', include_details=true)." % summary
|
|
else:
|
|
var reason_suffix := (": %s" % break_reason) if not break_reason.is_empty() else ""
|
|
decision["resolve"] = true
|
|
decision["message"] = "Game is paused at a debugger break%s. Resume it from the editor's Debugger panel or call project_manage(op='stop')." % reason_suffix
|
|
elif correlated_error:
|
|
decision["resolve"] = true
|
|
decision["liveness_status"] = "not_live"
|
|
decision["message"] = "Game launched but failed to load before the Godot AI game helper registered: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0])
|
|
if truncated:
|
|
decision["message"] += " Editor logs since this run may be truncated; showing retained errors."
|
|
elif state == "not_live":
|
|
decision["resolve"] = true
|
|
if not recent_errors.is_empty():
|
|
decision["message"] = "Game launched but is not responding. A recent editor error may be related, but may predate this run: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0])
|
|
else:
|
|
decision["message"] = "Game launched but did not become live before the helper-ready window elapsed. It may still be booting or may have failed silently; check logs_read(source='editor', include_details=true) and poll editor_state."
|
|
elif state == "no_helper":
|
|
decision["resolve"] = true
|
|
decision["message"] = "Game launched, but no _mcp_game_helper autoload is expected. Headless or custom-main-loop projects cannot confirm helper liveness; use editor_state and viewport/editor tools where applicable."
|
|
elif state == "stopped":
|
|
decision["resolve"] = true
|
|
decision["message"] = "The play session stopped, or no active game liveness run exists, before the Godot AI game helper became live."
|
|
elif state == "launching" and elapsed_msec >= ready_wait_msec:
|
|
decision["resolve"] = true
|
|
decision["message"] = "Game launched but is not yet live after %.1fs; it may still be booting. Poll editor_state and check logs_read(source='editor', include_details=true)." % (float(elapsed_msec) / 1000.0)
|
|
return decision
|
|
|
|
|
|
static func _format_editor_error_summary(entry: Dictionary) -> String:
|
|
return McpSurfacedErrorTracker.format_editor_error_summary(entry)
|
|
|
|
|
|
func stop_project(params: Dictionary) -> Dictionary:
|
|
# Idempotent: a project that's already stopped satisfies the caller's intent.
|
|
# Returning INVALID_PARAMS here was the largest single source of fleet-wide
|
|
# project_manage failures (87 installs/24h). was_running=false lets callers
|
|
# distinguish a no-op stop from one that actually halted a running session.
|
|
if not EditorInterface.is_playing_scene():
|
|
return {
|
|
"data": {
|
|
"stopped": true,
|
|
"was_running": false,
|
|
"undoable": false,
|
|
"reason": "Project was not running; no action taken",
|
|
}
|
|
}
|
|
|
|
if _debugger_plugin != null:
|
|
_debugger_plugin.end_game_run()
|
|
EditorInterface.stop_playing_scene()
|
|
|
|
# stop_playing_scene() is async — is_playing_scene() only flips to false on
|
|
# the next frame, and readiness_changed follows in _process. Defer the
|
|
# response so we can reply with authoritative readiness instead of letting
|
|
# the server poll for the event. Issue #29.
|
|
var request_id: String = params.get("_request_id", "")
|
|
if _connection != null and not request_id.is_empty():
|
|
_finish_stop_project_deferred(request_id, _connection)
|
|
return McpDispatcher.DEFERRED_RESPONSE
|
|
|
|
# Fallback for contexts without a connection (e.g. batch_execute via
|
|
# dispatch_direct, or unit tests that instantiate the handler with null).
|
|
return {
|
|
"data": {
|
|
"stopped": true,
|
|
"was_running": true,
|
|
"undoable": false,
|
|
"reason": "Play/stop is a runtime action",
|
|
}
|
|
}
|
|
|
|
|
|
# Wait two frames so Godot can tick the stop-play state change. After this
|
|
# is_playing_scene() reflects truth and get_readiness() is authoritative.
|
|
# If the plugin tears down (_exit_tree frees _connection) during the await,
|
|
# is_instance_valid() goes false and we drop the response silently — the
|
|
# server's 5s request timeout will surface the failure to the caller.
|
|
# `static` is load-bearing (#712): see _finish_run_project_deferred.
|
|
static func _finish_stop_project_deferred(request_id: String, connection) -> void:
|
|
var tree: SceneTree = connection.get_tree()
|
|
await tree.process_frame
|
|
await tree.process_frame
|
|
if not is_instance_valid(connection):
|
|
return
|
|
connection.send_deferred_response(request_id, {
|
|
"data": {
|
|
"stopped": true,
|
|
"was_running": true,
|
|
"undoable": false,
|
|
"reason": "Play/stop is a runtime action",
|
|
"readiness_after": McpConnection.get_readiness(),
|
|
}
|
|
})
|
|
|
|
|
|
func search_filesystem(params: Dictionary) -> Dictionary:
|
|
var name_filter: String = params.get("name", "")
|
|
var type_filter: String = params.get("type", "")
|
|
var path_filter: String = params.get("path", "")
|
|
|
|
if name_filter.is_empty() and type_filter.is_empty() and path_filter.is_empty():
|
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "At least one filter (name, type, path) is required")
|
|
|
|
var efs := EditorInterface.get_resource_filesystem()
|
|
if efs == null:
|
|
return ErrorCodes.make_not_ready(
|
|
ErrorCodes.SUB_EDITOR_UNAVAILABLE,
|
|
"EditorFileSystem not available", false)
|
|
|
|
var results: Array[Dictionary] = []
|
|
_scan_directory(efs.get_filesystem(), name_filter, type_filter, path_filter, results)
|
|
return {"data": {"files": results, "count": results.size()}}
|
|
|
|
|
|
func _scan_directory(dir: EditorFileSystemDirectory, name_filter: String, type_filter: String, path_filter: String, out: Array[Dictionary]) -> void:
|
|
for i in dir.get_file_count():
|
|
var file_path := dir.get_file_path(i)
|
|
var file_type := dir.get_file_type(i)
|
|
|
|
var matches := true
|
|
|
|
if not name_filter.is_empty():
|
|
if file_path.get_file().to_lower().find(name_filter.to_lower()) == -1:
|
|
matches = false
|
|
|
|
if matches and not type_filter.is_empty():
|
|
if file_type != type_filter:
|
|
matches = false
|
|
|
|
if matches and not path_filter.is_empty():
|
|
if file_path.to_lower().find(path_filter.to_lower()) == -1:
|
|
matches = false
|
|
|
|
if matches:
|
|
out.append({
|
|
"path": file_path,
|
|
"type": file_type,
|
|
})
|
|
|
|
for i in dir.get_subdir_count():
|
|
_scan_directory(dir.get_subdir(i), name_filter, type_filter, path_filter, out)
|