@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, Object.CONNECT_PERSIST) _undo_redo.add_undo_method(source, "disconnect", signal_name, callable) _undo_redo.commit_action() return {"data": _signal_response(source, signal_name, target, method, scene_root)} func disconnect_signal(params: Dictionary) -> Dictionary: var resolved := _resolve_signal_params(params) if resolved.has("error"): return resolved var source: Node = resolved.source var target: Node = resolved.target var signal_name: String = resolved.signal_name var method: String = resolved.method var scene_root: Node = resolved.scene_root var callable := Callable(target, method) if not source.is_connected(signal_name, callable): return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Signal '%s' is not connected to %s.%s" % [signal_name, params.target, method]) # Capture the connection's current flags so undo restores it exactly as it # was, not unconditionally as CONNECT_PERSIST. Hardcoding PERSIST here would # silently promote a runtime-only connection into one that serializes on the # next save. (The connection still exists at this point — checked above.) var reconnect_flags := 0 for conn in source.get_signal_connection_list(signal_name): if conn.get("callable", Callable()) == callable: reconnect_flags = int(conn.get("flags", 0)) break _undo_redo.create_action("MCP: Disconnect signal %s" % signal_name) _undo_redo.add_do_method(source, "disconnect", signal_name, callable) _undo_redo.add_undo_method(source, "connect", signal_name, callable, reconnect_flags) _undo_redo.commit_action() return {"data": _signal_response(source, signal_name, target, method, scene_root)} func _resolve_signal_params(params: Dictionary) -> Dictionary: for key in ["path", "signal", "target", "method"]: ## Type-check before calling .is_empty(): a non-string value (e.g. an ## int or dict) has no is_empty() and would crash the handler, which ## the dispatcher only reports as an opaque "malformed result" (#210). var value = params.get(key, "") var type_err = McpParamValidators.require_string(key, value) if type_err != null: return type_err if value.is_empty(): return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % key) var _scene_check := McpNodeValidator.require_scene_or_error() if _scene_check.has("error"): return _scene_check var scene_root: Node = _scene_check.scene_root var source_result := _resolve_node_or_autoload(params.path, scene_root, "Source") if source_result.has("error"): return source_result var source: Node = source_result.node var target_result := _resolve_node_or_autoload(params.target, scene_root, "Target") if target_result.has("error"): return target_result var target: Node = target_result.node return { "source": source, "target": target, "signal_name": params.signal, "method": params.method, "scene_root": scene_root, } ## Resolve a path to a Node, with three distinct outcomes: ## 1. Found in the edited scene tree → returns {node} ## 2. Declared as an autoload AND instantiated at edit time → returns {node} ## 3. Declared as an autoload but NOT instantiated at edit time → returns ## INVALID_PARAMS with guidance. Most autoloads are runtime-only, so a ## silent "not found" hides the real reason the connection can't be made. ## 4. Not in scene and not a declared autoload → returns INVALID_PARAMS. func _resolve_node_or_autoload(path: String, scene_root: Node, role: String) -> Dictionary: var node := McpScenePath.resolve(path, scene_root) if node != null: return {"node": node} var name := path.trim_prefix("/") if ProjectSettings.has_setting("autoload/" + name): # Autoload is declared — see if the editor has it instanced. var tree := Engine.get_main_loop() if tree is SceneTree: var live := (tree as SceneTree).root.get_node_or_null(name) if live != null: return {"node": live} return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "%s '%s' is a declared autoload but isn't instantiated in the editor. " % [role, name] + "Most autoloads are runtime-only; edit-time signal connection isn't supported for them. " + "Connect it from a script attached to the scene using @onready + connect(), " + "or enable editor-instancing for this autoload in Project Settings > Autoload.") return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, "%s node not found: %s (not in scene tree or autoloads)" % [role, path]) func _signal_response(source: Node, signal_name: String, target: Node, method: String, scene_root: Node) -> Dictionary: return { "source": McpScenePath.from_node(source, scene_root), "signal": signal_name, "target": McpScenePath.from_node(target, scene_root), "method": method, "undoable": true, }