Files
tekton/addons/godot_ai/handlers/signal_handler.gd
T

269 lines
10 KiB
GDScript

@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/<Name>`` 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/<Name>/...``) 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,
}