@tool extends Node class_name ProjectTools ## Project configuration and debug tools for MCP. ## Handles: get_project_settings, list_settings, update_project_settings, ## get_input_map, configure_input_map, get_collision_layers, ## get_node_properties, setup_autoload, ## get_console_log, get_errors, clear_console_log, ## open_in_godot, scene_tree_dump var _editor_plugin: EditorPlugin = null # Cached reference to the editor Output panel's RichTextLabel. var _editor_log_rtl: RichTextLabel = null # Character offset for clear_console_log. var _clear_char_offset: int = 0 func set_editor_plugin(plugin: EditorPlugin) -> void: _editor_plugin = plugin # ============================================================================= # get_project_settings # ============================================================================= func get_project_settings(args: Dictionary) -> Dictionary: var include_render: bool = bool(args.get(&"include_render", true)) var include_physics: bool = bool(args.get(&"include_physics", true)) var out: Dictionary = {} out[&"main_scene"] = str(ProjectSettings.get_setting("application/run/main_scene", "")) # Window size var width = ProjectSettings.get_setting("display/window/size/viewport_width", null) var height = ProjectSettings.get_setting("display/window/size/viewport_height", null) if width != null: out[&"window_width"] = int(width) if height != null: out[&"window_height"] = int(height) # Stretch var stretch_mode = ProjectSettings.get_setting("display/window/stretch/mode", null) var stretch_aspect = ProjectSettings.get_setting("display/window/stretch/aspect", null) if stretch_mode != null: out[&"stretch_mode"] = str(stretch_mode) if stretch_aspect != null: out[&"stretch_aspect"] = str(stretch_aspect) if include_physics: var pps = ProjectSettings.get_setting("physics/common/physics_ticks_per_second", null) if pps != null: out[&"physics_ticks_per_second"] = int(pps) if include_render: var method = ProjectSettings.get_setting("rendering/renderer/rendering_method", null) if method != null: out[&"rendering_method"] = str(method) var vsync = ProjectSettings.get_setting("display/window/vsync/vsync_mode", null) if vsync != null: out[&"vsync"] = str(vsync) return {&"ok": true, &"settings": out} # ============================================================================= # list_settings # ============================================================================= func list_settings(args: Dictionary) -> Dictionary: var category: String = str(args.get(&"category", "")) var properties: Array = ProjectSettings.get_property_list() if category.strip_edges().is_empty(): var categories: Dictionary = {} for prop: Dictionary in properties: var prop_name: String = prop[&"name"] if prop_name.is_empty() or prop_name.begins_with("_"): continue var slash_idx := prop_name.find("/") if slash_idx == -1: continue var cat: String = prop_name.substr(0, slash_idx) categories[cat] = categories.get(cat, 0) + 1 return {&"ok": true, &"categories": categories, &"hint": "Pass a category name to list its settings with current values and valid options."} var settings: Array = [] for prop: Dictionary in properties: var prop_name: String = prop[&"name"] if not prop_name.begins_with(category + "/"): continue if prop_name.begins_with("_"): continue var info: Dictionary = { &"path": prop_name, &"type": _type_to_string(prop[&"type"]), &"value": _serialize_value(ProjectSettings.get_setting(prop_name)) } var hint: int = prop.get(&"hint", 0) var hint_string: String = str(prop.get(&"hint_string", "")) if hint == PROPERTY_HINT_ENUM and not hint_string.is_empty(): info[&"enum_values"] = hint_string elif hint == PROPERTY_HINT_RANGE and not hint_string.is_empty(): info[&"range"] = hint_string settings.append(info) return {&"ok": true, &"category": category, &"settings": settings, &"count": settings.size()} # ============================================================================= # update_project_settings # ============================================================================= func update_project_settings(args: Dictionary) -> Dictionary: var settings = args.get(&"settings", {}) if not settings is Dictionary or settings.is_empty(): return {&"ok": false, &"error": "Missing or empty 'settings' dictionary. Use list_settings to discover available setting paths."} var updated: Array = [] for key: String in settings: ProjectSettings.set_setting(key, settings[key]) updated.append(key) _save_and_refresh_settings() return {&"ok": true, &"updated": updated, &"count": updated.size()} # ============================================================================= # get_input_map # ============================================================================= func get_input_map(args: Dictionary) -> Dictionary: var include_deadzones: bool = bool(args.get(&"include_deadzones", true)) var actions: Array = InputMap.get_actions() actions.sort() var result: Dictionary = {} for action: StringName in actions: var events: Array = [] for e: InputEvent in InputMap.action_get_events(action): var item := {&"type": e.get_class()} if e is InputEventKey: var keycode = e.physical_keycode if e.physical_keycode != 0 else e.keycode item[&"keycode"] = keycode item[&"key_label"] = OS.get_keycode_string(keycode) if keycode != 0 else "" elif e is InputEventMouseButton: item[&"button_index"] = e.button_index elif e is InputEventJoypadButton: item[&"button_index"] = e.button_index elif e is InputEventJoypadMotion: item[&"axis"] = e.axis if include_deadzones: item[&"axis_value"] = e.axis_value events.append(item) result[action] = events return {&"ok": true, &"actions": result, &"count": result.size()} # ============================================================================= # configure_input_map # ============================================================================= func configure_input_map(args: Dictionary) -> Dictionary: var action: String = str(args.get(&"action", "")) var operation: String = str(args.get(&"operation", "")) if action.strip_edges().is_empty(): return {&"ok": false, &"error": "Missing 'action' name"} if operation.strip_edges().is_empty(): return {&"ok": false, &"error": "Missing 'operation'. Use: add, remove, set"} match operation: "add": return _input_map_add(action, args) "remove": return _input_map_remove(action) "set": return _input_map_set(action, args) _: return {&"ok": false, &"error": "Unknown operation: %s. Use: add, remove, set" % operation} func _input_map_add(action: String, args: Dictionary) -> Dictionary: var deadzone: float = float(args.get(&"deadzone", 0.5)) var events_data: Array = args.get(&"events", []) var created := false if not InputMap.has_action(action): InputMap.add_action(action, deadzone) created = true var added_events: Array = [] var event_errors: Array = [] for event_desc in events_data: if not event_desc is Dictionary: continue var result: Dictionary = _create_input_event(event_desc) if result.has(&"error"): event_errors.append(result[&"error"]) continue InputMap.action_add_event(action, result[&"event"]) added_events.append(_describe_event(result[&"event"])) _persist_action(action) _save_and_refresh_settings() _try_refresh_input_map_ui() var msg := "Action '%s' %s" % [action, "created" if created else "updated"] if added_events.size() > 0: msg += " with %d event(s)" % added_events.size() var out: Dictionary = {&"ok": true, &"message": msg, &"events_added": added_events} if event_errors.size() > 0: out[&"event_errors"] = event_errors return out func _input_map_remove(action: String) -> Dictionary: if not InputMap.has_action(action): return {&"ok": false, &"error": "Action not found: " + action} InputMap.erase_action(action) if ProjectSettings.has_setting("input/" + action): ProjectSettings.clear("input/" + action) _save_and_refresh_settings() _try_refresh_input_map_ui() return {&"ok": true, &"message": "Removed action: " + action} func _input_map_set(action: String, args: Dictionary) -> Dictionary: var deadzone: float = float(args.get(&"deadzone", 0.5)) var events_data: Array = args.get(&"events", []) if InputMap.has_action(action): InputMap.erase_action(action) InputMap.add_action(action, deadzone) var added_events: Array = [] var event_errors: Array = [] for event_desc in events_data: if not event_desc is Dictionary: continue var result: Dictionary = _create_input_event(event_desc) if result.has(&"error"): event_errors.append(result[&"error"]) continue InputMap.action_add_event(action, result[&"event"]) added_events.append(_describe_event(result[&"event"])) _persist_action(action) _save_and_refresh_settings() _try_refresh_input_map_ui() var out: Dictionary = {&"ok": true, &"message": "Set action '%s' with %d event(s)" % [action, added_events.size()], &"events": added_events} if event_errors.size() > 0: out[&"event_errors"] = event_errors return out func _create_input_event(desc: Dictionary) -> Dictionary: var type: String = str(desc.get(&"type", "")) match type: "key": var key_string: String = str(desc.get(&"key", "")) if key_string.is_empty(): return {&"error": "Missing 'key' for key event"} var event := InputEventKey.new() var keycode := OS.find_keycode_from_string(key_string) if keycode == 0: return {&"error": "Unknown key: " + key_string} event.physical_keycode = keycode return {&"event": event} "mouse_button": var button_index: int = int(desc.get(&"button_index", 0)) if button_index <= 0: return {&"error": "Invalid 'button_index' for mouse_button (must be >= 1: 1=left, 2=right, 3=middle)"} var event := InputEventMouseButton.new() event.button_index = button_index return {&"event": event} "joypad_button": var button_index: int = int(desc.get(&"button_index", -1)) if button_index < 0: return {&"error": "Missing or invalid 'button_index' for joypad_button"} var event := InputEventJoypadButton.new() event.button_index = button_index return {&"event": event} "joypad_motion": var axis: int = int(desc.get(&"axis", -1)) if axis < 0: return {&"error": "Missing or invalid 'axis' for joypad_motion"} var axis_value: float = float(desc.get(&"axis_value", 0.0)) var event := InputEventJoypadMotion.new() event.axis = axis event.axis_value = axis_value return {&"event": event} _: return {&"error": "Unknown event type: '%s'. Use: key, mouse_button, joypad_button, joypad_motion" % type} func _save_and_refresh_settings() -> void: ProjectSettings.save() ProjectSettings.notify_property_list_changed() func _try_refresh_input_map_ui() -> void: if not _editor_plugin: return var base := _editor_plugin.get_editor_interface().get_base_control() var pse := _find_node_by_class(base, "ProjectSettingsEditor") if not pse: return if pse.has_method("_update_action_map_editor"): pse.call("_update_action_map_editor") else: push_warning("[Godot MCP] Input map changed and saved, but the editor UI could not refresh. Reopen Project Settings to see changes.") func _persist_action(action: String) -> void: if not InputMap.has_action(action): return var deadzone: float = InputMap.action_get_deadzone(action) var events: Array = InputMap.action_get_events(action) ProjectSettings.set_setting("input/" + action, { "deadzone": deadzone, "events": events }) func _describe_event(event: InputEvent) -> String: if event is InputEventKey: var keycode: int = event.physical_keycode if event.physical_keycode != 0 else event.keycode var label: String = OS.get_keycode_string(keycode) if keycode != 0 else "Unknown" return "Key: " + label elif event is InputEventMouseButton: return "Mouse Button: " + str(event.button_index) elif event is InputEventJoypadButton: return "Joypad Button: " + str(event.button_index) elif event is InputEventJoypadMotion: return "Joypad Axis: %d (%.1f)" % [event.axis, event.axis_value] return event.get_class() # ============================================================================= # get_collision_layers # ============================================================================= func get_collision_layers(_args: Dictionary) -> Dictionary: var layers_2d: Array = _collect_layers("layer_names/2d_physics") var layers_3d: Array = _collect_layers("layer_names/3d_physics") return {&"ok": true, &"layers_2d": layers_2d, &"layers_3d": layers_3d} func _collect_layers(prefix: String) -> Array: var out: Array = [] for i: int in range(1, 33): var key := "%s/layer_%d" % [prefix, i] var layer_name := str(ProjectSettings.get_setting(key, "")) if not layer_name.is_empty(): out.append({&"index": i, &"name": layer_name}) return out # ============================================================================= # get_node_properties # ============================================================================= const _SKIP_PROPS: Dictionary = { "script": true, "owner": true, "scene_file_path": true, "unique_name_in_owner": true, } const ENUM_HINTS = { "anchors_preset": "0:Top Left,1:Top Right,2:Bottom Right,3:Bottom Left,4:Center Left,5:Center Top,6:Center Right,7:Center Bottom,8:Center,9:Left Wide,10:Top Wide,11:Right Wide,12:Bottom Wide,13:VCenter Wide,14:HCenter Wide,15:Full Rect", "grow_horizontal": "0:Begin,1:End,2:Both", "grow_vertical": "0:Begin,1:End,2:Both", "horizontal_alignment": "0:Left,1:Center,2:Right,3:Fill", "vertical_alignment": "0:Top,1:Center,2:Bottom,3:Fill" } func get_node_properties(args: Dictionary) -> Dictionary: var node_type: String = str(args.get(&"node_type", "")) if node_type.strip_edges().is_empty(): return {&"ok": false, &"error": "Missing 'node_type'"} if not ClassDB.class_exists(node_type): return {&"ok": false, &"error": "Unknown node type: " + node_type} var temp = ClassDB.instantiate(node_type) if not temp: return {&"ok": false, &"error": "Cannot instantiate: " + node_type} var properties: Array = [] for prop: Dictionary in temp.get_property_list(): var prop_name: String = prop[&"name"] if prop_name.begins_with("_"): continue if _SKIP_PROPS.has(prop_name): continue if not (prop.get(&"usage", 0) & PROPERTY_USAGE_EDITOR): continue var info := { &"name": prop_name, &"type": _type_to_string(prop[&"type"]), &"default": _serialize_value(temp.get(prop_name)) } # Enum hints if prop.has(&"hint") and prop[&"hint"] == PROPERTY_HINT_ENUM and prop.has(&"hint_string"): info[&"enum_values"] = prop[&"hint_string"] if prop_name in ENUM_HINTS: info[&"enum_values"] = ENUM_HINTS[prop_name] properties.append(info) temp.queue_free() # Inheritance chain var chain: Array = [] var cls: String = node_type while cls != "": chain.append(cls) cls = ClassDB.get_parent_class(cls) return {&"ok": true, &"node_type": node_type, &"inheritance_chain": chain, &"property_count": properties.size(), &"properties": properties} func _type_to_string(type_id: int) -> String: match type_id: TYPE_BOOL: return "bool" TYPE_INT: return "int" TYPE_FLOAT: return "float" TYPE_STRING: return "String" TYPE_VECTOR2: return "Vector2" TYPE_VECTOR3: return "Vector3" TYPE_VECTOR2I: return "Vector2i" TYPE_VECTOR3I: return "Vector3i" TYPE_COLOR: return "Color" TYPE_RECT2: return "Rect2" TYPE_OBJECT: return "Resource" TYPE_ARRAY: return "Array" TYPE_DICTIONARY: return "Dictionary" _: return "Variant" func _serialize_value(value: Variant) -> Variant: match typeof(value): TYPE_VECTOR2: return {&"type": &"Vector2", &"x": value.x, &"y": value.y} TYPE_VECTOR3: return {&"type": &"Vector3", &"x": value.x, &"y": value.y, &"z": value.z} TYPE_COLOR: return {&"type": &"Color", &"r": value.r, &"g": value.g, &"b": value.b, &"a": value.a} TYPE_VECTOR2I: return {&"type": &"Vector2i", &"x": value.x, &"y": value.y} TYPE_VECTOR3I: return {&"type": &"Vector3i", &"x": value.x, &"y": value.y, &"z": value.z} TYPE_OBJECT: if value and value is Resource and value.resource_path: return {&"type": &"Resource", &"path": value.resource_path} return null _: return value # ============================================================================= # setup_autoload # ============================================================================= func setup_autoload(args: Dictionary) -> Dictionary: var operation: String = str(args.get(&"operation", "")) if operation.strip_edges().is_empty(): return {&"ok": false, &"error": "Missing 'operation'. Use: add, remove, list"} match operation: "list": return _autoload_list() "add": return _autoload_add(args) "remove": return _autoload_remove(args) _: return {&"ok": false, &"error": "Unknown operation: %s. Use: add, remove, list" % operation} func _autoload_list() -> Dictionary: var autoloads: Array = [] for prop: Dictionary in ProjectSettings.get_property_list(): var prop_name: String = prop[&"name"] if not prop_name.begins_with("autoload/"): continue var al_name: String = prop_name.substr(9) var al_path: String = str(ProjectSettings.get_setting(prop_name, "")) var enabled: bool = al_path.begins_with("*") if enabled: al_path = al_path.substr(1) autoloads.append({&"name": al_name, &"path": al_path, &"enabled": enabled}) return {&"ok": true, &"autoloads": autoloads, &"count": autoloads.size()} func _autoload_add(args: Dictionary) -> Dictionary: var autoload_name: String = str(args.get(&"name", "")) var path: String = str(args.get(&"path", "")) if autoload_name.strip_edges().is_empty(): return {&"ok": false, &"error": "Missing 'name'"} if path.strip_edges().is_empty(): return {&"ok": false, &"error": "Missing 'path' for add operation"} if not path.begins_with("res://"): path = "res://" + path if not FileAccess.file_exists(path): return {&"ok": false, &"error": "File not found: " + path} var setting_key := "autoload/" + autoload_name ProjectSettings.set_setting(setting_key, "*" + path) _save_and_refresh_settings() return {&"ok": true, &"message": "Registered autoload: %s -> %s" % [autoload_name, path]} func _autoload_remove(args: Dictionary) -> Dictionary: var autoload_name: String = str(args.get(&"name", "")) if autoload_name.strip_edges().is_empty(): return {&"ok": false, &"error": "Missing 'name'"} var setting_key := "autoload/" + autoload_name if not ProjectSettings.has_setting(setting_key): return {&"ok": false, &"error": "Autoload not found: " + autoload_name} ProjectSettings.clear(setting_key) _save_and_refresh_settings() return {&"ok": true, &"message": "Unregistered autoload: " + autoload_name} # ============================================================================= # Editor Output Panel access # ============================================================================= # We read directly from the editor's internal EditorLog RichTextLabel. # This is real-time and matches exactly what the user sees in the Output panel. # ============================================================================= func _get_editor_log_rtl() -> RichTextLabel: """Find (and cache) the RichTextLabel inside the editor's Output panel.""" if is_instance_valid(_editor_log_rtl): return _editor_log_rtl if not _editor_plugin: return null var base := _editor_plugin.get_editor_interface().get_base_control() var editor_log := _find_node_by_class(base, "EditorLog") if editor_log: _editor_log_rtl = _find_child_rtl(editor_log) return _editor_log_rtl func _find_node_by_class(root: Node, cls_name: String) -> Node: if root.get_class() == cls_name: return root for child: Node in root.get_children(): var found := _find_node_by_class(child, cls_name) if found: return found return null func _find_child_rtl(node: Node) -> RichTextLabel: for child: Node in node.get_children(): if child is RichTextLabel: return child var found := _find_child_rtl(child) if found: return found return null func _read_output_panel_lines() -> Array: """Return all non-empty lines from the editor Output panel (after clear offset).""" var rtl := _get_editor_log_rtl() if not rtl: return [] var full_text: String = rtl.get_parsed_text() if _clear_char_offset > 0 and _clear_char_offset < full_text.length(): full_text = full_text.substr(_clear_char_offset) elif _clear_char_offset >= full_text.length(): return [] var lines: Array = [] for line: String in full_text.split("\n"): if not line.strip_edges().is_empty(): lines.append(line) return lines # ============================================================================= # get_console_log # ============================================================================= func get_console_log(args: Dictionary) -> Dictionary: var max_lines: int = int(args.get(&"max_lines", 50)) var rtl := _get_editor_log_rtl() if not rtl: return {&"ok": false, &"error": "Could not access the Godot editor Output panel. Make sure the MCP plugin is enabled and running inside the Godot editor."} var all_lines := _read_output_panel_lines() var start := maxi(0, all_lines.size() - max_lines) var lines := all_lines.slice(start) return {&"ok": true, &"lines": lines, &"line_count": lines.size(), &"content": "\n".join(lines)} # ============================================================================= # get_errors # ============================================================================= const _ERROR_PREFIXES: PackedStringArray = [ "ERROR:", "SCRIPT ERROR:", "USER ERROR:", "WARNING:", "USER WARNING:", "SCRIPT WARNING:", "Parse Error:", "Invalid", ] func get_errors(args: Dictionary) -> Dictionary: var max_errors: int = int(args.get(&"max_errors", 50)) var include_warnings: bool = bool(args.get(&"include_warnings", true)) var rtl := _get_editor_log_rtl() if not rtl: return {&"ok": false, &"error": "Could not access the Godot editor Output panel. Make sure the MCP plugin is enabled and running inside the Godot editor."} var all_lines := _read_output_panel_lines() var all_errors: Array = [] for i: int in range(all_lines.size()): var line: String = all_lines[i].strip_edges() if line.is_empty(): continue var is_error := false var severity := "error" for prefix: String in _ERROR_PREFIXES: if line.begins_with(prefix): is_error = true if "WARNING" in prefix: severity = "warning" break # Godot continuation lines: "at: res://path/file.gd:123" if not is_error and line.begins_with("at: ") and "res://" in line: if all_errors.size() > 0: var prev: Dictionary = all_errors[all_errors.size() - 1] var loc := _extract_file_line(line) if not loc.is_empty(): prev[&"file"] = loc.get(&"file", "") prev[&"line"] = loc.get(&"line", 0) continue if not is_error: continue if severity == "warning" and not include_warnings: continue var error_info := {&"message": line, &"severity": severity} var loc := _extract_file_line(line) if not loc.is_empty(): error_info[&"file"] = loc.get(&"file", "") error_info[&"line"] = loc.get(&"line", 0) all_errors.append(error_info) # Return the most recent errors var start := maxi(0, all_errors.size() - max_errors) var errors := all_errors.slice(start) return {&"ok": true, &"errors": errors, &"error_count": errors.size(), &"summary": "%d error(s) found" % errors.size()} func _extract_file_line(text: String) -> Dictionary: var idx := text.find("res://") if idx == -1: return {} var rest := text.substr(idx) var colon_idx := rest.find(":", 6) if colon_idx == -1: return {&"file": rest.strip_edges()} var file_path := rest.substr(0, colon_idx) var after_colon := rest.substr(colon_idx + 1) var line_str := "" for c in after_colon: if c.is_valid_int(): line_str += c else: break if not line_str.is_empty(): return {&"file": file_path, &"line": int(line_str)} return {&"file": file_path} # ============================================================================= # clear_console_log # ============================================================================= func clear_console_log(_args: Dictionary) -> Dictionary: var rtl := _get_editor_log_rtl() if not rtl: return {&"ok": false, &"error": "Could not access the Godot editor Output panel. Make sure the MCP plugin is enabled and running inside the Godot editor."} # Actually clear the editor Output panel rtl.clear() _clear_char_offset = 0 return {&"ok": true, &"message": "Console log cleared."} # ============================================================================= # open_in_godot # ============================================================================= func open_in_godot(args: Dictionary) -> Dictionary: var path: String = str(args.get(&"path", "")) var line: int = int(args.get(&"line", 0)) if path.strip_edges().is_empty(): return {&"ok": false, &"error": "Missing 'path'"} if not path.begins_with("res://"): path = "res://" + path if not _editor_plugin: return {&"ok": false, &"error": "Editor plugin not available"} var ei = _editor_plugin.get_editor_interface() if path.ends_with(".gd") or path.ends_with(".shader"): var script = load(path) if script: ei.edit_resource(script) if line > 0: ei.get_script_editor().goto_line(line - 1) else: return {&"ok": false, &"error": "Could not load: " + path} elif path.ends_with(".tscn") or path.ends_with(".scn"): ei.open_scene_from_path(path) else: var res = load(path) if res: ei.edit_resource(res) return {&"ok": true, &"message": "Opened %s%s" % [path, " at line %d" % line if line > 0 else ""]} # ============================================================================= # scene_tree_dump # ============================================================================= func scene_tree_dump(_args: Dictionary) -> Dictionary: if not _editor_plugin: return {&"ok": false, &"error": "Editor plugin not available"} var ei = _editor_plugin.get_editor_interface() var edited_scene = ei.get_edited_scene_root() if not edited_scene: return {&"ok": true, &"tree": "(no scene open)", &"message": "No scene is currently open in the editor"} var tree_text := _dump_node(edited_scene, 0) return {&"ok": true, &"tree": tree_text, &"scene_path": edited_scene.scene_file_path} func _dump_node(node: Node, depth: int) -> String: var indent := " ".repeat(depth) var line := "%s%s (%s)" % [indent, node.name, node.get_class()] var script = node.get_script() if script: line += " [%s]" % script.resource_path.get_file() var children := node.get_children() if children.is_empty(): return line var parts: PackedStringArray = [line] for child: Node in children: parts.append(_dump_node(child, depth + 1)) return "\n".join(parts)