2026-10-09 12:49:07 +02:00

285 lines
12 KiB
GDScript

@tool
extends RefCounted
## signal.* command handlers — list, manage (connect/disconnect), emit on edited-scene nodes.
const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd")
const Coerce = Modules.Coerce
const Helpers = Modules.CommandHelpers
const SignalPairResolver := preload("res://addons/godot_mcp_toolkit/scene/signal_pair_resolver.gd")
static var _extends_path_re: RegEx = _compile_extends_path_re()
static func _compile_extends_path_re() -> RegEx:
var re := RegEx.new()
re.compile('extends\\s+"(res://[^"]+)"')
return re
static func register(registry: MCPToolkitCommandRegistry, _server: Node) -> void:
registry.add("signal.list", func(parameters: Dictionary) -> Dictionary:
return _cmd_signal_list(parameters)
, MCPToolkitCommandOptions.new().mark_read_only())
registry.add("signal.manage", func(parameters: Dictionary) -> Dictionary:
return _cmd_signal_manage(parameters)
, MCPToolkitCommandOptions.new())
registry.add("signal.emit", func(parameters: Dictionary) -> Dictionary:
return _cmd_signal_emit(parameters)
, MCPToolkitCommandOptions.new())
# -- Helpers ------------------------------------------------------------------
static func _get_edited_root() -> Node:
return Helpers.get_edited_root()
static func _resolve_scene_node(node_path: String) -> Variant:
return Helpers.resolve_scene_node(node_path)
## Walk extends "res://..." directives via raw file reads when get_base_script()
## returns null (broken compilation chain). Returns true if any ancestor source
## contains the method definition.
static func _source_walk_has_method(source: String, method_name: String) -> bool:
var depth := 0
while depth < 50:
var m := _extends_path_re.search(source)
if m == null:
return false
var parent_path := m.get_string(1)
if not FileAccess.file_exists(parent_path):
return false
source = FileAccess.get_file_as_string(parent_path)
if source.find("func %s" % method_name) >= 0:
return true
depth += 1
return false
static func _signal_list_of(
node: Object, include_connections: bool = false, root: Node = null,
) -> Array:
var result: Array = []
for signal_info in node.get_signal_list():
var signal_name := str(signal_info.get("name", ""))
var arguments: Array = []
for argument in signal_info.get("args", []):
arguments.append({
"name": str(argument.get("name", "")),
"type": int(argument.get("type", 0)),
})
var entry := {
"name": signal_name,
"args": arguments,
}
if include_connections:
var connections: Array = []
for conn in node.get_signal_connection_list(signal_name):
var callable: Callable = conn.get("callable", Callable())
var target_obj = callable.get_object()
var target_path := ""
if target_obj is Node and root != null:
if target_obj == root:
target_path = "."
else:
target_path = str(root.get_path_to(target_obj))
elif target_obj != null:
target_path = str(target_obj.get_class())
else:
target_path = "<freed>"
connections.append({
"target_path": target_path,
"method_name": callable.get_method(),
"flags": int(conn.get("flags", 0)),
})
entry["connections"] = connections
result.append(entry)
return result
## Editor wrapper over the shared SignalPairResolver. It normalizes the editor-
## relative paths, distinguishes the no-edited-scene case as NO_SCENE (the shared
## resolver only knows NOT_FOUND), delegates the structural validation to the shared
## resolver, then — on a has_signal/has_method failure — layers the editor-only hint
## enrichment (instanced-scene hint, script-source method-walk) the runtime path
## deliberately omits. The shared resolver owns the skeleton; this owns the hints.
static func _resolve_signal_pair(parameters: Variant) -> Dictionary:
if typeof(parameters) != TYPE_DICTIONARY:
return {"code": "INVALID_PARAMS", "error": "params must be an object"}
# Normalize /root/… paths to editor-relative form before resolving, and keep the
# original guard ORDER exactly: required-params first, then no-edited-scene
# (NO_SCENE, which the shared resolver can't distinguish from NOT_FOUND).
var source_path := Helpers.normalize_editor_path(
str(parameters.get("node_path", parameters.get("source_path", ""))))
var signal_name := str(parameters.get("signal_name", ""))
var target_path := Helpers.normalize_editor_path(str(parameters.get("target_path", "")))
var method_name := str(parameters.get("method_name", ""))
if source_path.is_empty() or signal_name.is_empty() \
or target_path.is_empty() or method_name.is_empty():
return {"code": "INVALID_PARAMS",
"error": "node_path, signal_name, target_path, method_name are all required"}
var root := _get_edited_root()
if root == null:
return {"code": "NO_SCENE", "error": "no edited scene"}
# Delegate the structural validation (node resolution + has_signal/has_method)
# to the shared resolver with the already-normalized paths; it echoes those
# normalized paths in its NOT_FOUND messages, matching the pre-extraction output.
var normalized := {
"source_path": source_path,
"target_path": target_path,
"signal_name": signal_name,
"method_name": method_name,
}
var resolved := SignalPairResolver.resolve_pair(normalized, root)
if not resolved.has("error"):
return resolved
# Layer editor-only hints onto the two validation failures (signal/method
# missing). A NOT_FOUND (source/target missing) passes through unchanged.
if str(resolved.get("code", "")) != "INVALID_PARAMS":
return resolved
var source = _resolve_scene_node(source_path)
if source != null and not source.has_signal(signal_name):
var instance_hint := ""
var check: Node = source
while check != null and check != root:
if check.scene_file_path != "":
instance_hint = ". Node is instanced from %s — open that scene to connect script-defined signals" % check.scene_file_path
break
check = check.get_parent()
return {"code": "INVALID_PARAMS",
"error": "signal %s not on %s%s" % [signal_name, source_path, instance_hint]}
var target = _resolve_scene_node(target_path)
if target != null and not target.has_method(method_name):
var method_hint := ""
var scr := target.get_script() as Script
if scr == null:
method_hint = "; no script is attached — use node_set_script first, or connect via _ready() code"
elif scr is GDScript:
var found_in_source := false
var walk: GDScript = scr
while walk != null:
if walk.source_code.find("func %s" % method_name) >= 0:
found_in_source = true
break
var next := walk.get_base_script() as GDScript
if next == null:
# Compiled chain broke — walk remaining ancestors via source text
found_in_source = _source_walk_has_method(
walk.source_code, method_name)
break
walk = next
if found_in_source:
method_hint = "; method found in script source but not visible — the script (or a parent script) likely has compilation errors (check editor_get_console)"
else:
method_hint = "; script %s is attached but does not define this method — check spelling and inheritance chain" % scr.resource_path
return {"code": "INVALID_PARAMS",
"error": "method %s not on %s%s" % [method_name, target_path, method_hint]}
# INVALID_PARAMS with no scene node to hint on (e.g. missing params) — pass through.
return resolved
# -- Commands -----------------------------------------------------------------
static func _cmd_signal_list(parameters: Dictionary) -> Dictionary:
var root := _get_edited_root()
if root == null:
return MCPToolkitError.fail("NO_SCENE", "no edited scene")
var node_path := str(parameters.get("node_path", ""))
node_path = Helpers.normalize_editor_path(node_path)
var include_connections: bool = bool(parameters.get("include_connections", false))
var node = _resolve_scene_node(node_path)
if node == null:
return MCPToolkitError.fail("NOT_FOUND", "node not found: %s" % node_path, MCPToolkitError.HINT_NODE_PATH)
return MCPToolkitSuccess.ok({"path": node_path, "signals": _signal_list_of(node, include_connections, root)})
static func _cmd_signal_manage(parameters: Dictionary) -> Dictionary:
var action := str(parameters.get("action", ""))
if not (action in ["connect", "disconnect"]):
return MCPToolkitError.fail("INVALID_PARAMS",
"action must be 'connect' or 'disconnect' (got '%s')" % action)
var resolved := _resolve_signal_pair(parameters)
if resolved.has("error"):
return MCPToolkitError.fail(str(resolved["code"]), str(resolved["error"]))
var source = resolved["source"]
var callable: Callable = resolved["callable"]
var signal_name: String = str(resolved["signal_name"])
var source_path: String = str(resolved["source_path"])
var target_path: String = str(resolved["target_path"])
var method_name: String = str(resolved["method_name"])
if action == "connect":
if source.is_connected(signal_name, callable):
return MCPToolkitSuccess.ok({
"status": "returned",
"node_path": source_path,
"signal": signal_name,
"target_path": target_path,
"method": method_name,
})
source.connect(signal_name, callable, Object.CONNECT_PERSIST)
MCPToolkitUndoRedoAction.begin("connect %s.%s -> %s.%s" % [
source_path, signal_name, target_path, method_name], source) \
.do_method(source.connect.bind(signal_name, callable, Object.CONNECT_PERSIST)) \
.undo_method(source.disconnect.bind(signal_name, callable)) \
.commit_recorded()
var response := MCPToolkitSuccess.ok({
"status": "created",
"node_path": source_path,
"signal": signal_name,
"target_path": target_path,
"method": method_name,
})
var target = resolved["target"]
var same_scene: bool = (source.owner == target.owner) \
or (source == target.owner) or (target == source.owner)
if not same_scene:
response["hint"] = "Cross-scene connections (nodes from different .tscn files) cannot persist — use _ready() code instead."
else:
response["hint"] = "Save the scene to persist this connection."
return response
else:
if not source.is_connected(signal_name, callable):
return MCPToolkitError.fail("NOT_FOUND", "no connection to disconnect")
source.disconnect(signal_name, callable)
MCPToolkitUndoRedoAction.begin("disconnect %s.%s -> %s.%s" % [
source_path, signal_name, target_path, method_name], source) \
.do_method(source.disconnect.bind(signal_name, callable)) \
.undo_method(source.connect.bind(signal_name, callable, Object.CONNECT_PERSIST)) \
.commit_recorded()
return MCPToolkitSuccess.ok({
"node_path": source_path,
"signal": signal_name,
"target_path": target_path,
"method": method_name,
})
static func _cmd_signal_emit(parameters: Dictionary) -> Dictionary:
var root := _get_edited_root()
if root == null:
return MCPToolkitError.fail("NO_SCENE", "no edited scene")
var node_path := str(parameters.get("node_path", ""))
node_path = Helpers.normalize_editor_path(node_path)
var signal_name := str(parameters.get("signal_name", ""))
if signal_name.is_empty():
return MCPToolkitError.fail("INVALID_PARAMS", "missing signal")
var node = _resolve_scene_node(node_path)
if node == null:
return MCPToolkitError.fail("NOT_FOUND", "node not found: %s" % node_path, MCPToolkitError.HINT_NODE_PATH)
if not node.has_signal(signal_name):
return MCPToolkitError.fail("INVALID_PARAMS",
"signal %s not on %s" % [signal_name, node_path])
var raw_args = parameters.get("args", [])
if typeof(raw_args) != TYPE_ARRAY:
raw_args = []
var coerced: Array = [signal_name]
for argument in raw_args:
var coerced_arg = Coerce.coerce_value(argument)
if typeof(coerced_arg) == TYPE_DICTIONARY and (coerced_arg as Dictionary).has("_coerce_error"):
return MCPToolkitError.fail("INVALID_PARAMS", str(coerced_arg["_coerce_error"]))
coerced.append(coerced_arg)
node.callv("emit_signal", coerced)
return MCPToolkitSuccess.ok()