@tool extends Node var editor_plugin: EditorPlugin ## Override in subclasses: return {"method_name": Callable} func get_commands() -> Dictionary: return {} ## Helper: return a success result func success(data: Dictionary = {}) -> Dictionary: return {"result": data} ## Helper: return an error func error(code: int, message: String, data: Dictionary = {}) -> Dictionary: var err := {"code": code, "message": message} if not data.is_empty(): err["data"] = data return {"error": err} ## Error codes func error_not_found(what: String, suggestion: String = "") -> Dictionary: var data := {} if suggestion: data["suggestion"] = suggestion return error(-32001, "%s not found" % what, data) func error_invalid_params(message: String) -> Dictionary: return error(-32602, message) func error_no_scene() -> Dictionary: return error(-32000, "No scene is currently open", {"suggestion": "Use open_scene to open a scene first"}) func error_internal(message: String) -> Dictionary: return error(-32603, "Internal error: %s" % message) func error_conflict(message: String, data: Dictionary = {}) -> Dictionary: return error(-32009, message, data) ## Get required string param func require_string(params: Dictionary, key: String) -> Array: if not params.has(key) or not params[key] is String or (params[key] as String).is_empty(): return [null, error_invalid_params("Missing required parameter: %s" % key)] return [params[key] as String, null] ## Get optional string param with default func optional_string(params: Dictionary, key: String, default: String = "") -> String: if params.has(key) and params[key] is String: return params[key] as String return default ## Get optional bool param with default func optional_bool(params: Dictionary, key: String, default: bool = false) -> bool: if params.has(key) and params[key] is bool: return params[key] as bool return default ## Get optional int param with default func optional_int(params: Dictionary, key: String, default: int = 0) -> int: if params.has(key): return int(params[key]) return default ## Get the game process's user data directory. ## OS.get_user_data_dir() is cached at editor startup and won't reflect ## project name changes made to project.godot while the editor is running. ## The game process reads the name from disk, so we must do the same. func get_game_user_dir() -> String: var cached_dir := OS.get_user_data_dir() var cfg := ConfigFile.new() var err := cfg.load(ProjectSettings.globalize_path("res://project.godot")) if err != OK: return cached_dir # When use_custom_user_dir=true, editor and game share the same dir # (OS.get_user_data_dir() already resolves to the custom path). if cfg.get_value("application", "config/use_custom_user_dir", false): return cached_dir var disk_name = cfg.get_value("application", "config/name", "") if typeof(disk_name) != TYPE_STRING or (disk_name as String).is_empty(): return cached_dir # Sanitize exactly like Godot does when computing the default user dir # (core/config/project_settings.cpp ProjectSettings::_init). var sanitized := (disk_name as String).xml_unescape().validate_filename().replace(".", "_") if sanitized.is_empty(): return cached_dir var base_dir := cached_dir.get_base_dir() var game_dir := base_dir.path_join(sanitized) # Ensure the directory exists (game may not have created it yet) if not DirAccess.dir_exists_absolute(game_dir): DirAccess.make_dir_recursive_absolute(game_dir) return game_dir ## Get EditorInterface func get_editor() -> EditorInterface: return editor_plugin.get_editor_interface() ## Get the edited scene root func get_edited_root() -> Node: return EditorInterface.get_edited_scene_root() ## Get UndoRedo func get_undo_redo() -> EditorUndoRedoManager: return editor_plugin.get_undo_redo() func normalize_project_path(path: String) -> String: if path.is_empty(): return "" if path.begins_with("res://") or path.begins_with("user://"): return path.simplify_path() return ProjectSettings.localize_path(path).simplify_path() func is_scene_resource_path(path: String) -> bool: var ext := path.get_extension().to_lower() return ext == "tscn" or ext == "scn" func get_open_scene_paths() -> Array[String]: var paths: Array[String] = [] var open_scenes: PackedStringArray = EditorInterface.get_open_scenes() for scene_path: String in open_scenes: var normalized := normalize_project_path(scene_path) if not normalized.is_empty() and normalized not in paths: paths.append(normalized) var root := get_edited_root() if root != null and not root.scene_file_path.is_empty(): var active_path := normalize_project_path(root.scene_file_path) if active_path not in paths: paths.append(active_path) return paths func is_scene_path_open(path: String) -> bool: var normalized := normalize_project_path(path) if normalized.is_empty(): return false return normalized in get_open_scene_paths() func is_active_scene_path(path: String) -> bool: var root := get_edited_root() if root == null: return false return normalize_project_path(root.scene_file_path) == normalize_project_path(path) func guard_offline_scene_save(path: String) -> Dictionary: if is_scene_resource_path(path) and is_scene_path_open(path): return error_conflict( "Refusing to save open scene '%s' outside the Godot editor state" % normalize_project_path(path), { "path": normalize_project_path(path), "open_scenes": get_open_scene_paths(), "suggestion": "Use live editor changes plus save_scene, or close the scene before offline edits.", } ) return {} ## Helper: create the parent directory of a res:// path if missing. ## Returns {} on success, an error dictionary on failure. func ensure_parent_dir(path: String) -> Dictionary: var dir := path.get_base_dir() if dir.is_empty() or DirAccess.dir_exists_absolute(dir): return {} var derr := DirAccess.make_dir_recursive_absolute(dir) if derr != OK: return error_internal("Cannot create directory '%s': %s" % [dir, error_string(derr)]) return {} ## Helper: unwrap the (possibly multi-)wrapped {"result": ...} envelope returned ## by the game IPC channel. The game writes its own {"result": ...} envelope and ## the transport wraps it again, so consumers must unwrap defensively. func unwrap_game_result(result: Dictionary) -> Dictionary: var payload: Variant = result while payload is Dictionary and payload.has("result") and payload["result"] is Dictionary: payload = payload["result"] return payload if payload is Dictionary else {} ## Shared IPC helper: send a command to the running game and await its response. func send_game_command(command: String, params: Dictionary = {}, timeout_sec: float = 5.0) -> Dictionary: var ei := get_editor() if not ei.is_playing_scene(): return error(-32000, "No scene is currently playing", {"suggestion": "Use play_scene first"}) var user_dir := get_game_user_dir() var request_path := user_dir + "/mcp_game_request" var response_path := user_dir + "/mcp_game_response" # Clean stale response if FileAccess.file_exists(response_path): DirAccess.remove_absolute(response_path) # Write request var request_data := JSON.stringify({"command": command, "params": params}) var req := FileAccess.open(request_path, FileAccess.WRITE) if req == null: return error_internal("Could not create game request file") req.store_string(request_data) req.close() # Poll for response var attempts := int(timeout_sec / 0.1) while attempts > 0: await get_tree().create_timer(0.1).timeout if FileAccess.file_exists(response_path): break if not ei.is_playing_scene(): if FileAccess.file_exists(request_path): DirAccess.remove_absolute(request_path) return error(-32000, "Game stopped during command execution") attempts -= 1 if not FileAccess.file_exists(response_path): # Try to auto-resume the debugger (runtime error may have paused the game) if ei.is_playing_scene(): try_debugger_continue() for _retry in 20: await get_tree().create_timer(0.1).timeout if FileAccess.file_exists(response_path): break if not FileAccess.file_exists(response_path): if FileAccess.file_exists(request_path): DirAccess.remove_absolute(request_path) return build_timeout_error(timeout_sec) # Read response var file := FileAccess.open(response_path, FileAccess.READ) if file == null: return error_internal("Could not read game response file") var text := file.get_as_text() file.close() DirAccess.remove_absolute(response_path) var parsed = JSON.parse_string(text) if parsed == null or not parsed is Dictionary: return error_internal("Invalid response JSON from game") if parsed.has("error"): return error(-32000, str(parsed["error"])) return success(parsed) func is_shader_resource_path(path: String) -> bool: var ext := path.get_extension().to_lower() return ext == "gdshader" or ext == "gdshaderinc" or ext == "shader" func is_text_resource_open_in_script_editor(path: String) -> bool: var target := normalize_project_path(path) if target.is_empty(): return false if is_shader_resource_path(target) and ResourceLoader.has_cached(target): return true var script_editor := EditorInterface.get_script_editor() if script_editor == null: return false for open_resource in script_editor.get_open_scripts(): if open_resource is Resource: var resource_path := normalize_project_path((open_resource as Resource).resource_path) if resource_path == target: return true return false func guard_text_resource_write(path: String, force: bool) -> Dictionary: if not force and is_text_resource_open_in_script_editor(path): return error_conflict( "Refusing to write open text resource '%s' outside the script editor state" % normalize_project_path(path), { "path": normalize_project_path(path), "suggestion": "Close the file in Godot's script editor or pass force=true to overwrite it deliberately.", } ) return {} func mark_current_scene_unsaved() -> void: if EditorInterface.has_method("mark_scene_as_unsaved"): EditorInterface.mark_scene_as_unsaved() func add_child_with_undo(parent: Node, child: Node, root: Node, action_name: String) -> void: var undo_redo := get_undo_redo() undo_redo.create_action(action_name) undo_redo.add_do_method(parent, "add_child", child) undo_redo.add_do_method(child, "set_owner", root) undo_redo.add_do_reference(child) undo_redo.add_undo_method(parent, "remove_child", child) undo_redo.commit_action() func set_property_with_undo(target: Object, property: String, new_value: Variant, action_name: String) -> void: var old_value: Variant = target.get(property) var undo_redo := get_undo_redo() undo_redo.create_action(action_name) undo_redo.add_do_property(target, property, new_value) if new_value is Resource: undo_redo.add_do_reference(new_value) undo_redo.add_undo_property(target, property, old_value) if old_value is Resource: undo_redo.add_undo_reference(old_value) undo_redo.commit_action() ## ── Game-command timeout diagnostics ────────────────────────────────────────── ## Shared by the file-IPC `_send_game_command` helpers (runtime/test commands). ## The goal is to never tell the agent "the game isn't running / autoload missing" ## when the game IS running and merely paused by a runtime error. ## Locate the editor's ScriptEditorDebugger node (BFS from base control). func _find_script_editor_debugger() -> Node: var base := EditorInterface.get_base_control() if base == null: return null var queue: Array[Node] = [base] while not queue.is_empty(): var node := queue.pop_front() if node.get_class() == "ScriptEditorDebugger": return node for child in node.get_children(): queue.append(child) return null ## Look up an editor theme icon by name (locale-independent), or null. func _get_editor_icon(icon_name: String) -> Texture2D: var base := EditorInterface.get_base_control() if base != null and base.has_theme_icon(icon_name, "EditorIcons"): return base.get_theme_icon(icon_name, "EditorIcons") return null ## Find the debugger "Continue" button without relying on UI text. ## The editor is translated, so matching tooltip/label text breaks for ## non-English editors (issue #34: Italian → "Continua"). Match by the editor ## theme icon "DebugContinue" first, falling back to the English text only if ## the icon can't be resolved. func _find_debugger_continue_button() -> Button: var dbg := _find_script_editor_debugger() if dbg == null: return null var continue_icon := _get_editor_icon("DebugContinue") var fallback: Button = null var inner: Array[Node] = [dbg] while not inner.is_empty(): var n := inner.pop_front() if n is Button: var b := n as Button if continue_icon != null and b.icon == continue_icon: return b if b.tooltip_text == "Continue": fallback = b for c in n.get_children(): inner.append(c) return fallback ## True when the running game is halted at a breakpoint or runtime error ## (the debugger's "Continue" button is present and enabled). func is_debugger_paused() -> bool: var btn := _find_debugger_continue_button() return btn != null and not btn.disabled ## Read recent runtime errors from the debugger's "Errors" tab tree, so a ## timeout caused by a script error can report the actual cause inline. func collect_debugger_errors(max_errors: int = 10) -> Array: var out: Array = [] var dbg := _find_script_editor_debugger() if dbg == null: return out for child in dbg.get_children(): if child is TabContainer: var tab_container := child as TabContainer for tab_idx in range(tab_container.get_tab_count()): var tab_control: Control = tab_container.get_tab_control(tab_idx) if tab_control is VBoxContainer and tab_control.name.begins_with("Errors"): for vchild in tab_control.get_children(): if vchild is Tree: var tree := vchild as Tree var root_item: TreeItem = tree.get_root() if root_item: var item: TreeItem = root_item.get_first_child() while item and out.size() < max_errors: var col0: String = item.get_text(0).strip_edges() var col1: String = item.get_text(1).strip_edges() var msg: String = col0 if not col1.is_empty(): msg = (msg + " " + col1) if not msg.is_empty() else col1 if not msg.is_empty(): out.append(msg) item = item.get_next() break break return out ## Press the debugger "Continue" button to resume a paused game process. func try_debugger_continue() -> void: var btn := _find_debugger_continue_button() if btn != null and not btn.disabled: btn.emit_signal("pressed") push_warning("[MCP] Auto-resumed debugger after runtime error") ## Build an accurate error for a file-IPC game-command timeout. ## Distinguishes "game not running" from "game running but unresponsive ## (likely paused by a runtime error / breakpoint)" so callers aren't misled ## into thinking the MCP connection is dead or the autoload is missing. func build_timeout_error(timeout_sec: float) -> Dictionary: # Re-check play state at the moment we give up. if not get_editor().is_playing_scene(): return error( -32000, "Game command timed out after %.1fs and the game process is no longer running." % timeout_sec, { "game_running": false, "suggestion": "The scene stopped. Call play_scene to start it again before sending runtime commands.", } ) # The game IS running. Figure out *why* it didn't answer. var paused := is_debugger_paused() var runtime_errors := collect_debugger_errors(10) var data := { "game_running": true, "debugger_paused": paused, } if not runtime_errors.is_empty(): data["runtime_errors"] = runtime_errors var msg: String if paused or not runtime_errors.is_empty(): msg = ("Game command timed out after %.1fs, but the game IS running. " % timeout_sec) \ + "A runtime/script error paused the scene, so it could not respond to the command." data["suggestion"] = "This is NOT a connection or autoload problem. Fix the error in 'runtime_errors' " \ + "(or call get_editor_errors for the full list), then retry. The debugger was auto-resumed; " \ + "if errors persist, call stop_scene then play_scene to restart cleanly." else: msg = ("Game command timed out after %.1fs. The game is running but did not respond in time." % timeout_sec) data["suggestion"] = "The MCP server connection is fine and the game is running. The command may be slow " \ + "or the game may be busy/blocked. Retry with a longer timeout, and call get_editor_errors to check " \ + "for runtime errors. In rare cases (custom projects) verify the MCPGameInspector autoload is active." return error(-32000, msg, data) ## Find node by path in edited scene func find_node_by_path(node_path: String) -> Node: var root := get_edited_root() if root == null: return null if node_path == "." or node_path == root.name: return root # Try relative from root if root.has_node(node_path): return root.get_node(node_path) # Try with root name prefix stripped if node_path.begins_with(root.name + "/"): var rel := node_path.substr(root.name.length() + 1) if root.has_node(rel): return root.get_node(rel) return null