269 lines
10 KiB
GDScript
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,
|
|
}
|