@tool class_name McpScenePath extends RefCounted const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") ## Utility for converting between Godot internal node paths and clean ## scene-relative paths like /Main/Camera3D. ## Return a clean path relative to the scene root (e.g. /Main/Camera3D). ## Returns "" when `node` is not the scene root or a descendant of it — ## without the ancestry guard, get_path_to() returns an empty NodePath that ## concatenates into a plausible-looking but invalid "/Main/". static func from_node(node: Node, scene_root: Node) -> String: if scene_root == null or node == null: return "" if node == scene_root: return "/" + scene_root.name if not scene_root.is_ancestor_of(node): return "" var relative := scene_root.get_path_to(node) return "/" + scene_root.name + "/" + str(relative) ## Resolve a clean scene path like "/Main/Camera3D" to the actual node. ## ## Accepts forms relative to the edited scene root: ## "/Main" — explicit root prefix (canonical) ## "/Main/Camera3D" — descendant path ## "Camera3D" — bare relative to scene_root ## "World/Ground" — nested bare relative to scene_root ## ## Also accepts SceneTree-style "/root/[/...]" as an alias for ## the edited scene root. Agents reach for /root/Foo right after creating a ## scene because that's where scenes live at runtime; we honor it so the call ## doesn't fail with a confusing "not found" error. The alias only kicks in ## when the segment after /root matches the scene root's name — paths like ## "/root/@EditorNode@.../Main/..." (returned by Node.get_path() in the editor) ## fall through to the absolute-path fallback unchanged. static func resolve(scene_path: String, scene_root: Node) -> Node: if scene_root == null: return null ## /root/[/...] alias: strip the /root prefix and recurse. ## Match the scene root by name explicitly so we don't capture editor- ## internal paths that legitimately live under /root. var alias_prefix := "/root/" + scene_root.name if scene_path == alias_prefix or scene_path.begins_with(alias_prefix + "/"): return resolve(scene_path.substr(5), scene_root) # keep leading slash var root_prefix := "/" + scene_root.name if scene_path == root_prefix: return scene_root if scene_path.begins_with(root_prefix + "/"): var relative := scene_path.substr(root_prefix.length() + 1) return scene_root.get_node_or_null(relative) # Try as-is (relative path, or absolute SceneTree path). return scene_root.get_node_or_null(scene_path) ## Return the edited scene root, or an error dict if the editor has no open ## scene or the open scene doesn't match `expected_scene_file`. ## ## `expected_scene_file` is the caller's `scene_file` parameter — an empty ## string means "target whatever is currently edited" (current behaviour, ## no guard). A non-empty value must match `scene_file_path` on the current ## edited scene root exactly, or we return EDITED_SCENE_MISMATCH so the ## caller can re-open the right scene. ## ## Shape on success: {"node": }. Shape on error matches ## `ErrorCodes.make()` so callers can propagate the result directly. static func require_edited_scene(expected_scene_file: String) -> Dictionary: var root := EditorInterface.get_edited_scene_root() if root == null: # Mirrors the structured payload that the Python-side require_writable # gate attaches for `playing` / `importing`. Together these cover the # three recoverable editor *states* (playing / importing / no_scene) # — the EDITOR_NOT_READY paths an AI caller can act on. Other # EDITOR_NOT_READY callsites describing internal-state failures # ("EditorFileSystem not available" etc.) intentionally don't carry # this payload because there's no useful caller hint to give. var err := ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "No scene open") err["error"]["data"] = { "editor_state": "no_scene", "retryable": false, "hint": ( "No scene is open. Call scene_open with a scene path " + "(e.g. \"res://main.tscn\") before issuing scene-mutating tools." ), } return err if not expected_scene_file.is_empty() and root.scene_file_path != expected_scene_file: var actual := root.scene_file_path if not root.scene_file_path.is_empty() else "" return ErrorCodes.make( ErrorCodes.EDITED_SCENE_MISMATCH, ( "Expected edited scene \"%s\" but \"%s\" is active. " + "Call scene_open(\"%s\") first, or omit scene_file to target the active scene." ) % [expected_scene_file, actual, expected_scene_file], ) return {"node": root} ## Format a "parent not found" error that names the path convention. ## Agents routinely try /root/Foo or absolute SceneTree paths; the bare ## "Parent not found: X" gave them no hint that paths are scene-relative. ## Wording is generic ("Paths are relative...") so the helper works for any ## param name (parent_path, new_parent, …). static func format_parent_error(path: String, scene_root: Node) -> String: if scene_root == null: return "Parent not found: %s. No edited scene is open." % path var root_name := str(scene_root.name) return "Parent not found: %s. Paths are relative to the edited scene root (e.g. \"/%s\" or \"\"), not the SceneTree. Scene root is \"/%s\"." % [path, root_name, root_name] ## Format a "node not found" error that names the path convention and, when ## possible, suggests a corrected path. Agents routinely pass /root/Foo ## (runtime SceneTree) or unprefixed names; the bare "Node not found: X" ## gives no hint that paths are edited-scene-relative. ## ## Suggestion logic (highest-confidence first): ## 1. /root/[/...] where is not the scene root → suggest //[/...] ## 2. path doesn't start with "/" → suggest "//" ## 3. otherwise no concrete "did you mean", just the convention reminder. static func format_node_error(path: String, scene_root: Node) -> String: if scene_root == null: return "Node not found: %s. No edited scene is open." % path var root_name := str(scene_root.name) var suggestion := "" if path.begins_with("/root/"): var after_root := path.substr(6) # "/root/" is 6 chars # Only suggest if the segment after /root/ isn't already the scene root # (resolve() handles /root//... as an alias, so a failure # with that prefix means a deeper segment is wrong — no clean rewrite). var first_seg := after_root.split("/")[0] if first_seg != root_name and not first_seg.is_empty(): suggestion = "/" + root_name + "/" + after_root elif not path.begins_with("/") and not path.is_empty(): suggestion = "/" + root_name + "/" + path if suggestion.is_empty(): return "Node not found: %s. Paths are relative to the edited scene root (e.g. \"/%s/Child\"), not runtime /root/... paths. Scene root is \"/%s\"." % [path, root_name, root_name] return "Node not found: %s. Did you mean \"%s\"? Paths are relative to the edited scene root, not runtime /root/... paths. Scene root is \"/%s\"." % [path, suggestion, root_name]