first commit

This commit is contained in:
Leonid Pershin
2026-08-10 20:39:06 +03:00
commit f16f60445c
281 changed files with 43576 additions and 0 deletions
@@ -0,0 +1,71 @@
@tool
class_name McpNodeValidator
extends RefCounted
## Shared resolve-or-error helper that subsumes the 38+ sites where
## handlers each rolled their own "is the editor ready, does the path
## resolve, otherwise return EDITOR_NOT_READY / NODE_NOT_FOUND" guard.
##
## audit-v2 #20 (issue #364). Uses the audit-v2 #21 (issue #365) error
## vocabulary.
## Local const names alias the preloaded scripts. The naming choice is
## stylistic, not an upgrade-safety boundary: bare `McpErrorCodes.MEMBER`
## and `ErrorCodes.MEMBER` both depend on the Script object Godot has for
## `error_codes.gd`. The transient #398 parse errors were caused by the
## old runner scanning a mixed old/new plugin snapshot and seeing stale
## Script-object content; the runner now writes one v(N+1) snapshot before
## its scan.
const ScenePath := preload("res://addons/godot_ai/utils/scene_path.gd")
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Resolve a scene-relative path to the live Node, or return a structured
## error dict.
##
## Success shape: `{"node": Node, "scene_root": Node, "path": String}`.
## Error shape: matches `ErrorCodes.make(...)` so callers can
## `return resolved` to propagate.
##
## Errors (in order checked):
## - `MISSING_REQUIRED_PARAM`: `node_path` is empty
## - `EDITOR_NOT_READY`: no scene open
## - `EDITED_SCENE_MISMATCH`: caller pinned `scene_file` and the open
## scene's path doesn't match
## - `NODE_NOT_FOUND`: `node_path` doesn't resolve under the scene root
##
## `param_name` is the agent-facing name reported in the
## `MISSING_REQUIRED_PARAM` message — handlers pass "node_path",
## "player_path", "target_path", etc. so the error reads like the
## hand-written messages it replaces.
static func resolve_or_error(
node_path: String,
param_name: String = "path",
scene_file: String = "",
) -> Dictionary:
if node_path.is_empty():
return ErrorCodes.make(
ErrorCodes.MISSING_REQUIRED_PARAM,
"Missing required param: %s" % param_name,
)
var scene_check := ScenePath.require_edited_scene(scene_file)
if scene_check.has("error"):
return scene_check
var scene_root: Node = scene_check.node
var node := ScenePath.resolve(node_path, scene_root)
if node == null:
return ErrorCodes.make(
ErrorCodes.NODE_NOT_FOUND,
ScenePath.format_node_error(node_path, scene_root),
)
return {"node": node, "scene_root": scene_root, "path": node_path}
## When the caller needs the scene root but no specific node yet — e.g.
## handlers that walk children or filter by group. Returns either
## `{"scene_root": Node}` or an `ErrorCodes.make(...)` error dict.
static func require_scene_or_error(scene_file: String = "") -> Dictionary:
var scene_check := ScenePath.require_edited_scene(scene_file)
if scene_check.has("error"):
return scene_check
return {"scene_root": scene_check.node}
@@ -0,0 +1 @@
uid://dn75jifad0ghx
@@ -0,0 +1,30 @@
@tool
class_name McpParamValidators
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Type-check a JSON-decoded param Variant before assigning it into a typed
## GDScript local. The dispatcher only catches handler crashes as an opaque
## "malformed result" (issue #210), so a typed assignment like
## var group: String = params.get("group", "")
## will runtime-error and bubble up without telling the caller which param
## was the wrong shape. Only string params are guarded — int/bool params
## can't be: Godot's JSON parser decodes every number as float (a wire `5`
## arrives as `5.0`), so a strict int check would reject every legitimate
## integer a client sends, and GDScript's typed assignment already converts
## numeric Variants safely. Bool params arrive as real bools and a wrong
## type surfaces through the dispatcher's malformed-result path.
## Returns null iff `value` is a String or StringName. On any other type
## returns an INVALID_PARAMS error dict whose message names both `name` and
## the actual Variant type (via Godot's built-in `type_string`).
static func require_string(name: String, value: Variant) -> Variant:
var t := typeof(value)
if t == TYPE_STRING or t == TYPE_STRING_NAME:
return null
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Param '%s' must be a String, got %s" % [name, type_string(t)],
)
@@ -0,0 +1 @@
uid://difa877m8dsla
@@ -0,0 +1,82 @@
@tool
class_name McpPropertyErrors
extends RefCounted
## Shared helper for building "Property not found" error messages that include
## "did you mean" suggestions and a tail of available property names. All
## handlers that validate user-supplied property names against a target Object
## (Node, Resource, …) should route through build_message() so agents get
## consistent, actionable errors on typos.
##
## Ranking combines Godot's built-in String.similarity() with a substring
## bonus so both "radus" → "radius" (edit distance) and "top" → "top_radius"
## (substring) surface naturally.
const _SIMILARITY_THRESHOLD: float = 0.4
const _SUBSTRING_BONUS: float = 0.5
const _MAX_SUGGESTIONS: int = 5
const _MAX_TAIL: int = 10
static func build_message(target: Object, bad_name: String) -> String:
if target == null:
return "Property '%s' not found" % bad_name
var class_label := _class_label(target)
var available := _available_property_names(target)
if available.is_empty():
return "Property '%s' not found on %s" % [bad_name, class_label]
var msg := "Property '%s' not found on %s" % [bad_name, class_label]
var suggestions := _rank_suggestions(bad_name, available)
if not suggestions.is_empty():
msg += ". Did you mean: %s?" % ", ".join(suggestions)
var tail_names := available.slice(0, min(_MAX_TAIL, available.size()))
msg += " (available: %s" % ", ".join(tail_names)
if available.size() > tail_names.size():
msg += ", ..."
msg += ")"
return msg
## Prefer a scripted class_name if the target has one, else the engine class.
static func _class_label(target: Object) -> String:
var scr := target.get_script()
if scr != null and scr.has_method("get_global_name"):
var gcn: String = scr.get_global_name()
if not gcn.is_empty():
return gcn
return target.get_class()
## Editor-visible properties, alphabetised, with internal/category entries dropped.
static func _available_property_names(target: Object) -> Array:
var names: Array = []
for p in target.get_property_list():
var usage: int = int(p.get("usage", 0))
if (usage & PROPERTY_USAGE_EDITOR) == 0:
continue
var name: String = p.get("name", "")
if name.is_empty() or name.begins_with("_"):
continue
names.append(name)
names.sort()
return names
static func _rank_suggestions(bad: String, available: Array) -> Array:
if bad.is_empty():
return []
var bad_lower := bad.to_lower()
var scored: Array = []
for n in available:
var score: float = bad.similarity(n)
if n.to_lower().find(bad_lower) != -1 or bad_lower.find(n.to_lower()) != -1:
score += _SUBSTRING_BONUS
if score >= _SIMILARITY_THRESHOLD:
scored.append([score, n])
scored.sort_custom(func(a, b): return a[0] > b[0])
var result: Array = []
for i in range(min(_MAX_SUGGESTIONS, scored.size())):
result.append(scored[i][1])
return result
@@ -0,0 +1 @@
uid://c74d560g4l86b
@@ -0,0 +1,825 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles AnimationPlayer authoring: creating players, animations, tracks,
## keyframes, autoplay, and dev-ergonomics playback.
##
## Animations live inside an AnimationLibrary attached to an AnimationPlayer
## node in the scene. They save with the .tscn — no separate resource file
## needed. Undo callables hold direct Animation references (not paths).
##
## Split (issue #342, audit finding #13):
## - animation_presets.gd → preset_fade / slide / shake / pulse + helpers
## - animation_values.gd → animation_list / get / validate + shared
## value coercion / serialization
## Both submodules hold a WeakRef back to this handler. The handler's
## preset_* / list / get / validate methods are thin proxies so existing
## dispatcher registrations and test fixtures don't change.
const AnimationPresets := preload("res://addons/godot_ai/handlers/animation_presets.gd")
const AnimationValues := preload("res://addons/godot_ai/handlers/animation_values.gd")
var _undo_redo: EditorUndoRedoManager
var _presets
var _values
const _LOOP_MODES := {
"none": Animation.LOOP_NONE,
"linear": Animation.LOOP_LINEAR,
"pingpong": Animation.LOOP_PINGPONG,
}
const _INTERP_MODES := {
"nearest": Animation.INTERPOLATION_NEAREST,
"linear": Animation.INTERPOLATION_LINEAR,
"cubic": Animation.INTERPOLATION_CUBIC,
}
func _init(undo_redo: EditorUndoRedoManager) -> void:
_undo_redo = undo_redo
_presets = AnimationPresets.new(self)
_values = AnimationValues.new(self)
# ============================================================================
# animation_player_create
# ============================================================================
func create_player(params: Dictionary) -> Dictionary:
var parent_path: String = params.get("parent_path", "")
var node_name: String = params.get("name", "AnimationPlayer")
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 parent: Node = scene_root
if not parent_path.is_empty():
parent = McpScenePath.resolve(parent_path, scene_root)
if parent == null:
return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root))
var player := AnimationPlayer.new()
if not node_name.is_empty():
player.name = node_name
# Attach the default library before adding to tree — it persists on redo.
var library := AnimationLibrary.new()
player.add_animation_library("", library)
_undo_redo.create_action("MCP: Create AnimationPlayer %s" % player.name)
_undo_redo.add_do_method(parent, "add_child", player, true)
_undo_redo.add_do_method(player, "set_owner", scene_root)
_undo_redo.add_do_reference(player)
_undo_redo.add_do_reference(library)
_undo_redo.add_undo_method(parent, "remove_child", player)
_undo_redo.commit_action()
return {
"data": {
"path": McpScenePath.from_node(player, scene_root),
"parent_path": McpScenePath.from_node(parent, scene_root),
"name": String(player.name),
"undoable": true,
}
}
# ============================================================================
# animation_create
# ============================================================================
func create_animation(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var anim_name: String = params.get("name", "")
var length: float = float(params.get("length", 1.0))
var loop_mode_str: String = params.get("loop_mode", "none")
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if anim_name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name")
if length <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "length must be > 0 (got %s)" % length)
if not _LOOP_MODES.has(loop_mode_str):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid loop_mode '%s'. Valid: %s" % [loop_mode_str, ", ".join(_LOOP_MODES.keys())])
var resolved := _resolve_player(player_path, true)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
var library: AnimationLibrary = resolved.library
var created_player: bool = resolved.get("player_created", false)
var player_parent: Node = resolved.get("player_parent", null)
var created_library := false
if library == null:
library = AnimationLibrary.new()
created_library = true
var overwrite: bool = params.get("overwrite", false)
var old_anim: Animation = null
if library.has_animation(anim_name):
if not overwrite:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name)
old_anim = library.get_animation(anim_name)
var anim := Animation.new()
anim.length = length
anim.loop_mode = _LOOP_MODES[loop_mode_str]
_commit_animation_add("MCP: Create animation %s" % anim_name,
player, library, created_library, anim_name, anim, old_anim,
created_player, player_parent)
return {
"data": {
"player_path": player_path,
"name": anim_name,
"length": length,
"loop_mode": loop_mode_str,
"library_created": created_library or created_player,
"animation_player_created": created_player,
"overwritten": old_anim != null,
"undoable": true,
}
}
# ============================================================================
# animation_delete
# ============================================================================
func delete_animation(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var anim_name: String = params.get("animation_name", "")
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if anim_name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: animation_name")
var resolved := _resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
# Use _resolve_animation so we can delete from ANY library, not just the
# default. Mirrors the read-side symmetry with animation_get / animation_play
# which already search all libraries via _resolve_animation.
var anim_resolved := _resolve_animation(player, anim_name)
if anim_resolved.has("error"):
return anim_resolved
var old_anim: Animation = anim_resolved.animation
var library: AnimationLibrary = anim_resolved.library
# Clip key within the owning library — strips the "libname/" prefix if the
# caller passed a qualified name.
var clip_key: String = anim_name
var slash := anim_name.find("/")
if slash >= 0:
clip_key = anim_name.substr(slash + 1)
_undo_redo.create_action("MCP: Delete animation %s" % anim_name)
_undo_redo.add_do_method(library, "remove_animation", clip_key)
_undo_redo.add_undo_method(library, "add_animation", clip_key, old_anim)
_undo_redo.add_do_reference(old_anim) # prevent GC so undo→redo works
_undo_redo.commit_action()
return {
"data": {
"player_path": player_path,
"animation_name": anim_name,
"library_key": anim_resolved.get("library_key", ""),
"undoable": true,
}
}
# ============================================================================
# animation_add_property_track
# ============================================================================
func add_property_track(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var anim_name: String = params.get("animation_name", "")
var track_path: String = params.get("track_path", "")
var keyframes = params.get("keyframes", [])
var interp_str: String = params.get("interpolation", "linear")
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if anim_name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: animation_name")
if track_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM,
"Missing required param: track_path (format: 'NodeName:property', e.g. 'Panel:modulate')")
if not track_path.contains(":"):
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"track_path must include ':property' suffix (e.g. 'Panel:modulate', '.:position')")
if not _INTERP_MODES.has(interp_str):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid interpolation '%s'. Valid: %s" % [interp_str, ", ".join(_INTERP_MODES.keys())])
if typeof(keyframes) != TYPE_ARRAY or keyframes.is_empty():
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "keyframes must be a non-empty array")
var resolved := _resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
var anim_resolved := _resolve_animation(player, anim_name)
if anim_resolved.has("error"):
return anim_resolved
var anim: Animation = anim_resolved.animation
# Validate + pre-coerce keyframes before mutating. Coercion errors
# surface as INVALID_PARAMS rather than silently inserting garbage keys.
# Resolve the target property's type ONCE — dense clips used to re-walk
# get_property_list() per keyframe.
var ctx := AnimationValues.resolve_track_prop_context(track_path, player)
if ctx.has("error"):
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, ctx.error)
var coerced_keyframes: Array = []
for kf in keyframes:
if typeof(kf) != TYPE_DICTIONARY:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Each keyframe must be a dictionary")
if not "time" in kf:
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Each keyframe must have a 'time' field")
if not "value" in kf:
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Each keyframe must have a 'value' field")
var coerce_result := AnimationValues.coerce_with_context(kf.get("value"), ctx)
if coerce_result.has("error"):
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, coerce_result.error)
coerced_keyframes.append({
"time": kf.get("time"),
"value": coerce_result.ok,
"transition": kf.get("transition", "linear"),
})
_create_scene_pinned_action("MCP: Add property track %s to %s" % [track_path, anim_name])
_undo_redo.add_do_method(self, "_do_add_property_track", anim, track_path, interp_str, coerced_keyframes)
# Undo locates the track by (path, type) at undo time rather than caching
# an index captured at do time. Cached indices go stale if any other track
# mutation lands between do and undo (Godot editor, another MCP call, etc.)
_undo_redo.add_undo_method(self, "_undo_remove_track_by_path", anim, track_path, Animation.TYPE_VALUE)
_undo_redo.commit_action()
return {
"data": {
"player_path": player_path,
"animation_name": anim_name,
"track_path": track_path,
"interpolation": interp_str,
"keyframe_count": keyframes.size(),
"undoable": true,
}
}
## Insert a pre-coerced track into the animation. Callers must coerce
## values against the target property before calling this (see
## AnimationValues.coerce_value_for_track) — this method runs inside the
## undo do-method path where error propagation isn't possible.
func _do_add_property_track(
anim: Animation,
track_path: String,
interp_str: String,
keyframes: Array,
) -> void:
var idx := anim.add_track(Animation.TYPE_VALUE)
anim.track_set_path(idx, NodePath(track_path))
anim.track_set_interpolation_type(idx, _INTERP_MODES.get(interp_str, Animation.INTERPOLATION_LINEAR))
for kf in keyframes:
var t: float = float(kf.get("time", 0.0))
var trans: float = AnimationValues.parse_transition(kf.get("transition", "linear"))
anim.track_insert_key(idx, t, kf.get("value"), trans)
# ============================================================================
# animation_add_method_track
# ============================================================================
func add_method_track(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var anim_name: String = params.get("animation_name", "")
var target_path: String = params.get("target_node_path", "")
var keyframes = params.get("keyframes", [])
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if anim_name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: animation_name")
if target_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_node_path")
if target_path.contains(":"):
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"target_node_path is a bare NodePath without ':property' (got '%s'). " % target_path +
"Method name goes in each keyframe's 'method' field, not the path.")
if typeof(keyframes) != TYPE_ARRAY or keyframes.is_empty():
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "keyframes must be a non-empty array")
for kf in keyframes:
if typeof(kf) != TYPE_DICTIONARY:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Each keyframe must be a dictionary")
if not "time" in kf:
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Each keyframe must have a 'time' field")
if not "method" in kf:
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Each keyframe must have a 'method' field")
var method_field = kf.get("method")
if typeof(method_field) != TYPE_STRING or (method_field as String).is_empty():
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "'method' must be a non-empty string")
if kf.has("args") and typeof(kf.get("args")) != TYPE_ARRAY:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"'args' must be an array if provided (got %s)" % type_string(typeof(kf.get("args"))))
var resolved := _resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
var anim_resolved := _resolve_animation(player, anim_name)
if anim_resolved.has("error"):
return anim_resolved
var anim: Animation = anim_resolved.animation
_create_scene_pinned_action("MCP: Add method track %s to %s" % [target_path, anim_name])
_undo_redo.add_do_method(self, "_do_add_method_track", anim, target_path, keyframes)
# Undo locates the track by (path, type) at undo time — see add_property_track.
_undo_redo.add_undo_method(self, "_undo_remove_track_by_path", anim, target_path, Animation.TYPE_METHOD)
_undo_redo.commit_action()
return {
"data": {
"player_path": player_path,
"animation_name": anim_name,
"target_node_path": target_path,
"keyframe_count": keyframes.size(),
"undoable": true,
}
}
## Remove a track identified by (path, type) at undo time. Robust to
## history interleaving: if another track was added since the do, the
## find_track call still resolves to the correct index. Returns silently
## if the track is no longer present (e.g. a prior undo already removed it).
func _undo_remove_track_by_path(anim: Animation, track_path: String, track_type: int) -> void:
var idx := anim.find_track(NodePath(track_path), track_type)
if idx >= 0:
anim.remove_track(idx)
func _do_add_method_track(anim: Animation, target_path: String, keyframes: Array) -> void:
var idx := anim.add_track(Animation.TYPE_METHOD)
anim.track_set_path(idx, NodePath(target_path))
for kf in keyframes:
var t: float = float(kf.get("time", 0.0))
var method_name: String = str(kf.get("method", ""))
var args: Array = kf.get("args", [])
anim.track_insert_key(idx, t, {"method": method_name, "args": args})
# ============================================================================
# animation_set_autoplay
# ============================================================================
func set_autoplay(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var anim_name: String = params.get("animation_name", "")
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
var resolved := _resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
# Allow empty string to clear autoplay; otherwise validate the name exists.
if not anim_name.is_empty() and not player.has_animation(anim_name):
return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Animation '%s' not found on player at %s" % [anim_name, player_path])
var old_autoplay: String = player.autoplay
_undo_redo.create_action("MCP: Set autoplay %s on %s" % [anim_name, player_path])
_undo_redo.add_do_property(player, "autoplay", anim_name)
_undo_redo.add_undo_property(player, "autoplay", old_autoplay)
_undo_redo.commit_action()
return {
"data": {
"player_path": player_path,
"animation_name": anim_name,
"previous_autoplay": old_autoplay,
"cleared": anim_name.is_empty(),
"undoable": true,
}
}
# ============================================================================
# animation_play (dev ergonomics — not saved with scene)
# ============================================================================
func play(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var anim_name: String = params.get("animation_name", "")
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
var resolved := _resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
if not anim_name.is_empty() and not player.has_animation(anim_name):
return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Animation '%s' not found on player at %s" % [anim_name, player_path])
player.play(anim_name)
return {
"data": {
"player_path": player_path,
"animation_name": anim_name,
"undoable": false,
"reason": "Runtime playback state — not saved with scene",
}
}
# ============================================================================
# animation_stop (dev ergonomics — not saved with scene)
# ============================================================================
func stop(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
var resolved := _resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
player.stop()
return {
"data": {
"player_path": player_path,
"undoable": false,
"reason": "Runtime playback state — not saved with scene",
}
}
# ============================================================================
# animation_create_simple (composer)
# ============================================================================
func create_simple(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var anim_name: String = params.get("name", "")
var tweens = params.get("tweens", [])
var loop_mode_str: String = params.get("loop_mode", "none")
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if anim_name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name")
if typeof(tweens) != TYPE_ARRAY or tweens.is_empty():
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "tweens must be a non-empty array")
if not _LOOP_MODES.has(loop_mode_str):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid loop_mode '%s'. Valid: %s" % [loop_mode_str, ", ".join(_LOOP_MODES.keys())])
# Validate all tween specs before touching the scene.
var seen_paths := {}
for spec in tweens:
if typeof(spec) != TYPE_DICTIONARY:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Each tween spec must be a dictionary")
for field in ["target", "property", "from", "to", "duration"]:
if not field in spec:
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM,
"Each tween spec must have '%s'" % field)
if float(spec.get("duration", 0.0)) <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"tween 'duration' must be > 0")
var dup_key: String = str(spec.target) + ":" + str(spec.property)
if seen_paths.has(dup_key):
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Duplicate tween target '%s' — merge keyframes into a single track " % dup_key +
"via animation_add_property_track instead of two separate tweens.")
seen_paths[dup_key] = true
# Compute/validate length before resolving the player — a fresh auto-created
# AnimationPlayer is a detached Node that leaks if we return after creation.
var has_length: bool = params.has("length") and params.get("length") != null
var computed_length: float = 0.0
if has_length:
computed_length = float(params.get("length"))
if computed_length <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"'length' must be > 0 when provided (got %s)" % str(params.get("length")))
else:
for spec in tweens:
var end_time: float = float(spec.get("delay", 0.0)) + float(spec.get("duration", 0.0))
if end_time > computed_length:
computed_length = end_time
if computed_length <= 0.0:
computed_length = 1.0
var resolved := _resolve_player(player_path, true)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
var library: AnimationLibrary = resolved.library
var created_player: bool = resolved.get("player_created", false)
var player_parent: Node = resolved.get("player_parent", null)
var created_library := false
if library == null:
library = AnimationLibrary.new()
created_library = true
var overwrite: bool = params.get("overwrite", false)
var old_anim: Animation = null
if library.has_animation(anim_name):
if not overwrite:
if created_player:
player.queue_free()
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name)
old_anim = library.get_animation(anim_name)
# Pre-coerce all tween values before touching the anim — coercion errors
# surface as INVALID_PARAMS, not silent garbage keyframes.
# When the player was auto-created, it isn't in the tree yet — pass its
# future parent so the coercer can still resolve target property types.
var coerce_root: Node = player_parent if created_player else null
var per_track_keyframes: Array = []
for spec in tweens:
var target: String = str(spec.get("target", ""))
var property: String = str(spec.get("property", ""))
var track_path: String = target + ":" + property
var duration: float = float(spec.get("duration", 1.0))
var delay: float = float(spec.get("delay", 0.0))
var trans_str = spec.get("transition", "linear")
var from_result := AnimationValues.coerce_value_for_track(spec.get("from"), track_path, player, coerce_root)
if from_result.has("error"):
if created_player:
player.queue_free()
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "tween '%s': %s" % [track_path, from_result.error])
var to_result := AnimationValues.coerce_value_for_track(spec.get("to"), track_path, player, coerce_root)
if to_result.has("error"):
if created_player:
player.queue_free()
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "tween '%s': %s" % [track_path, to_result.error])
per_track_keyframes.append({
"track_path": track_path,
"keyframes": [
{"time": delay, "value": from_result.ok, "transition": trans_str},
{"time": delay + duration, "value": to_result.ok, "transition": trans_str},
],
})
# Build the animation fully in memory before touching the undo stack.
var anim := Animation.new()
anim.length = computed_length
anim.loop_mode = _LOOP_MODES[loop_mode_str]
for entry in per_track_keyframes:
_do_add_property_track(anim, entry.track_path, "linear", entry.keyframes)
# One atomic undo action — bundles player creation (if any), library
# creation (if any), and the animation add. A single Ctrl-Z rolls back all.
_commit_animation_add("MCP: Create animation %s (%d tracks)" % [anim_name, anim.get_track_count()],
player, library, created_library, anim_name, anim, old_anim,
created_player, player_parent)
return {
"data": {
"player_path": player_path,
"name": anim_name,
"length": computed_length,
"loop_mode": loop_mode_str,
"track_count": anim.get_track_count(),
"library_created": created_library or created_player,
"animation_player_created": created_player,
"overwritten": old_anim != null,
"undoable": true,
}
}
# ============================================================================
# Proxies — preset_* and read methods live in the submodules. Kept here so
# the dispatcher registrations and `_handler.method(...)` test fixtures stay
# unchanged across the split.
# ============================================================================
func preset_fade(params: Dictionary) -> Dictionary:
return _presets.preset_fade(params)
func preset_slide(params: Dictionary) -> Dictionary:
return _presets.preset_slide(params)
func preset_shake(params: Dictionary) -> Dictionary:
return _presets.preset_shake(params)
func preset_pulse(params: Dictionary) -> Dictionary:
return _presets.preset_pulse(params)
func list_animations(params: Dictionary) -> Dictionary:
return _values.list_animations(params)
func get_animation(params: Dictionary) -> Dictionary:
return _values.get_animation(params)
func validate_animation(params: Dictionary) -> Dictionary:
return _values.validate_animation(params)
# ============================================================================
# Helpers — undo
# ============================================================================
## Shared undo setup for create_animation and create_simple. Handles fresh-
## create, overwrite, library auto-create, and player auto-create in a single
## atomic action. When `created_player` is true, the player already has the
## library attached (eagerly, from `_instantiate_player`) and the library
## doesn't need its own undo bookkeeping — it rides along with the add_child.
func _commit_animation_add(
action_label: String,
player: AnimationPlayer,
library: AnimationLibrary,
created_library: bool,
anim_name: String,
anim: Animation,
old_anim: Animation, ## null when not overwriting
created_player: bool = false,
player_parent: Node = null,
) -> void:
_undo_redo.create_action(action_label)
if created_player:
var scene_root := EditorInterface.get_edited_scene_root()
_undo_redo.add_do_method(player_parent, "add_child", player, true)
_undo_redo.add_do_method(player, "set_owner", scene_root)
_undo_redo.add_do_reference(player)
_undo_redo.add_do_reference(library)
_undo_redo.add_undo_method(player_parent, "remove_child", player)
elif created_library:
_undo_redo.add_do_method(player, "add_animation_library", "", library)
_undo_redo.add_undo_method(player, "remove_animation_library", "")
_undo_redo.add_do_reference(library)
if old_anim != null:
_undo_redo.add_do_method(library, "remove_animation", anim_name)
_undo_redo.add_do_method(library, "add_animation", anim_name, anim)
if old_anim != null:
_undo_redo.add_undo_method(library, "remove_animation", anim_name)
_undo_redo.add_undo_method(library, "add_animation", anim_name, old_anim)
_undo_redo.add_do_reference(old_anim)
else:
_undo_redo.add_undo_method(library, "remove_animation", anim_name)
_undo_redo.add_do_reference(anim)
_undo_redo.commit_action()
## Open a `create_action` pinned to the edited scene's history.
##
## Without an explicit context, `add_do_method(self, ...)` against a
## RefCounted handler lands in GLOBAL_HISTORY while sibling actions whose
## first do-target is a Resource (e.g. AnimationLibrary) land in the scene's
## history. Mismatched histories make the test-side `editor_undo` helper
## (walks scene first) undo the wrong action, and break batch_handler's
## rollback. Mirrors `camera_handler.gd`'s identical pinning rationale.
func _create_scene_pinned_action(action_label: String) -> void:
_undo_redo.create_action(
action_label, UndoRedo.MERGE_DISABLE, EditorInterface.get_edited_scene_root(),
)
# ============================================================================
# Helpers — resolution
# ============================================================================
## Resolve an AnimationPlayer and its default library for write operations.
## Returns {player, library, player_created, player_parent} on success, or an
## error dict. library is null if the player exists but has no default library
## yet — callers bundle an `add_animation_library` step into their undo action.
##
## When `create_if_missing` is true and `player_path` resolves to nothing, a
## fresh AnimationPlayer is instantiated (with an empty default library attached
## eagerly) but is NOT added to the scene tree — callers must bundle the
## add_child step into their undo action via `_commit_animation_add`.
## If the resolved node exists but isn't an AnimationPlayer, that's still an
## error — we don't clobber an existing node of a different type.
func _resolve_player(player_path: String, create_if_missing: bool = false) -> Dictionary:
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 node := McpScenePath.resolve(player_path, scene_root)
if node == null:
if not create_if_missing:
return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_node_error(player_path, scene_root))
return _instantiate_player(player_path, scene_root)
if not node is AnimationPlayer:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"Node at %s is not an AnimationPlayer (got %s)" % [player_path, node.get_class()])
var player := node as AnimationPlayer
var lib: AnimationLibrary = null
if player.has_animation_library(""):
lib = player.get_animation_library("")
return {"player": player, "library": lib, "player_created": false, "player_parent": null}
## Build a new AnimationPlayer (with empty default library) for insertion under
## the parent implied by `player_path`. Returns an error dict if the parent
## can't be resolved or the path has no usable leaf name.
func _instantiate_player(player_path: String, scene_root: Node) -> Dictionary:
var slash := player_path.rfind("/")
var parent_path: String
var player_name: String
if slash < 0:
parent_path = ""
player_name = player_path
else:
parent_path = player_path.substr(0, slash)
player_name = player_path.substr(slash + 1)
if player_name.is_empty():
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Cannot auto-create AnimationPlayer: player_path '%s' has no leaf name" % player_path)
var parent: Node
if parent_path.is_empty():
parent = scene_root
else:
parent = McpScenePath.resolve(parent_path, scene_root)
if parent == null:
return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND,
"Cannot auto-create AnimationPlayer at %s: %s" % [
player_path, McpScenePath.format_parent_error(parent_path, scene_root)])
var new_player := AnimationPlayer.new()
new_player.name = player_name
var lib := AnimationLibrary.new()
new_player.add_animation_library("", lib)
return {
"player": new_player,
"library": lib,
"player_created": true,
"player_parent": parent,
}
## Resolve for read operations (no library requirement).
func _resolve_player_read(player_path: String) -> Dictionary:
var resolved := McpNodeValidator.resolve_or_error(player_path, "player_path")
if resolved.has("error"):
return resolved
var node: Node = resolved.node
if not node is AnimationPlayer:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"Node at %s is not an AnimationPlayer (got %s)" % [player_path, node.get_class()])
return {"player": node as AnimationPlayer}
## Resolve an animation by name, searching all libraries.
## Accepts bare clip names ("idle") and library-qualified names ("moves/idle")
## as returned by `list_animations` for non-default libraries.
func _resolve_animation(player: AnimationPlayer, anim_name: String) -> Dictionary:
if not player.has_animation(anim_name):
return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Animation '%s' not found on player. Available: %s" % [
anim_name,
", ".join(Array(player.get_animation_list()))
])
# If the caller passed "library/clip", look up in that specific library.
var slash := anim_name.find("/")
if slash >= 0:
var lib_key := anim_name.substr(0, slash)
var clip_key := anim_name.substr(slash + 1)
if player.has_animation_library(lib_key):
var lib: AnimationLibrary = player.get_animation_library(lib_key)
if lib.has_animation(clip_key):
return {"animation": lib.get_animation(clip_key), "library": lib, "library_key": lib_key}
# Otherwise scan libraries for a bare clip name.
for lib_name in player.get_animation_library_list():
var lib2: AnimationLibrary = player.get_animation_library(lib_name)
if lib2.has_animation(anim_name):
return {"animation": lib2.get_animation(anim_name), "library": lib2, "library_key": lib_name}
# Fallback — shouldn't happen if has_animation returned true.
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Animation found by player but not in any library")
@@ -0,0 +1 @@
uid://c0jrius46xsd4
@@ -0,0 +1,536 @@
@tool
extends RefCounted
## Curated motion presets for the AnimationPlayer surface.
##
## Each preset_* method:
## 1. Validates params + resolves the player (auto-creating its default lib).
## 2. Resolves the target node + classifies it as control / 2d / 3d.
## 3. Builds a single-track Animation with shape-appropriate keyframes.
## 4. Commits the add through the handler's shared `_commit_animation_add`
## so a single Ctrl-Z rolls back any auto-created library + the animation.
##
## Holds a WeakRef back to the AnimationHandler instance so the handler can
## continue to own this module strongly via `_presets` without forming a
## RefCounted cycle. Resolution / undo helpers live on the handler — keeping
## the `_undo_redo` member single-source there avoids drift.
const AnimationValues := preload("res://addons/godot_ai/handlers/animation_values.gd")
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const ScenePath := preload("res://addons/godot_ai/utils/scene_path.gd")
var _handler_weak: WeakRef
func _init(handler) -> void:
_handler_weak = weakref(handler)
func _h():
return _handler_weak.get_ref()
# ============================================================================
# animation_preset_fade
# ============================================================================
func preset_fade(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var target_path: String = params.get("target_path", "")
var mode: String = params.get("mode", "in")
var duration: float = float(params.get("duration", 0.5))
var anim_name: String = params.get("animation_name", "")
var overwrite: bool = params.get("overwrite", false)
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if target_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_path")
if mode != "in" and mode != "out":
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid mode '%s'. Valid: 'in', 'out'" % mode)
if duration <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'duration' must be > 0")
var handler = _h()
if handler == null:
return ErrorCodes.make_not_ready(
ErrorCodes.SUB_EDITOR_UNAVAILABLE,
"AnimationHandler not available", false)
var resolved: Dictionary = handler._resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
var library: AnimationLibrary = resolved.library
var created_library := false
if library == null:
library = AnimationLibrary.new()
created_library = true
var target_resolved := _resolve_preset_target(player, target_path)
if target_resolved.has("error"):
return target_resolved
var target: Node = target_resolved.node
var track_target: String = target_resolved.track_path_root
# Fade requires a `modulate` property (CanvasItem/Control/Node2D/Sprite3D/etc).
var has_modulate := false
for p in target.get_property_list():
if p.name == "modulate":
has_modulate = true
break
if not has_modulate:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"Target '%s' (class %s) has no 'modulate' property — fade requires a CanvasItem, Control, Node2D, or Sprite3D"
% [target_path, target.get_class()])
if anim_name.is_empty():
anim_name = "fade_%s" % mode
var old_anim: Animation = null
if library.has_animation(anim_name):
if not overwrite:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name)
old_anim = library.get_animation(anim_name)
var start_a: float = 0.0 if mode == "in" else 1.0
var end_a: float = 1.0 if mode == "in" else 0.0
var anim := Animation.new()
anim.length = duration
anim.loop_mode = Animation.LOOP_NONE
var track_path := "%s:modulate:a" % track_target
handler._do_add_property_track(anim, track_path, "linear", [
{"time": 0.0, "value": start_a, "transition": "linear"},
{"time": duration, "value": end_a, "transition": "linear"},
])
handler._commit_animation_add(
"MCP: Create animation %s" % anim_name,
player, library, created_library, anim_name, anim, old_anim,
)
return {
"data": {
"player_path": player_path,
"animation_name": anim_name,
"mode": mode,
"length": duration,
"track_count": anim.get_track_count(),
"library_created": created_library,
"overwritten": old_anim != null,
"undoable": true,
}
}
# ============================================================================
# animation_preset_slide
# ============================================================================
func preset_slide(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var target_path: String = params.get("target_path", "")
var direction: String = params.get("direction", "left")
var mode: String = params.get("mode", "in")
var duration: float = float(params.get("duration", 0.4))
var anim_name: String = params.get("animation_name", "")
var overwrite: bool = params.get("overwrite", false)
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if target_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_path")
if not ["left", "right", "up", "down"].has(direction):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid direction '%s'. Valid: 'left', 'right', 'up', 'down'" % direction)
if mode != "in" and mode != "out":
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid mode '%s'. Valid: 'in', 'out'" % mode)
if duration <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'duration' must be > 0")
var handler = _h()
if handler == null:
return ErrorCodes.make_not_ready(
ErrorCodes.SUB_EDITOR_UNAVAILABLE,
"AnimationHandler not available", false)
var resolved: Dictionary = handler._resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
var library: AnimationLibrary = resolved.library
var created_library := false
if library == null:
library = AnimationLibrary.new()
created_library = true
var target_resolved := _resolve_preset_target(player, target_path)
if target_resolved.has("error"):
return target_resolved
var target = target_resolved.node
var kind: String = target_resolved.kind
var track_target: String = target_resolved.track_path_root
# Default distance picks 3D units vs screen pixels based on target kind.
var default_distance: float = 1.0 if kind == "3d" else 100.0
var distance: float = float(params.get("distance", default_distance))
if distance == 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'distance' must be non-zero")
var offset: Variant = _direction_offset(kind, direction, distance)
var current_pos: Variant = target.position
var start_pos: Variant
var end_pos: Variant
if mode == "in":
start_pos = current_pos + offset
end_pos = current_pos
else:
start_pos = current_pos
end_pos = current_pos + offset
if anim_name.is_empty():
anim_name = "slide_%s_%s" % [mode, direction]
var old_anim: Animation = null
if library.has_animation(anim_name):
if not overwrite:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name)
old_anim = library.get_animation(anim_name)
var anim := Animation.new()
anim.length = duration
anim.loop_mode = Animation.LOOP_NONE
var track_path := "%s:position" % track_target
handler._do_add_property_track(anim, track_path, "linear", [
{"time": 0.0, "value": start_pos, "transition": "linear"},
{"time": duration, "value": end_pos, "transition": "linear"},
])
handler._commit_animation_add(
"MCP: Create animation %s" % anim_name,
player, library, created_library, anim_name, anim, old_anim,
)
return {
"data": {
"player_path": player_path,
"animation_name": anim_name,
"direction": direction,
"mode": mode,
"distance": distance,
"length": duration,
"track_count": anim.get_track_count(),
"library_created": created_library,
"overwritten": old_anim != null,
"undoable": true,
}
}
# ============================================================================
# animation_preset_shake
# ============================================================================
func preset_shake(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var target_path: String = params.get("target_path", "")
var duration: float = float(params.get("duration", 0.3))
var frequency: float = float(params.get("frequency", 30.0))
var rng_seed: int = int(params.get("seed", 0))
var anim_name: String = params.get("animation_name", "")
var overwrite: bool = params.get("overwrite", false)
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if target_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_path")
if duration <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'duration' must be > 0")
if frequency <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'frequency' must be > 0")
var handler = _h()
if handler == null:
return ErrorCodes.make_not_ready(
ErrorCodes.SUB_EDITOR_UNAVAILABLE,
"AnimationHandler not available", false)
var resolved: Dictionary = handler._resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
var library: AnimationLibrary = resolved.library
var created_library := false
if library == null:
library = AnimationLibrary.new()
created_library = true
var target_resolved := _resolve_preset_target(player, target_path)
if target_resolved.has("error"):
return target_resolved
var target = target_resolved.node
var kind: String = target_resolved.kind
var track_target: String = target_resolved.track_path_root
var default_intensity: float = 0.1 if kind == "3d" else 10.0
var intensity: float = float(params.get("intensity", default_intensity))
if intensity <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'intensity' must be > 0")
if anim_name.is_empty():
anim_name = "shake"
var old_anim: Animation = null
if library.has_animation(anim_name):
if not overwrite:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name)
old_anim = library.get_animation(anim_name)
var rng := RandomNumberGenerator.new()
if rng_seed != 0:
rng.seed = rng_seed
else:
rng.randomize()
# Samples between t=0 and t=duration (exclusive); bookended by at-rest keys.
var sample_count: int = int(ceil(frequency * duration))
if sample_count < 2:
sample_count = 2
var current_pos: Variant = target.position
var kfs: Array = []
kfs.append({"time": 0.0, "value": current_pos, "transition": "linear"})
for i in range(1, sample_count):
var t: float = (float(i) / float(sample_count)) * duration
var jx: float = rng.randf_range(-intensity, intensity)
var jy: float = rng.randf_range(-intensity, intensity)
var jittered: Variant
if kind == "3d":
var jz: float = rng.randf_range(-intensity, intensity)
jittered = current_pos + Vector3(jx, jy, jz)
else:
jittered = current_pos + Vector2(jx, jy)
kfs.append({"time": t, "value": jittered, "transition": "linear"})
kfs.append({"time": duration, "value": current_pos, "transition": "linear"})
var anim := Animation.new()
anim.length = duration
anim.loop_mode = Animation.LOOP_NONE
var track_path := "%s:position" % track_target
handler._do_add_property_track(anim, track_path, "linear", kfs)
handler._commit_animation_add(
"MCP: Create animation %s" % anim_name,
player, library, created_library, anim_name, anim, old_anim,
)
return {
"data": {
"player_path": player_path,
"animation_name": anim_name,
"length": duration,
"frequency": frequency,
"intensity": intensity,
"keyframe_count": kfs.size(),
"track_count": anim.get_track_count(),
"library_created": created_library,
"overwritten": old_anim != null,
"undoable": true,
}
}
# ============================================================================
# animation_preset_pulse
# ============================================================================
func preset_pulse(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var target_path: String = params.get("target_path", "")
var from_scale: float = float(params.get("from_scale", 1.0))
var to_scale: float = float(params.get("to_scale", 1.1))
var duration: float = float(params.get("duration", 0.4))
var anim_name: String = params.get("animation_name", "")
var overwrite: bool = params.get("overwrite", false)
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if target_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_path")
if duration <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'duration' must be > 0")
if from_scale <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'from_scale' must be > 0")
if to_scale <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'to_scale' must be > 0")
var handler = _h()
if handler == null:
return ErrorCodes.make_not_ready(
ErrorCodes.SUB_EDITOR_UNAVAILABLE,
"AnimationHandler not available", false)
var resolved: Dictionary = handler._resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
var library: AnimationLibrary = resolved.library
var created_library := false
if library == null:
library = AnimationLibrary.new()
created_library = true
var target_resolved := _resolve_preset_target(player, target_path)
if target_resolved.has("error"):
return target_resolved
var kind: String = target_resolved.kind
var track_target: String = target_resolved.track_path_root
if anim_name.is_empty():
anim_name = "pulse"
var old_anim: Animation = null
if library.has_animation(anim_name):
if not overwrite:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name)
old_anim = library.get_animation(anim_name)
var from_vec: Variant
var to_vec: Variant
if kind == "3d":
from_vec = Vector3(from_scale, from_scale, from_scale)
to_vec = Vector3(to_scale, to_scale, to_scale)
else:
from_vec = Vector2(from_scale, from_scale)
to_vec = Vector2(to_scale, to_scale)
var anim := Animation.new()
anim.length = duration
anim.loop_mode = Animation.LOOP_NONE
var track_path := "%s:scale" % track_target
handler._do_add_property_track(anim, track_path, "linear", [
{"time": 0.0, "value": from_vec, "transition": "linear"},
{"time": duration * 0.5, "value": to_vec, "transition": "linear"},
{"time": duration, "value": from_vec, "transition": "linear"},
])
handler._commit_animation_add(
"MCP: Create animation %s" % anim_name,
player, library, created_library, anim_name, anim, old_anim,
)
return {
"data": {
"player_path": player_path,
"animation_name": anim_name,
"from_scale": from_scale,
"to_scale": to_scale,
"length": duration,
"track_count": anim.get_track_count(),
"library_created": created_library,
"overwritten": old_anim != null,
"undoable": true,
}
}
# ============================================================================
# Helpers — preset resolution
# ============================================================================
## Resolve a preset target node and classify its transform kind.
##
## Accepts two `target_path` shapes:
## * Scene-absolute (starts with "/") — resolved through `ScenePath.resolve`,
## matching the convention used by every other scene-mutating tool. Targets
## outside the player's `root_node` subtree are converted to `..`-prefixed
## paths via `root_node.get_path_to(target)`, mirroring what the relative
## form accepts and how Godot stores track paths.
## * Relative — used as-is against the player's `root_node`, matching how
## animation tracks themselves are stored.
##
## Returns `{node, kind, track_path_root}` where `track_path_root` is the path
## (relative to `root_node`) that callers should embed in the track path. For
## scene-absolute inputs this is the converted relative path; for relative
## inputs it equals the input. `kind` ∈ {"control", "2d", "3d"}.
##
## Mirrors the same root-node fallback that
## `AnimationValues.resolve_track_prop_context` uses so tool inputs match how
## the track path will resolve at playback.
func _resolve_preset_target(player: AnimationPlayer, target_path: String) -> Dictionary:
var root_node := AnimationValues.player_root_node(player)
if root_node == null:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"AnimationPlayer at %s has no resolvable root_node (is the scene open?)" % str(player.get_path()))
var target: Node = null
var track_path_root: String = target_path
if target_path.begins_with("/"):
var scene_root := EditorInterface.get_edited_scene_root()
if scene_root == null:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Cannot resolve scene-absolute target_path '%s': no scene open" % target_path)
target = ScenePath.resolve(target_path, scene_root)
if target == null:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
ScenePath.format_node_error(target_path, scene_root))
# Convert to a root_node-relative path. For targets outside the
# subtree this yields a `..`-prefixed path, matching what the
# relative form already accepts (root_node.get_node_or_null
# resolves `..` segments) and what Godot's animation engine
# stores natively.
track_path_root = str(root_node.get_path_to(target))
else:
target = root_node.get_node_or_null(target_path)
if target == null:
# root_node.get_path() leaks the editor's SubViewport-wrapped
# path; use the clean scene-relative form so the hint is
# actionable.
var scene_root := EditorInterface.get_edited_scene_root()
var root_hint := ScenePath.from_node(root_node, scene_root) if scene_root != null else str(root_node.name)
var abs_example := "/%s/path/to/target" % scene_root.name if scene_root != null else "/SceneRoot/path/to/target"
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
("Target node not found at '%s' (resolved relative to AnimationPlayer's root_node '%s'). "
+ "Pass a path relative to root_node (e.g. \"path/to/target\") or a scene-absolute path (e.g. \"%s\").")
% [target_path, root_hint, abs_example])
var kind: String
if target is Control:
kind = "control"
elif target is Node2D:
kind = "2d"
elif target is Node3D:
kind = "3d"
else:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"Target '%s' must be a Control, Node2D, or Node3D (got %s)" % [target_path, target.get_class()])
return {"node": target, "kind": kind, "track_path_root": track_path_root}
## Build a directional offset for slide presets.
## Axis conventions:
## Control + Node2D (screen-space, y-down): left/right = ∓x, up = -y, down = +y
## Node3D (world-up): left/right = ∓x, up = +y, down = -y
static func _direction_offset(kind: String, direction: String, distance: float) -> Variant:
if kind == "3d":
match direction:
"left": return Vector3(-distance, 0.0, 0.0)
"right": return Vector3(distance, 0.0, 0.0)
"up": return Vector3(0.0, distance, 0.0)
"down": return Vector3(0.0, -distance, 0.0)
else:
match direction:
"left": return Vector2(-distance, 0.0)
"right": return Vector2(distance, 0.0)
"up": return Vector2(0.0, -distance)
"down": return Vector2(0.0, distance)
return null
@@ -0,0 +1 @@
uid://c4s3h78bwvr6w
@@ -0,0 +1,442 @@
@tool
extends RefCounted
const VariantSerializer := preload("res://addons/godot_ai/utils/variant_serializer.gd")
## Read-only animation introspection + shared value-coercion / serialization.
##
## Holds:
## - Static helpers used by both the write handler (track building, simple
## composer) and the preset module (target/property resolution).
## - Instance methods that back the read MCP ops: animation_list,
## animation_get, animation_validate.
##
## The instance methods need the handler to resolve players / animations.
## To keep that without introducing a RefCounted cycle (the handler holds a
## strong ref to this module via `_values`), the back-pointer is a WeakRef.
## When the handler is freed during plugin teardown, _h() returns null and
## the (no-longer-routable) calls short-circuit to a generic editor-not-ready
## error — matches the dispatcher already being torn down at that point.
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const PropertyErrors := preload("res://addons/godot_ai/handlers/_property_errors.gd")
const _NAMED_TRANSITIONS := {
"linear": 1.0,
"ease_in": 2.0,
"ease_out": 0.5,
"ease_in_out": -2.0,
}
## Component letters accepted on each aggregate base type, paired with the
## scalar Variant type the component resolves to. A subpath like `position:y`
## on a Vector3 maps to TYPE_FLOAT; on a Vector3i it maps to TYPE_INT.
const _SUBPATH_COMPONENTS := {
TYPE_VECTOR2: ["xy", TYPE_FLOAT],
TYPE_VECTOR3: ["xyz", TYPE_FLOAT],
TYPE_VECTOR4: ["xyzw", TYPE_FLOAT],
TYPE_QUATERNION: ["xyzw", TYPE_FLOAT],
TYPE_COLOR: ["rgba", TYPE_FLOAT],
TYPE_VECTOR2I: ["xy", TYPE_INT],
TYPE_VECTOR3I: ["xyz", TYPE_INT],
TYPE_VECTOR4I: ["xyzw", TYPE_INT],
}
var _handler_weak: WeakRef
func _init(handler) -> void:
_handler_weak = weakref(handler)
func _h():
return _handler_weak.get_ref()
# ============================================================================
# animation_list (read)
# ============================================================================
func list_animations(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
var handler = _h()
if handler == null:
return ErrorCodes.make_not_ready(
ErrorCodes.SUB_EDITOR_UNAVAILABLE,
"AnimationHandler not available", false)
var resolved: Dictionary = handler._resolve_player_read(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
var animations: Array[Dictionary] = []
for lib_name in player.get_animation_library_list():
var lib: AnimationLibrary = player.get_animation_library(lib_name)
for anim_name in lib.get_animation_list():
var anim: Animation = lib.get_animation(anim_name)
var display_name: String = anim_name if lib_name == "" else "%s/%s" % [lib_name, anim_name]
animations.append({
"name": display_name,
"length": anim.length,
"loop_mode": loop_mode_to_string(anim.loop_mode),
"track_count": anim.get_track_count(),
})
return {
"data": {
"player_path": player_path,
"animations": animations,
"count": animations.size(),
}
}
# ============================================================================
# animation_get (read)
# ============================================================================
func get_animation(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var anim_name: String = params.get("animation_name", "")
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if anim_name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: animation_name")
var handler = _h()
if handler == null:
return ErrorCodes.make_not_ready(
ErrorCodes.SUB_EDITOR_UNAVAILABLE,
"AnimationHandler not available", false)
var resolved: Dictionary = handler._resolve_player_read(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
var anim_resolved: Dictionary = handler._resolve_animation(player, anim_name)
if anim_resolved.has("error"):
return anim_resolved
var anim: Animation = anim_resolved.animation
var tracks: Array[Dictionary] = []
for i in anim.get_track_count():
var track_type := anim.track_get_type(i)
var type_name := track_type_to_string(track_type)
var keys: Array[Dictionary] = []
for k in anim.track_get_key_count(i):
var key_val = anim.track_get_key_value(i, k)
keys.append({
"time": anim.track_get_key_time(i, k),
"value": serialize_value(key_val),
"transition": anim.track_get_key_transition(i, k),
})
tracks.append({
"index": i,
"type": type_name,
"path": str(anim.track_get_path(i)),
"interpolation": interp_to_string(anim.track_get_interpolation_type(i)),
"key_count": keys.size(),
"keys": keys,
})
return {
"data": {
"player_path": player_path,
"name": anim_name,
"length": anim.length,
"loop_mode": loop_mode_to_string(anim.loop_mode),
"track_count": anim.get_track_count(),
"tracks": tracks,
}
}
# ============================================================================
# animation_validate (read-only)
# ============================================================================
func validate_animation(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var anim_name: String = params.get("animation_name", "")
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if anim_name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: animation_name")
var handler = _h()
if handler == null:
return ErrorCodes.make_not_ready(
ErrorCodes.SUB_EDITOR_UNAVAILABLE,
"AnimationHandler not available", false)
var resolved: Dictionary = handler._resolve_player_read(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
if not player.has_animation(anim_name):
return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Animation '%s' not found on player at %s" % [anim_name, player_path])
var anim: Animation = player.get_animation(anim_name)
var root_node := player_root_node(player)
var broken_tracks: Array[Dictionary] = []
var valid_count := 0
for i in anim.get_track_count():
var track_path_str := str(anim.track_get_path(i))
# Split on the FIRST colon (node↔property boundary), not the last.
# Godot's get_node_or_null strips the ":property" tail natively, so
# the valid/broken classification is the same either way — but for
# BROKEN tracks the broken_tracks[].node_path field is what callers
# read to diagnose the missing node, and rfind would surface
# "MissingTarget:modulate" instead of "MissingTarget" for subpath
# tracks like the "Target:modulate:a" shape preset_fade emits.
var colon := track_path_str.find(":")
var node_part: String
if colon >= 0:
node_part = track_path_str.substr(0, colon)
else:
node_part = track_path_str
var target_node: Node = null
if root_node != null:
target_node = root_node.get_node_or_null(node_part)
if target_node == null:
broken_tracks.append({
"index": i,
"path": track_path_str,
"type": track_type_to_string(anim.track_get_type(i)),
"issue": "node_not_found",
"node_path": node_part,
})
else:
valid_count += 1
return {
"data": {
"player_path": player_path,
"animation_name": anim_name,
"track_count": anim.get_track_count(),
"valid_count": valid_count,
"broken_count": broken_tracks.size(),
"broken_tracks": broken_tracks,
"valid": broken_tracks.is_empty(),
}
}
# ============================================================================
# Static helpers — shared with handler + presets
# ============================================================================
## Resolve the effective root node an AnimationPlayer animates against.
## Falls back to the player's parent when the explicit root_node NodePath is
## empty or unresolvable. Returns null when the player isn't in the tree.
##
## Mirrors the resolution Godot does at playback time so the validator,
## preset target resolver, and track-property coercer all see the same root.
static func player_root_node(player: AnimationPlayer) -> Node:
if not player.is_inside_tree():
return null
var rn := player.root_node
if rn != NodePath():
var n := player.get_node_or_null(rn)
if n != null:
return n
return player.get_parent()
## Coerce a JSON value to match the expected Godot type for the given
## track_path. Returns {"ok": value} or {"error": msg}.
## Passes the raw value through when the target node isn't in the scene
## yet (authoring-time path). Errors when the target exists but the
## property doesn't, or when parsing a typed value (Color/Vector2/Vector3)
## clearly fails — better to reject than silently store garbage.
## `override_root_node` lets callers supply the root to resolve target paths
## against when the player isn't in the tree yet (auto-create flow) — the
## player's future parent stands in for the root the AnimationPlayer will
## eventually use.
static func coerce_value_for_track(value: Variant, track_path: String, player: AnimationPlayer, override_root_node: Node = null) -> Dictionary:
var ctx := resolve_track_prop_context(track_path, player, override_root_node)
if ctx.has("error"):
return {"error": ctx.error}
return coerce_with_context(value, ctx)
## Resolve a track_path's target property type once, so callers coercing many
## keyframes avoid walking `get_property_list()` on every one. Returns:
## {pass_through: true} — no resolution / authoring-time
## {pass_through: false, prop_type, prop_name} — coerce against this type
## {error: msg} — property not found on target
##
## Supports Godot's native NodePath subpath form `property:sub` (e.g.
## `position:y`, `modulate:a`) — splits on the FIRST colon (node↔property
## boundary), resolves the base property on the target, and for known
## scalar subpaths (x/y/z/w on vectors, r/g/b/a on Color) narrows the
## coerce target to TYPE_FLOAT so JSON numbers land as floats, not dicts.
static func resolve_track_prop_context(track_path: String, player: AnimationPlayer, override_root_node: Node = null) -> Dictionary:
var colon := track_path.find(":")
if colon < 0:
return {"pass_through": true}
var node_part := track_path.substr(0, colon)
var prop_full := track_path.substr(colon + 1)
# Property may include a subpath: "position:y", "modulate:a", etc.
var sub_colon := prop_full.find(":")
var prop_base := prop_full if sub_colon < 0 else prop_full.substr(0, sub_colon)
var prop_sub := "" if sub_colon < 0 else prop_full.substr(sub_colon + 1)
var root_node: Node = override_root_node
if root_node == null:
root_node = player_root_node(player)
if root_node == null:
return {"pass_through": true}
var target: Node = root_node.get_node_or_null(node_part)
if target == null:
# Target node isn't in the scene yet — authoring-time path. Pass through.
return {"pass_through": true}
for p in target.get_property_list():
if p.name == prop_base:
var base_type: int = p.get("type", TYPE_NIL)
var coerce_type := base_type
if not prop_sub.is_empty():
var sub_type := subpath_component_type(base_type, prop_sub)
if sub_type == TYPE_NIL:
# Unknown subpath component — pass through so Godot's own
# NodePath resolution raises at playback if it's truly bogus,
# rather than fabricating a coerce error for a valid-but-
# uncommon form (e.g. Transform3D subpaths).
return {"pass_through": true}
coerce_type = sub_type
return {
"pass_through": false,
"prop_type": coerce_type,
"prop_name": prop_full,
}
# Target exists but the property doesn't. Reject loudly — silently storing
# the raw value here produces garbage keyframes at playback time.
return {"error":
"%s (target path: '%s')" %
[PropertyErrors.build_message(target, prop_base), node_part]}
## Map a `property:sub` subpath to its scalar component type. Returns
## TYPE_NIL when the base type / subkey pair isn't one we recognise —
## callers pass-through in that case rather than mis-coerce.
static func subpath_component_type(base_type: int, sub: String) -> int:
var entry = _SUBPATH_COMPONENTS.get(base_type)
if entry == null or sub.length() != 1:
return TYPE_NIL
return entry[1] if (entry[0] as String).contains(sub) else TYPE_NIL
static func coerce_with_context(value: Variant, ctx: Dictionary) -> Dictionary:
if ctx.get("pass_through", false):
return {"ok": value}
return coerce_for_type(value, ctx.prop_type, ctx.prop_name)
## Coerce a single value to the given Godot variant type. Returns
## {"ok": coerced} or {"error": msg}. Unknown types pass through.
static func coerce_for_type(value: Variant, prop_type: int, prop_name: String) -> Dictionary:
match prop_type:
TYPE_COLOR:
## Canonical strict parser (#714): same shapes as every other
## color-accepting handler, including [r,g,b(,a)] arrays.
var col = McpJsonValues.parse_color(value)
if col != null:
return {"ok": col}
return {"error": "Cannot coerce value to Color for property '%s' (expected \"#rrggbb(aa)\"/named string, {r,g,b[,a]}, [r,g,b(,a)], or Color)" % prop_name}
TYPE_VECTOR2:
var v2 = McpJsonValues.parse_vector2(value)
if v2 != null:
return {"ok": v2}
return {"error": "Cannot coerce value to Vector2 for property '%s' (expected {x,y}, [x,y], or Vector2)" % prop_name}
TYPE_VECTOR3:
var v3 = McpJsonValues.parse_vector3(value)
if v3 != null:
return {"ok": v3}
return {"error": "Cannot coerce value to Vector3 for property '%s' (expected {x,y,z}, [x,y,z], or Vector3)" % prop_name}
TYPE_FLOAT:
if value is int or value is float:
return {"ok": float(value)}
TYPE_INT:
if value is float or value is int:
return {"ok": int(value)}
TYPE_BOOL:
if value is int or value is float or value is bool:
return {"ok": bool(value)}
return {"ok": value}
# ============================================================================
# Static helpers — parsing + serializing
# ============================================================================
## Parse a transition value: named string or raw float.
## Named values live in `_NAMED_TRANSITIONS` so the mapping has a single source.
static func parse_transition(v: Variant) -> float:
if v is float or v is int:
return float(v)
if v is String:
var key: String = (v as String).to_lower()
if _NAMED_TRANSITIONS.has(key):
return float(_NAMED_TRANSITIONS[key])
return 1.0
## Map an Animation.TrackType enum to a stable string. Unknown types report
## as "unknown" rather than being silently coerced to "method" — callers that
## only produce value/method tracks can ignore the others; clients that want
## to round-trip bezier/audio/etc. get an honest label to key off.
static func track_type_to_string(track_type: int) -> String:
match track_type:
Animation.TYPE_VALUE: return "value"
Animation.TYPE_METHOD: return "method"
Animation.TYPE_POSITION_3D: return "position_3d"
Animation.TYPE_ROTATION_3D: return "rotation_3d"
Animation.TYPE_SCALE_3D: return "scale_3d"
Animation.TYPE_BLEND_SHAPE: return "blend_shape"
Animation.TYPE_BEZIER: return "bezier"
Animation.TYPE_AUDIO: return "audio"
Animation.TYPE_ANIMATION: return "animation"
_: return "unknown"
static func loop_mode_to_string(mode: int) -> String:
match mode:
Animation.LOOP_LINEAR: return "linear"
Animation.LOOP_PINGPONG: return "pingpong"
_: return "none"
static func interp_to_string(mode: int) -> String:
match mode:
Animation.INTERPOLATION_NEAREST: return "nearest"
Animation.INTERPOLATION_CUBIC: return "cubic"
_: return "linear"
## Convert a Godot Variant to a JSON-safe value.
static func serialize_value(value: Variant) -> Variant:
## Delegates to the shared serializer (#714) — the drifted private copy
## stringified rotation_3d keyframe Quaternions into opaque text where
## McpVariantSerializer emits the {x,y,z,w} dict callers can round-trip
## (it also NaN/Inf-guards floats, matching the wire contract).
return VariantSerializer.serialize(value)
@@ -0,0 +1 @@
uid://bguta2eb8blgf
+89
View File
@@ -0,0 +1,89 @@
@tool
extends RefCounted
## Read-only access to version-correct Godot class metadata.
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const ClassIntrospection := preload("res://addons/godot_ai/utils/class_introspection.gd")
const FuzzySuggestions := preload("res://addons/godot_ai/utils/fuzzy_suggestions.gd")
func get_class_info(params: Dictionary) -> Dictionary:
var requested_class: String = params.get("class_name", "")
if requested_class.is_empty():
return ErrorCodes.make(
ErrorCodes.MISSING_REQUIRED_PARAM,
"Missing required param: class_name"
)
if not ClassDB.class_exists(requested_class):
var script_class := _global_script_class(requested_class)
if not script_class.is_empty():
return _script_class_error(requested_class, script_class)
return _unknown_class_error(requested_class)
if params.has("limit") and int(params.get("limit")) < 0:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"limit must be >= 0; use limit=0 only when an unlimited section is needed"
)
var section_check := ClassIntrospection.validate_sections(
params.get("sections", ClassIntrospection.DEFAULT_SECTIONS)
)
if not section_check.invalid.is_empty():
return _invalid_sections_error(section_check.invalid)
return {"data": ClassIntrospection.build(requested_class, params)}
static func _unknown_class_error(requested_class: String) -> Dictionary:
var suggestions := _suggest_classes(requested_class)
var message := "Unknown Godot class: %s" % requested_class
if not suggestions.is_empty():
message += ". Did you mean: %s?" % ", ".join(suggestions)
var result := ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, message)
result["error"]["data"] = {"suggestions": suggestions}
return result
static func _suggest_classes(requested_class: String) -> Array[String]:
return FuzzySuggestions.rank(requested_class, ClassDB.get_class_list())
static func _global_script_class(requested_class: String) -> Dictionary:
for raw_info in ProjectSettings.get_global_class_list():
var info: Dictionary = raw_info
if info.get("class", "") == requested_class:
return info
return {}
static func _script_class_error(requested_class: String, script_class: Dictionary) -> Dictionary:
var path := str(script_class.get("path", ""))
var base := str(script_class.get("base", ""))
var message := (
"%s is a project script class, not a ClassDB class. "
+ "Use script_manage(op=\"find_symbols\", params={\"path\": \"%s\"}) for script symbols."
) % [requested_class, path]
var result := ErrorCodes.make(ErrorCodes.WRONG_TYPE, message)
result["error"]["data"] = {
"script_class": true,
"class_name": requested_class,
"base_class": base,
"path": path,
}
return result
static func _invalid_sections_error(invalid_sections: Array[String]) -> Dictionary:
var suggestions := {}
for section in invalid_sections:
suggestions[section] = FuzzySuggestions.rank(
section,
ClassIntrospection.SUGGESTABLE_SECTION_TOKENS,
3,
0.3
)
var message := "Unknown class-info section(s): %s. Valid sections: %s (or \"all\" for all documentation sections; \"inheritors\" must be requested by name)" % [
", ".join(invalid_sections),
", ".join(ClassIntrospection.KNOWN_SECTIONS),
]
var result := ErrorCodes.make(ErrorCodes.INVALID_PARAMS, message)
result["error"]["data"] = {"suggestions": suggestions}
return result
@@ -0,0 +1 @@
uid://v3rkd7ueunii
+361
View File
@@ -0,0 +1,361 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles AudioStreamPlayer / 2D / 3D authoring — node creation, stream
## assignment, playback-property edits, and real editor preview playback.
##
## Stream assignment loads a Godot-imported AudioStream resource from
## res:// (the editor's import step converts .ogg / .wav / .mp3 into a
## streamable AudioStream subclass before we ever see it).
##
## play() / stop() call the live node method directly — no undo, no
## persistence; they match what the inspector's play button does.
const _VALID_TYPES := {
"1d": "AudioStreamPlayer",
"2d": "AudioStreamPlayer2D",
"3d": "AudioStreamPlayer3D",
}
## Whitelist of playback properties settable via audio_player_set_playback.
## Each value is the expected Variant type of the param dict value.
const _PLAYBACK_KEYS := {
"volume_db": TYPE_FLOAT,
"pitch_scale": TYPE_FLOAT,
"autoplay": TYPE_BOOL,
"bus": TYPE_STRING,
}
var _undo_redo: EditorUndoRedoManager
func _init(undo_redo: EditorUndoRedoManager) -> void:
_undo_redo = undo_redo
# ============================================================================
# audio_player_create
# ============================================================================
func create_player(params: Dictionary) -> Dictionary:
var parent_path: String = params.get("parent_path", "")
var node_name: String = params.get("name", "AudioStreamPlayer")
var type_str: String = params.get("type", "1d")
if not _VALID_TYPES.has(type_str):
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid audio player type '%s'. Valid: %s" % [type_str, ", ".join(_VALID_TYPES.keys())]
)
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 parent: Node = scene_root
if not parent_path.is_empty():
parent = McpScenePath.resolve(parent_path, scene_root)
if parent == null:
return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root))
var node := _instantiate_player(type_str)
if node == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate audio player")
if not node_name.is_empty():
node.name = node_name
_undo_redo.create_action("MCP: Create %s '%s'" % [_VALID_TYPES[type_str], node.name])
_undo_redo.add_do_method(parent, "add_child", node, true)
_undo_redo.add_do_method(node, "set_owner", scene_root)
_undo_redo.add_do_reference(node)
_undo_redo.add_undo_method(parent, "remove_child", node)
_undo_redo.commit_action()
return {
"data": {
"path": McpScenePath.from_node(node, scene_root),
"parent_path": McpScenePath.from_node(parent, scene_root),
"name": String(node.name),
"type": type_str,
"class": _VALID_TYPES[type_str],
"undoable": true,
}
}
# ============================================================================
# audio_player_set_stream
# ============================================================================
func set_stream(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var stream_path: String = params.get("stream_path", "")
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if stream_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: stream_path")
var stream_path_err = McpPathValidator.loadable_error(stream_path, "stream_path")
if stream_path_err != null:
return stream_path_err
var resolved := _resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: Node = resolved.player
if not ResourceLoader.exists(stream_path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "AudioStream not found: %s" % stream_path)
var loaded := ResourceLoader.load(stream_path)
if loaded == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load AudioStream: %s" % stream_path)
if not (loaded is AudioStream):
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Resource at %s is not an AudioStream (got %s)" % [stream_path, loaded.get_class()]
)
var old_stream: AudioStream = player.stream
_undo_redo.create_action("MCP: Set audio stream on %s" % player.name)
_undo_redo.add_do_property(player, "stream", loaded)
_undo_redo.add_undo_property(player, "stream", old_stream)
_undo_redo.commit_action()
return {
"data": {
"player_path": player_path,
"stream_path": stream_path,
"stream_class": loaded.get_class(),
"duration_seconds": float(loaded.get_length()),
"undoable": true,
}
}
# ============================================================================
# audio_player_set_playback
# ============================================================================
func set_playback(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
var resolved := _resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: Node = resolved.player
var updates: Dictionary = {}
for key in _PLAYBACK_KEYS:
if params.has(key):
var expected_type: int = _PLAYBACK_KEYS[key]
var value = params.get(key)
var coerced = _coerce_playback_value(value, expected_type)
if coerced == null:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Invalid value for %s: expected %s, got %s" % [
key, type_string(expected_type), type_string(typeof(value))
]
)
updates[key] = coerced
if updates.is_empty():
return ErrorCodes.make(
ErrorCodes.MISSING_REQUIRED_PARAM,
"At least one of %s is required" % ", ".join(_PLAYBACK_KEYS.keys())
)
var old_values: Dictionary = {}
for key in updates:
old_values[key] = player.get(key)
_undo_redo.create_action("MCP: Update playback on %s" % player.name)
for key in updates:
_undo_redo.add_do_property(player, key, updates[key])
_undo_redo.add_undo_property(player, key, old_values[key])
_undo_redo.commit_action()
return {
"data": {
"player_path": player_path,
"applied": updates.keys(),
"values": updates,
"undoable": true,
}
}
# ============================================================================
# audio_play (runtime preview — not saved with scene)
# ============================================================================
func play(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var from_position: float = float(params.get("from_position", 0.0))
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
var resolved := _resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: Node = resolved.player
if player.stream == null:
return ErrorCodes.make(
ErrorCodes.MISSING_REQUIRED_PARAM,
"Player has no stream assigned — call audio_player_set_stream first"
)
player.play(from_position)
return {
"data": {
"player_path": player_path,
"from_position": from_position,
"playing": bool(player.playing),
"undoable": false,
"reason": "Runtime playback state — not saved with scene",
}
}
# ============================================================================
# audio_stop (runtime preview — not saved with scene)
# ============================================================================
func stop(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
var resolved := _resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: Node = resolved.player
player.stop()
return {
"data": {
"player_path": player_path,
"playing": bool(player.playing),
"undoable": false,
"reason": "Runtime playback state — not saved with scene",
}
}
# ============================================================================
# audio_list (read — scan project for AudioStream resources)
# ============================================================================
func list_streams(params: Dictionary) -> Dictionary:
var root: String = params.get("root", "res://")
var include_duration: bool = bool(params.get("include_duration", true))
var root_err = McpPathValidator.path_error(root, "root")
if root_err != null:
return root_err
var efs := EditorInterface.get_resource_filesystem()
if efs == null:
return ErrorCodes.make_not_ready(
ErrorCodes.SUB_EDITOR_UNAVAILABLE,
"EditorFileSystem not available", false)
var results: Array[Dictionary] = []
var start_dir := efs.get_filesystem_path(root)
if start_dir == null:
start_dir = efs.get_filesystem()
_scan_audio(start_dir, root, include_duration, results)
return {
"data": {
"root": root,
"streams": results,
"count": results.size(),
}
}
func _scan_audio(dir: EditorFileSystemDirectory, root: String, include_duration: bool, out: Array[Dictionary]) -> void:
if dir == null:
return
for i in dir.get_file_count():
var file_path := dir.get_file_path(i)
if not file_path.begins_with(root):
continue
var file_type := dir.get_file_type(i)
var is_audio := file_type == "AudioStream" or ClassDB.is_parent_class(file_type, "AudioStream")
if not is_audio:
continue
var entry: Dictionary = {
"path": file_path,
"class": file_type,
}
if include_duration:
var res := ResourceLoader.load(file_path)
if res is AudioStream:
entry["duration_seconds"] = float((res as AudioStream).get_length())
else:
entry["duration_seconds"] = 0.0
out.append(entry)
for i in dir.get_subdir_count():
_scan_audio(dir.get_subdir(i), root, include_duration, out)
# ============================================================================
# Helpers
# ============================================================================
static func _instantiate_player(type_str: String) -> Node:
match type_str:
"1d":
return AudioStreamPlayer.new()
"2d":
return AudioStreamPlayer2D.new()
"3d":
return AudioStreamPlayer3D.new()
return null
func _resolve_player(player_path: String) -> Dictionary:
var resolved := McpNodeValidator.resolve_or_error(player_path, "player_path")
if resolved.has("error"):
return resolved
var node: Node = resolved.node
var is_player := node is AudioStreamPlayer \
or node is AudioStreamPlayer2D \
or node is AudioStreamPlayer3D
if not is_player:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Node at %s is not an AudioStreamPlayer/2D/3D (got %s)" % [player_path, node.get_class()]
)
return {"player": node}
## Coerce a playback param value to the expected type. int→float is allowed
## so JSON integers pass through; everything else requires the exact type.
## Returns the coerced value, or null on type mismatch.
static func _coerce_playback_value(value: Variant, expected_type: int) -> Variant:
match expected_type:
TYPE_FLOAT:
if value is float or value is int:
return float(value)
TYPE_BOOL:
if value is bool:
return value
TYPE_STRING:
if value is String:
return value
return null
@@ -0,0 +1 @@
uid://cjtvod52xxocs
@@ -0,0 +1,91 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles autoload listing, adding, and removing via ProjectSettings.
func list_autoloads(_params: Dictionary) -> Dictionary:
var autoloads: Array[Dictionary] = []
for prop in ProjectSettings.get_property_list():
var key: String = prop.get("name", "")
if not key.begins_with("autoload/"):
continue
var name := key.substr("autoload/".length())
var raw_value: String = ProjectSettings.get_setting(key, "")
var is_singleton := raw_value.begins_with("*")
var path := raw_value.substr(1) if is_singleton else raw_value
autoloads.append({
"name": name,
"path": path,
"singleton": is_singleton,
})
return {"data": {"autoloads": autoloads, "count": autoloads.size()}}
func add_autoload(params: Dictionary) -> Dictionary:
var name: String = params.get("name", "")
var path: String = params.get("path", "")
var singleton: bool = params.get("singleton", true)
if name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name")
if path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
var path_err = McpPathValidator.path_error(path, "path")
if path_err != null:
return path_err
if not FileAccess.file_exists(path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "File not found: %s" % path)
var key := "autoload/%s" % name
if ProjectSettings.has_setting(key):
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Autoload '%s' already exists" % name)
var value := ("*" if singleton else "") + path
ProjectSettings.set_setting(key, value)
ProjectSettings.set_initial_value(key, "")
ProjectSettings.set_as_basic(key, true)
var err := ProjectSettings.save()
if err != OK:
ProjectSettings.clear(key)
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
"Failed to save project settings while adding autoload '%s': %s (error %d)" % [name, error_string(err), err])
return {
"data": {
"name": name,
"path": path,
"singleton": singleton,
"undoable": false,
"reason": "Autoload changes are saved to project.godot",
}
}
func remove_autoload(params: Dictionary) -> Dictionary:
var name: String = params.get("name", "")
if name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name")
var key := "autoload/%s" % name
if not ProjectSettings.has_setting(key):
return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, "Autoload '%s' not found" % name)
var old_value: String = ProjectSettings.get_setting(key, "")
ProjectSettings.clear(key)
var err := ProjectSettings.save()
if err != OK:
ProjectSettings.set_setting(key, old_value)
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
"Failed to save project settings while removing autoload '%s': %s (error %d)" % [name, error_string(err), err])
return {
"data": {
"name": name,
"removed": true,
"undoable": false,
"reason": "Autoload changes are saved to project.godot",
}
}
@@ -0,0 +1 @@
uid://bb0inov044jn6
+170
View File
@@ -0,0 +1,170 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Executes a list of sub-commands through the dispatcher with stop-on-first-error
## semantics. When undo=true (default), any successful sub-commands are rolled
## back via the scene's UndoRedo history if a later sub-command fails.
## Commands that cannot run as batch sub-commands, each with the reason a batch
## can't host it.
## - batch_execute: would recurse.
## - run_tests: a batch executes synchronously inside one dispatcher tick with
## NO transport servicing, so a full suite starves the WebSocket heartbeat
## (the exact disconnect the serviced test_run path exists to prevent) and
## the Python batch handler only allows 30s anyway — call the test_run tool
## directly.
## - game_command: it is deferred — its reply flows out-of-band correlated by a
## _request_id that dispatch_direct deliberately strips (see
## McpDispatcher.dispatch_direct), so a game op nested in a batch would have
## no completion channel and hang or lose its reply. input_sequence made this
## concrete (#814); the whole game_command surface shares the deferred path.
const FORBIDDEN_SUBCOMMANDS := {
"batch_execute": "batch_execute cannot be nested inside another batch",
"run_tests":
"run_tests is not allowed as a sub-command — a batch runs synchronously "
+ "with no transport servicing; call the test_run tool directly",
"game_command":
"game_command ops are deferred (their reply arrives out-of-band) and "
+ "have no completion channel inside a batch — run them as their own tool call",
}
## The whole batch executes synchronously inside one dispatcher tick,
## outside the 4ms frame budget — an unbounded array freezes the editor
## for the batch's full duration. 500 is far above any legitimate scene
## edit while keeping worst-case stalls in check.
const MAX_BATCH_COMMANDS := 500
var _dispatcher: McpDispatcher
var _undo_redo: EditorUndoRedoManager
func _init(dispatcher: McpDispatcher, undo_redo: EditorUndoRedoManager) -> void:
_dispatcher = dispatcher
_undo_redo = undo_redo
func batch_execute(params: Dictionary) -> Dictionary:
var commands = params.get("commands", null)
if typeof(commands) != TYPE_ARRAY:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "commands must be a list")
if commands.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "commands must not be empty")
if commands.size() > MAX_BATCH_COMMANDS:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"commands exceeds the %d-command batch cap (got %d) — split into multiple batches" % [MAX_BATCH_COMMANDS, commands.size()]
)
var undo: bool = params.get("undo", true)
for idx in range(commands.size()):
var item = commands[idx]
if typeof(item) != TYPE_DICTIONARY:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "commands[%d] must be a dict" % idx)
var cmd_name: String = item.get("command", "")
if cmd_name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "commands[%d] missing 'command' field" % idx)
if FORBIDDEN_SUBCOMMANDS.has(cmd_name):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"commands[%d]: %s" % [idx, FORBIDDEN_SUBCOMMANDS[cmd_name]])
if not _dispatcher.has_command(cmd_name):
return _unknown_command_error(idx, cmd_name)
## Pre-validate params type: the execution loop's typed Dictionary
## local would hard-error on a non-dict mid-batch, aborting AFTER
## earlier mutations committed. Catching it here keeps the
## all-or-nothing contract for malformed input.
if typeof(item.get("params", {})) != TYPE_DICTIONARY:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "commands[%d].params must be a dict" % idx)
var results: Array = []
var succeeded := 0
var stopped_at = null
var all_undoable := true
# Captured after the first successful commit — get_history_undo_redo()
# errors if called before any action exists in the history_map.
var histories: Array = []
for idx in range(commands.size()):
var item: Dictionary = commands[idx]
var cmd_name: String = item["command"]
var sub_params: Dictionary = item.get("params", {})
var raw_result: Dictionary = _dispatcher.dispatch_direct(cmd_name, sub_params)
var status: String = raw_result.get("status", "ok")
var result_entry: Dictionary = {"command": cmd_name, "status": status}
if status == "error":
result_entry["error"] = raw_result.get("error", {})
results.append(result_entry)
stopped_at = idx
break
else:
var data: Dictionary = raw_result.get("data", raw_result)
result_entry["data"] = data
if typeof(data) == TYPE_DICTIONARY and data.get("undoable", false) != true:
all_undoable = false
results.append(result_entry)
succeeded += 1
_capture_histories(histories)
var rolled_back := false
if stopped_at != null and undo and succeeded > 0:
rolled_back = _rollback(succeeded, histories)
var response_data: Dictionary = {
"succeeded": succeeded,
"stopped_at": stopped_at,
"results": results,
"undo": undo,
"rolled_back": rolled_back,
"undoable": stopped_at == null and all_undoable and not rolled_back,
}
if stopped_at != null:
response_data["error"] = results[-1]["error"]
return {"data": response_data}
## Capture the scene's UndoRedo reference for batch rollback. Safe to call
## multiple times; appends only the new reference. MCP write handlers all pin
## their actions to the scene history, so the scene UndoRedo is the only one
## rollback needs. Must be called only after at least one action has been
## committed to the scene history.
func _capture_histories(histories: Array) -> void:
var scene_root := EditorInterface.get_edited_scene_root()
if scene_root == null:
return
var scene_id := _undo_redo.get_object_history_id(scene_root)
var scene_ur := _undo_redo.get_history_undo_redo(scene_id)
if scene_ur != null and not scene_ur in histories:
histories.append(scene_ur)
## Build the unknown-command error for a sub-command. Clarifies that
## batch_execute expects plugin command names (not MCP tool names) and
## surfaces fuzzy suggestions in both the message and structured data.
func _unknown_command_error(idx: int, cmd_name: String) -> Dictionary:
var suggestions := _dispatcher.suggest_similar(cmd_name)
var msg := "commands[%d]: unknown plugin command '%s'. batch_execute expects plugin command names (e.g. 'create_node'), not MCP tool names (e.g. 'node_create')." % [idx, cmd_name]
if not suggestions.is_empty():
msg += " Did you mean: %s?" % ", ".join(suggestions)
var err := ErrorCodes.make(ErrorCodes.UNKNOWN_COMMAND, msg)
err["error"]["data"] = {"suggestions": suggestions}
return err
## Undo `count` actions by calling undo() on captured histories in LIFO order.
## Returns true iff all undo calls succeeded.
func _rollback(count: int, histories: Array) -> bool:
if histories.is_empty():
return false
for _i in range(count):
var undone := false
for ur in histories:
if ur.undo():
undone = true
break
if not undone:
return false
return true
@@ -0,0 +1 @@
uid://dt7um75oofdrh
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
uid://c0lcviccrlrl8
@@ -0,0 +1,81 @@
@tool
extends RefCounted
## Opinionated Camera2D / Camera3D presets.
##
## build(preset_name, overrides) -> {default_type, properties} | null
## properties are merged with caller overrides (overrides win).
const _PRESETS := {
# Top-down roguelite / arena — damped follow feel, drag deadzone.
"topdown_2d": {
"default_type": "2d",
"properties": {
"zoom": {"x": 2.0, "y": 2.0},
"anchor_mode": "drag_center",
"position_smoothing_enabled": true,
"position_smoothing_speed": 5.0,
"rotation_smoothing_enabled": false,
"drag_horizontal_enabled": true,
"drag_vertical_enabled": true,
"drag_left_margin": 0.2,
"drag_right_margin": 0.2,
"drag_top_margin": 0.2,
"drag_bottom_margin": 0.2,
},
},
# Platformer — tight horizontal follow, vertical snap with smoothing on.
"platformer_2d": {
"default_type": "2d",
"properties": {
"zoom": {"x": 1.5, "y": 1.5},
"anchor_mode": "drag_center",
"position_smoothing_enabled": true,
"position_smoothing_speed": 8.0,
"drag_horizontal_enabled": true,
"drag_vertical_enabled": false,
"drag_left_margin": 0.15,
"drag_right_margin": 0.15,
},
},
# Cinematic 3D — narrow FOV, long range. Good for dramatic wide shots.
"cinematic_3d": {
"default_type": "3d",
"properties": {
"fov": 40.0,
"near": 0.1,
"far": 500.0,
"projection": "perspective",
},
},
# Action 3D — wider FOV for first/third-person action gameplay.
"action_3d": {
"default_type": "3d",
"properties": {
"fov": 70.0,
"near": 0.1,
"far": 200.0,
"projection": "perspective",
},
},
}
static func list_presets() -> Array:
return _PRESETS.keys()
## Build a preset blueprint. Returns null if preset_name is unknown.
## overrides is merged on top of preset defaults (caller values win).
static func build(preset_name: String, overrides: Dictionary) -> Variant:
if not _PRESETS.has(preset_name):
return null
var preset: Dictionary = _PRESETS[preset_name]
var properties: Dictionary = (preset.get("properties", {}) as Dictionary).duplicate(true)
for key in overrides:
properties[key] = overrides[key]
return {
"default_type": preset.get("default_type", "2d"),
"properties": properties,
}
@@ -0,0 +1 @@
uid://bl3rfy72o3wy5
+132
View File
@@ -0,0 +1,132 @@
@tool
extends RefCounted
## Value coercion helpers for camera authoring.
##
## Handles:
## - enum-by-name (keep_aspect="keep_height" -> Camera3D.KEEP_HEIGHT)
## - {x, y} dict -> Vector2 (zoom, offset, drag_*_offset)
## - serialization back to JSON-friendly shapes
const _ENUM_TABLES := {
"projection": {
"perspective": Camera3D.PROJECTION_PERSPECTIVE,
"orthogonal": Camera3D.PROJECTION_ORTHOGONAL,
"frustum": Camera3D.PROJECTION_FRUSTUM,
},
"keep_aspect": {
"keep_width": Camera3D.KEEP_WIDTH,
"keep_height": Camera3D.KEEP_HEIGHT,
},
"anchor_mode": {
"fixed_top_left": Camera2D.ANCHOR_MODE_FIXED_TOP_LEFT,
"drag_center": Camera2D.ANCHOR_MODE_DRAG_CENTER,
},
"doppler_tracking": {
"disabled": Camera3D.DOPPLER_TRACKING_DISABLED,
"idle_step": Camera3D.DOPPLER_TRACKING_IDLE_STEP,
"physics_step": Camera3D.DOPPLER_TRACKING_PHYSICS_STEP,
},
"process_callback": {
"physics": Camera2D.CAMERA2D_PROCESS_PHYSICS,
"idle": Camera2D.CAMERA2D_PROCESS_IDLE,
},
}
## Return the enum int for (property, string_name), or null if not a known enum string.
static func resolve_enum(property: String, value: Variant) -> Variant:
if not (value is String):
return null
if not _ENUM_TABLES.has(property):
return null
var table: Dictionary = _ENUM_TABLES[property]
var key: String = String(value).to_lower()
if table.has(key):
return table[key]
return null
## Valid enum names for a property, for error messages.
static func enum_keys(property: String) -> Array:
if not _ENUM_TABLES.has(property):
return []
return (_ENUM_TABLES[property] as Dictionary).keys()
static func parse_vector2(value: Variant) -> Variant:
## Camera-specific sugar kept from the pre-#714 copy: a bare number is
## a uniform zoom, splatted to both axes. Everything else goes through
## the canonical strict parser.
if value is int or value is float:
return Vector2(float(value), float(value))
return McpJsonValues.parse_vector2(value)
static func parse_vector3(value: Variant) -> Variant:
return McpJsonValues.parse_vector3(value)
## Coerce a JSON-shaped value for a camera property against the declared type.
## Returns {ok: true, value: ...} or {ok: false, error: "..."}.
static func coerce(property: String, value: Variant, target_type: int) -> Dictionary:
# Enum-by-name: must match before generic TYPE_INT coercion.
if _ENUM_TABLES.has(property):
if value is String:
var enum_val = resolve_enum(property, value)
if enum_val == null:
return {
"ok": false,
"error": "Invalid %s value: '%s'. Valid: %s" % [
property, value, ", ".join(enum_keys(property))
],
}
return {"ok": true, "value": int(enum_val)}
if value is int or value is float:
return {"ok": true, "value": int(value)}
match target_type:
TYPE_VECTOR2:
var v2 = parse_vector2(value)
if v2 == null:
return {"ok": false, "error": "Invalid vector2 for %s: %s" % [property, value]}
return {"ok": true, "value": v2}
TYPE_VECTOR3:
var v3 = parse_vector3(value)
if v3 == null:
return {"ok": false, "error": "Invalid vector3 for %s: %s" % [property, value]}
return {"ok": true, "value": v3}
TYPE_BOOL:
if value is bool:
return {"ok": true, "value": value}
if value is int or value is float:
return {"ok": true, "value": bool(value)}
return {"ok": false, "error": "Expected bool for %s" % property}
TYPE_INT:
if value is int:
return {"ok": true, "value": value}
if value is float:
return {"ok": true, "value": int(value)}
return {"ok": false, "error": "Expected int for %s" % property}
TYPE_FLOAT:
if value is float:
return {"ok": true, "value": value}
if value is int:
return {"ok": true, "value": float(value)}
return {"ok": false, "error": "Expected number for %s" % property}
TYPE_STRING:
return {"ok": true, "value": String(value)}
return {"ok": true, "value": value}
## Serialize a Variant into a JSON-friendly shape for responses.
static func serialize(value: Variant) -> Variant:
if value == null:
return null
if value is Vector2:
return {"x": value.x, "y": value.y}
if value is Vector3:
return {"x": value.x, "y": value.y, "z": value.z}
return value
@@ -0,0 +1 @@
uid://bgjnubgnv6ses
+123
View File
@@ -0,0 +1,123 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles MCP client configuration commands.
var _connection
var _fallback_launch_context := {}
var _status_workers: Array[Thread] = []
var _status_tearing_down := false
func _init(connection = null, fallback_launch_context = null) -> void:
_connection = connection
# Lazy-loading this handler can reload ClientConfigurator's static script and
# clear its warmed snapshot. Retain the plugin-start capture as a safe
# fallback; the worker still prefers capture_launch_context()'s live snapshot.
if fallback_launch_context is Dictionary:
_fallback_launch_context = fallback_launch_context.duplicate(true)
func configure_client(params: Dictionary) -> Dictionary:
var client_id: String = params.get("client", "")
if not McpClientConfigurator.has_client(client_id):
var valid := ", ".join(McpClientConfigurator.client_ids())
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown client: %s. Use one of: %s" % [client_id, valid])
var result := McpClientConfigurator.configure(client_id)
if result.get("status") == "error":
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
result.get("message", "Configuration failed for '%s'" % client_id))
return {"data": result}
func remove_client(params: Dictionary) -> Dictionary:
var client_id: String = params.get("client", "")
if not McpClientConfigurator.has_client(client_id):
var valid := ", ".join(McpClientConfigurator.client_ids())
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown client: %s. Use one of: %s" % [client_id, valid])
var result := McpClientConfigurator.remove(client_id)
if result.get("status") == "error":
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
result.get("message", "Removal failed for '%s'" % client_id))
return {"data": result}
func check_client_status(params: Dictionary) -> Dictionary:
var request_id: String = params.get("_request_id", "")
if _connection == null or request_id.is_empty():
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Client status requires a deferred request context.",
)
if _status_tearing_down:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Client status handler is shutting down.")
# Match the dock refresh worker's cold-load guard. This is pure-memory and
# performs no launcher discovery, CLI lookup, or config/status probe.
McpClientConfigurator.warm_status_worker_bytecode()
# The aggregate command has a 30-second budget. Its worker resolves the
# shared Claude Desktop/Codex attach launch once, then reuses it for every
# command-shaped client instead of repeating cold launcher discovery.
# Start the worker from the deferred finisher after its first frame. That
# lets the dispatcher register this request before any probe can complete.
_finish_client_status_deferred(
McpClientConfigurator.run_client_status_sweep.bind(_fallback_launch_context),
request_id,
_connection,
)
return McpDispatcher.DEFERRED_RESPONSE
## Called by McpDispatcher.clear() before releasing this lazy handler. Marking
## teardown is deliberately non-blocking: the in-flight coroutine retains this
## handler and its connection across frames, then joins each worker only after
## is_alive() becomes false. New sweeps are rejected immediately.
func prepare_for_teardown() -> void:
_status_tearing_down = true
## This instance coroutine intentionally keeps the lazily-created handler and
## deferred-response connection alive until its worker has been polled and
## joined. The first-frame yield is load-bearing: check_client_status() must
## return the deferred sentinel before the worker can produce a response.
func _finish_client_status_deferred(
worker_callable: Callable, request_id: String, connection
) -> void:
if not is_instance_valid(connection):
return
var tree: SceneTree = connection.get_tree()
if tree == null:
return
await tree.process_frame
if not is_instance_valid(connection) or _status_tearing_down:
return
var worker := Thread.new()
_status_workers.append(worker)
var start_error := worker.start(worker_callable)
if start_error != OK:
_status_workers.erase(worker)
connection.send_deferred_response(request_id, ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Could not start client status worker (error %d)." % start_error,
))
return
while worker.is_alive():
await tree.process_frame
var payload: Variant = worker.wait_to_finish()
_status_workers.erase(worker)
if _status_tearing_down:
return
if not is_instance_valid(connection):
return
if not payload is Dictionary:
payload = ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Client status worker returned an invalid response.",
)
elif payload.has("worker_error"):
payload = ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
str(payload.get("worker_error", "Client status worker failed.")),
)
connection.send_deferred_response(request_id, payload)
@@ -0,0 +1 @@
uid://bmo4foc5fq75c
@@ -0,0 +1,318 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles the control_draw_recipe MCP command. Attaches a shared DrawRecipe
## script to a Control and stores the caller's ordered draw ops in node
## metadata under "_ops". The DrawRecipe script dispatches each op to a
## CanvasItem draw_* call in _draw(). One Ctrl+Z reverts script + meta as a
## single undo step.
const DRAW_RECIPE_SCRIPT := preload("res://addons/godot_ai/runtime/draw_recipe.gd")
const UiHandler := preload("res://addons/godot_ai/handlers/ui_handler.gd")
var _undo_redo: EditorUndoRedoManager
func _init(undo_redo: EditorUndoRedoManager) -> void:
_undo_redo = undo_redo
func control_draw_recipe(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var ops_raw: Variant = params.get("ops", null)
var clear_existing: bool = bool(params.get("clear_existing", true))
if path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
if typeof(ops_raw) != TYPE_ARRAY:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "ops must be an Array")
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
if not node is Control:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"control_draw_recipe requires a Control node, got %s" % node.get_class()
)
var coerced := _coerce_ops(ops_raw)
if coerced.has("error"):
return coerced
var coerced_ops: Array = coerced.ops
var old_script: Variant = node.get_script()
if old_script != null and old_script != DRAW_RECIPE_SCRIPT:
if not clear_existing:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
(
"Node %s already has a script. Pass clear_existing=true to replace."
% path
)
)
var had_meta := node.has_meta("_ops")
var old_ops: Variant = node.get_meta("_ops") if had_meta else null
_undo_redo.create_action("MCP: Draw recipe on %s" % node.name)
_undo_redo.add_do_method(node, "set_script", DRAW_RECIPE_SCRIPT)
_undo_redo.add_do_method(node, "set_meta", "_ops", coerced_ops)
_undo_redo.add_do_method(node, "queue_redraw")
_undo_redo.add_undo_method(node, "set_script", old_script)
if had_meta:
_undo_redo.add_undo_method(node, "set_meta", "_ops", old_ops)
else:
_undo_redo.add_undo_method(node, "remove_meta", "_ops")
_undo_redo.add_undo_method(node, "queue_redraw")
_undo_redo.commit_action()
return {
"data":
{
"path": McpScenePath.from_node(node, scene_root),
"ops_count": coerced_ops.size(),
"script_attached": old_script == null,
"script_replaced": old_script != null and old_script != DRAW_RECIPE_SCRIPT,
"undoable": true,
}
}
## Validate and coerce every op dict. Returns {"ops": Array} or an error dict.
func _coerce_ops(ops: Array) -> Dictionary:
var result: Array = []
for i in ops.size():
var op: Variant = ops[i]
if typeof(op) != TYPE_DICTIONARY:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE, "ops[%d] must be a dictionary" % i
)
var coerced := _coerce_single_op(op, i)
if coerced.has("error"):
return coerced
result.append(coerced.op)
return {"ops": result}
func _coerce_single_op(op: Dictionary, idx: int) -> Dictionary:
var draw_type: String = op.get("draw", "")
if draw_type.is_empty():
return ErrorCodes.make(
ErrorCodes.MISSING_REQUIRED_PARAM, "ops[%d]: missing 'draw' field" % idx
)
match draw_type:
"line":
return _coerce_line(op, idx)
"rect":
return _coerce_rect(op, idx)
"arc":
return _coerce_arc(op, idx)
"circle":
return _coerce_circle(op, idx)
"polyline":
return _coerce_polyline_or_polygon(op, idx, "polyline")
"polygon":
return _coerce_polyline_or_polygon(op, idx, "polygon")
"string":
return _coerce_string(op, idx)
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"ops[%d]: unknown draw type '%s'" % [idx, draw_type]
)
func _require_fields(op: Dictionary, idx: int, kind: String, fields: Array) -> Dictionary:
for f in fields:
if not op.has(f):
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"ops[%d] (%s): missing '%s'" % [idx, kind, f]
)
return {}
func _coerce_typed(value: Variant, prop_type: int, idx: int, kind: String, field: String) -> Dictionary:
var r := UiHandler._coerce_for_type(value, prop_type)
if r.ok:
return {"ok": true, "value": r.value}
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE, "ops[%d] (%s): invalid '%s'" % [idx, kind, field]
)
func _coerce_line(op: Dictionary, idx: int) -> Dictionary:
var missing := _require_fields(op, idx, "line", ["from", "to", "color"])
if missing.has("error"):
return missing
var frm := _coerce_typed(op.from, TYPE_VECTOR2, idx, "line", "from")
if frm.has("error"):
return frm
var to_ := _coerce_typed(op.to, TYPE_VECTOR2, idx, "line", "to")
if to_.has("error"):
return to_
var c := _coerce_typed(op.color, TYPE_COLOR, idx, "line", "color")
if c.has("error"):
return c
var out := {"draw": "line", "from": frm.value, "to": to_.value, "color": c.value}
if op.has("width"):
out["width"] = float(op.width)
if op.has("antialiased"):
out["antialiased"] = bool(op.antialiased)
return {"op": out}
func _coerce_rect(op: Dictionary, idx: int) -> Dictionary:
var missing := _require_fields(op, idx, "rect", ["rect", "color"])
if missing.has("error"):
return missing
var r := _coerce_typed(op.rect, TYPE_RECT2, idx, "rect", "rect")
if r.has("error"):
return r
var c := _coerce_typed(op.color, TYPE_COLOR, idx, "rect", "color")
if c.has("error"):
return c
var out := {"draw": "rect", "rect": r.value, "color": c.value}
if op.has("filled"):
out["filled"] = bool(op.filled)
if op.has("width"):
out["width"] = float(op.width)
return {"op": out}
func _coerce_arc(op: Dictionary, idx: int) -> Dictionary:
var missing := _require_fields(
op, idx, "arc", ["center", "radius", "start_angle", "end_angle", "color"]
)
if missing.has("error"):
return missing
var center := _coerce_typed(op.center, TYPE_VECTOR2, idx, "arc", "center")
if center.has("error"):
return center
var c := _coerce_typed(op.color, TYPE_COLOR, idx, "arc", "color")
if c.has("error"):
return c
var out := {
"draw": "arc",
"center": center.value,
"radius": float(op.radius),
"start_angle": float(op.start_angle),
"end_angle": float(op.end_angle),
"color": c.value,
}
if op.has("point_count"):
out["point_count"] = int(op.point_count)
if op.has("width"):
out["width"] = float(op.width)
if op.has("antialiased"):
out["antialiased"] = bool(op.antialiased)
return {"op": out}
func _coerce_circle(op: Dictionary, idx: int) -> Dictionary:
var missing := _require_fields(op, idx, "circle", ["center", "radius", "color"])
if missing.has("error"):
return missing
var center := _coerce_typed(op.center, TYPE_VECTOR2, idx, "circle", "center")
if center.has("error"):
return center
var c := _coerce_typed(op.color, TYPE_COLOR, idx, "circle", "color")
if c.has("error"):
return c
return {
"op":
{
"draw": "circle",
"center": center.value,
"radius": float(op.radius),
"color": c.value,
}
}
func _coerce_polyline_or_polygon(op: Dictionary, idx: int, kind: String) -> Dictionary:
if not op.has("points"):
return ErrorCodes.make(
ErrorCodes.MISSING_REQUIRED_PARAM, "ops[%d] (%s): missing 'points'" % [idx, kind]
)
if typeof(op.points) != TYPE_ARRAY:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"ops[%d] (%s): 'points' must be an Array" % [idx, kind]
)
var points := PackedVector2Array()
for j in op.points.size():
var p := UiHandler._coerce_for_type(op.points[j], TYPE_VECTOR2)
if not p.ok:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"ops[%d] (%s): points[%d] invalid" % [idx, kind, j]
)
points.append(p.value)
var out := {"draw": kind, "points": points}
if op.has("colors"):
if typeof(op.colors) != TYPE_ARRAY:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"ops[%d] (%s): 'colors' must be an Array" % [idx, kind]
)
var colors := PackedColorArray()
for k in op.colors.size():
var ck := UiHandler._coerce_for_type(op.colors[k], TYPE_COLOR)
if not ck.ok:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"ops[%d] (%s): colors[%d] invalid" % [idx, kind, k]
)
colors.append(ck.value)
out["colors"] = colors
elif op.has("color"):
var c := UiHandler._coerce_for_type(op.color, TYPE_COLOR)
if not c.ok:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE, "ops[%d] (%s): invalid 'color'" % [idx, kind]
)
out["color"] = c.value
else:
return ErrorCodes.make(
ErrorCodes.MISSING_REQUIRED_PARAM,
"ops[%d] (%s): missing 'color' or 'colors'" % [idx, kind]
)
if op.has("width"):
out["width"] = float(op.width)
if op.has("antialiased"):
out["antialiased"] = bool(op.antialiased)
return {"op": out}
func _coerce_string(op: Dictionary, idx: int) -> Dictionary:
var missing := _require_fields(op, idx, "string", ["position", "text", "color"])
if missing.has("error"):
return missing
var pos := _coerce_typed(op.position, TYPE_VECTOR2, idx, "string", "position")
if pos.has("error"):
return pos
var c := _coerce_typed(op.color, TYPE_COLOR, idx, "string", "color")
if c.has("error"):
return c
var out := {
"draw": "string",
"position": pos.value,
"text": str(op.text),
"color": c.value,
}
if op.has("font_size"):
out["font_size"] = int(op.font_size)
if op.has("align"):
out["align"] = int(op.align)
if op.has("max_width"):
out["max_width"] = float(op.max_width)
return {"op": out}
@@ -0,0 +1 @@
uid://buat1mt0fjlqb
+118
View File
@@ -0,0 +1,118 @@
@tool
extends RefCounted
## CSG authoring — create CSG shapes (box, sphere, cylinder, torus, prism)
## and set their boolean operation (union / intersection / subtraction) so
## agents can carve geometry (holes, caves, tunnels) directly in the editor.
##
## All ops target nodes in the currently edited scene by scene-relative
## path. All write ops are undoable via EditorUndoRedoManager. Sibling CSG
## shapes under the same parent combine automatically; use a CSGCombiner3D
## parent when you need explicit grouping.
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const SHAPES := {
"box": "CSGBox3D",
"sphere": "CSGSphere3D",
"cylinder": "CSGCylinder3D",
"torus": "CSGTorus3D",
"polygon": "CSGPolygon3D",
}
const OPERATIONS := {
"union": CSGShape3D.OPERATION_UNION,
"intersection": CSGShape3D.OPERATION_INTERSECTION,
"subtraction": CSGShape3D.OPERATION_SUBTRACTION,
}
var _undo_redo: EditorUndoRedoManager
func _init(undo_redo: EditorUndoRedoManager) -> void:
_undo_redo = undo_redo
## Create a CSG shape under a Node3D parent.
## params: {parent_path, name="", shape="box", operation="union"}
## Returns: {path, name, shape, operation, undoable}
func create(params: Dictionary) -> Dictionary:
var parent_path: String = params.get("parent_path", "")
var shape: String = params.get("shape", "box")
var operation: String = params.get("operation", "union")
var scene_file: String = params.get("scene_file", "")
var shape_class: String = SHAPES.get(shape, "")
if shape_class.is_empty():
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Unknown shape: %s. Valid shapes: %s" % [shape, ", ".join(SHAPES.keys())])
if not OPERATIONS.has(operation):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Unknown operation: %s. Valid operations: %s" % [operation, ", ".join(OPERATIONS.keys())])
var scene_check := McpScenePath.require_edited_scene(scene_file)
if scene_check.has("error"):
return scene_check
var scene_root: Node = scene_check.node
var parent: Node = scene_root
if not parent_path.is_empty():
parent = McpScenePath.resolve(parent_path, scene_root)
if parent == null:
return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND,
McpScenePath.format_parent_error(parent_path, scene_root))
if not parent is Node3D:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"CSG parent must be a Node3D (got %s)" % parent.get_class())
var node: CSGShape3D = ClassDB.instantiate(shape_class)
if node == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % shape_class)
var node_name: String = params.get("name", "")
if node_name.is_empty():
node_name = shape_class
node.name = node_name
node.operation = OPERATIONS[operation]
_undo_redo.create_action("MCP: Create %s" % node.name)
_undo_redo.add_do_method(parent, "add_child", node, true)
_undo_redo.add_do_method(node, "set_owner", scene_root)
_undo_redo.add_do_reference(node)
_undo_redo.add_undo_method(parent, "remove_child", node)
_undo_redo.commit_action()
return {"data": {
"path": McpScenePath.from_node(node, scene_root),
"name": node.name,
"shape": shape,
"operation": operation,
"undoable": true,
}}
## Set the boolean operation of a CSG shape.
## params: {path, operation}
## Returns: {operation, undoable}
func set_operation(params: Dictionary) -> Dictionary:
var operation: String = params.get("operation", "")
if not OPERATIONS.has(operation):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Unknown operation: %s. Valid operations: %s" % [operation, ", ".join(OPERATIONS.keys())])
var resolved := McpNodeValidator.resolve_or_error(
params.get("path", ""), "path", params.get("scene_file", ""))
if resolved.has("error"):
return resolved
var node: Node = resolved.node
if not node is CSGShape3D:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"Node is not a CSGShape3D: %s" % params.get("path", ""))
var shape: CSGShape3D = node
var prev: int = shape.operation
var next: int = OPERATIONS[operation]
## Target the node in both callbacks so the action lands in the scene
## undo history (first-target routing); `set` exists on every Object.
_undo_redo.create_action("MCP: CSG set_operation")
_undo_redo.add_do_method(shape, "set", "operation", next)
_undo_redo.add_undo_method(shape, "set", "operation", prev)
_undo_redo.commit_action()
return {"data": {"operation": operation, "undoable": true}}
@@ -0,0 +1 @@
uid://c4mb0tptu52x2
+243
View File
@@ -0,0 +1,243 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Replaces all points on a Curve / Curve2D / Curve3D resource. The point
## list shape depends on resource type (see `set_points` for the schemas).
##
## Dedicated tool rather than a property set because Curve2D/Curve3D.add_point
## is a method call, not a property — resource_create's `properties` dict can't
## reach it.
const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd")
var _undo_redo: EditorUndoRedoManager
var _connection: McpConnection
func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void:
_undo_redo = undo_redo
_connection = connection
func set_points(params: Dictionary) -> Dictionary:
var node_path: String = params.get("path", "")
var property: String = params.get("property", "")
var resource_path: String = params.get("resource_path", "")
var new_points: Array = params.get("points", [])
var home_err := McpResourceIO.validate_home(params)
if home_err != null:
return home_err
var has_file_target := not resource_path.is_empty()
if not (new_points is Array):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "points must be an array")
var curve: Resource
var node: Node = null
var curve_created := false
if has_file_target:
var rpath_err = McpPathValidator.loadable_error(resource_path, "resource_path")
if rpath_err != null:
return rpath_err
if not ResourceLoader.exists(resource_path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % resource_path)
# ResourceLoader.load() returns Godot's cached Resource. Duplicate
# before mutating so: (a) open scenes holding a reference to this
# .tres don't silently see the new points outside any undo action,
# and (b) if ResourceSaver.save() fails we haven't corrupted the
# in-memory cache (cache/disk divergence). Also guard against
# ResourceLoader.exists() succeeding but load() returning null
# (corrupt .tres, unregistered class) — otherwise curve.get_class()
# on the response line below would crash the plugin.
var loaded_curve: Resource = ResourceLoader.load(resource_path)
if loaded_curve == null:
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Failed to load curve from %s (file exists but load returned null — may be corrupt)" % resource_path
)
curve = loaded_curve.duplicate()
else:
var _scene_check := McpNodeValidator.require_scene_or_error()
if _scene_check.has("error"):
return _scene_check
var scene_root: Node = _scene_check.scene_root
node = McpScenePath.resolve(node_path, scene_root)
if node == null:
return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_node_error(node_path, scene_root))
if not (property in node):
return ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
McpPropertyErrors.build_message(node, property)
)
curve = node.get(property)
# Auto-create a fresh Curve subclass if the slot is empty. Infer the
# concrete class from the property's hint_string (e.g. Path3D.curve's
# hint is "Curve3D"). Creation is bundled into the same undo action
# as the point-set below, so Ctrl-Z rolls back both.
if curve == null:
var inferred := _infer_curve_class(node, property)
if inferred.is_empty():
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Curve slot on %s.%s is null and the Curve class can't be inferred from the property hint — create one first with resource_create (type=Curve3D/Curve2D/Curve)" % [node.get_class(), property]
)
curve = ClassDB.instantiate(inferred)
if curve == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % inferred)
curve_created = true
if not (curve is Curve or curve is Curve2D or curve is Curve3D):
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Resource is %s — must be Curve, Curve2D, or Curve3D" % curve.get_class()
)
var coerced := _coerce_points(curve, new_points)
if coerced.has("error"):
return coerced.error
var new_snapshot: Array = coerced.snapshot
if has_file_target:
_apply_snapshot_to_curve(curve, new_snapshot)
# curve_set_points EDITS an existing .tres, so override the default
# "delete to revert" message via extra_fields.
return McpResourceIO.save_to_disk(curve, resource_path, true, "Curve", {
"curve_class": curve.get_class(),
"point_count": new_snapshot.size(),
"reason": "File save is persistent; edit the .tres file manually to revert",
}, _connection)
# Inline (node-attached) path: swap the curve property so the action lands
# cleanly in scene history, mirroring the resource-swap pattern used by
# material_handler::assign_material. When curve_created is true the
# "old" value is null — undo clears the slot back to empty.
var new_curve: Resource = curve if curve_created else curve.duplicate()
_apply_snapshot_to_curve(new_curve, new_snapshot)
var old_curve: Resource = null if curve_created else curve
_undo_redo.create_action("MCP: Set %d points on %s.%s" % [new_snapshot.size(), node.name, property])
_undo_redo.add_do_property(node, property, new_curve)
_undo_redo.add_undo_property(node, property, old_curve)
_undo_redo.add_do_reference(new_curve)
_undo_redo.commit_action()
return {
"data": {
"path": node_path,
"property": property,
"curve_class": new_curve.get_class(),
"point_count": new_snapshot.size(),
"curve_created": curve_created,
"undoable": true,
}
}
## Infer the concrete Curve class to instantiate for a null property slot.
## Reads the property's hint_string (set by Godot on resource-typed exports)
## to get the exact accepted class name (e.g. "Curve3D" for Path3D.curve).
## Returns empty string if no viable curve class can be determined.
static func _infer_curve_class(node: Node, property: String) -> String:
for prop in node.get_property_list():
if prop.name != property:
continue
var hint_string: String = prop.get("hint_string", "")
if hint_string.is_empty():
return ""
if not ClassDB.class_exists(hint_string):
return ""
if hint_string == "Curve" or hint_string == "Curve2D" or hint_string == "Curve3D":
return hint_string
# Some custom properties may list a parent class; require an exact
# match against our three supported types to avoid surprises.
return ""
return ""
## Convert input `points` into a normalized snapshot of typed values for
## the given curve type. Returns {snapshot: Array} on success or
## {error: ...} on failure.
static func _coerce_points(curve: Resource, points: Array) -> Dictionary:
var snapshot: Array = []
if curve is Curve:
for i in range(points.size()):
var p = points[i]
if not (p is Dictionary) or not p.has("offset") or not p.has("value"):
return {"error": ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Curve points[%d] must be {offset, value, [left_tangent, right_tangent]}" % i
)}
snapshot.append({
"offset": float(p["offset"]),
"value": float(p["value"]),
"left_tangent": float(p.get("left_tangent", 0.0)),
"right_tangent": float(p.get("right_tangent", 0.0)),
})
elif curve is Curve2D:
var zero2 := {"x": 0, "y": 0}
for i in range(points.size()):
var p2 = points[i]
if not (p2 is Dictionary) or not p2.has("position"):
return {"error": ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Curve2D points[%d] must have 'position' (and optional 'in', 'out')" % i
)}
var axes2 := {
"position": p2["position"],
"in": p2.get("in", zero2),
"out": p2.get("out", zero2),
}
var coerced2 := {}
for field in ["position", "in", "out"]:
var v = NodeHandler._coerce_value(axes2[field], TYPE_VECTOR2)
var err := NodeHandler._check_coerced(v, TYPE_VECTOR2, "Curve2D points[%d].%s" % [i, field])
if err != null:
return {"error": err}
coerced2[field] = v
snapshot.append(coerced2)
else: # Curve3D
var zero3 := {"x": 0, "y": 0, "z": 0}
for i in range(points.size()):
var p3 = points[i]
if not (p3 is Dictionary) or not p3.has("position"):
return {"error": ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Curve3D points[%d] must have 'position' (and optional 'in', 'out', 'tilt')" % i
)}
var axes3 := {
"position": p3["position"],
"in": p3.get("in", zero3),
"out": p3.get("out", zero3),
}
var coerced3 := {}
for field in ["position", "in", "out"]:
var v = NodeHandler._coerce_value(axes3[field], TYPE_VECTOR3)
var err := NodeHandler._check_coerced(v, TYPE_VECTOR3, "Curve3D points[%d].%s" % [i, field])
if err != null:
return {"error": err}
coerced3[field] = v
coerced3["tilt"] = float(p3.get("tilt", 0.0))
snapshot.append(coerced3)
return {"snapshot": snapshot}
func _apply_snapshot_to_curve(curve: Resource, snapshot: Array) -> void:
curve.clear_points()
if curve is Curve:
for p: Dictionary in snapshot:
curve.add_point(
Vector2(p.offset, p.value),
p.left_tangent,
p.right_tangent
)
elif curve is Curve2D:
for p: Dictionary in snapshot:
curve.add_point(p.position, p["in"], p.out)
elif curve is Curve3D:
for i in range(snapshot.size()):
var p: Dictionary = snapshot[i]
curve.add_point(p.position, p["in"], p.out)
curve.set_point_tilt(i, p.tilt)
@@ -0,0 +1 @@
uid://dboqr06a1fvqx
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
uid://dcro7yc8bor6v
@@ -0,0 +1,181 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Creates an Environment (+ optional Sky + ProceduralSkyMaterial) chain and
## either assigns it to a WorldEnvironment node or saves it to a .tres file.
## Bundles sub-resource creation + assignment in a single undo action.
const ResourceHandler := preload("res://addons/godot_ai/handlers/resource_handler.gd")
var _undo_redo: EditorUndoRedoManager
var _connection: McpConnection
func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void:
_undo_redo = undo_redo
_connection = connection
const _PRESETS := {
"default": {"sky": true, "fog": false},
"clear": {"sky": true, "fog": false},
"sunset": {"sky": true, "fog": false},
"night": {"sky": true, "fog": false},
"fog": {"sky": true, "fog": true},
}
func create_environment(params: Dictionary) -> Dictionary:
var node_path: String = params.get("path", "")
var resource_path: String = params.get("resource_path", "")
var overwrite: bool = params.get("overwrite", false)
var preset: String = params.get("preset", "default")
var properties: Dictionary = params.get("properties", {})
var sky_param = params.get("sky", null) # nullable — falls back to preset default
# environment_create targets the whole WorldEnvironment node (no separate
# `property` param) — pass require_property=false.
var home_err := McpResourceIO.validate_home(params, false)
if home_err != null:
return home_err
if not _PRESETS.has(preset):
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid preset '%s'. Valid: %s" % [preset, ", ".join(_PRESETS.keys())]
)
var preset_config: Dictionary = _PRESETS[preset]
var want_sky: bool = preset_config.sky
var sky_properties: Dictionary = {}
if sky_param != null:
if sky_param is bool:
want_sky = sky_param
elif sky_param is Dictionary:
var sky_config: Dictionary = (sky_param as Dictionary).duplicate()
var material_type: String = String(sky_config.get("sky_material", "procedural")).to_lower()
if material_type != "procedural":
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"sky.sky_material must be 'procedural' when sky is a dictionary"
)
sky_config.erase("sky_material")
sky_properties = sky_config
want_sky = true
else:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"sky must be a bool, null, or dictionary of ProceduralSkyMaterial properties"
)
var env := Environment.new()
var sky: Sky = null
var sky_material: ProceduralSkyMaterial = null
if want_sky:
sky_material = ProceduralSkyMaterial.new()
sky = Sky.new()
sky.sky_material = sky_material
env.background_mode = Environment.BG_SKY
env.sky = sky
else:
env.background_mode = Environment.BG_CLEAR_COLOR
_apply_preset(env, sky_material, preset)
if not sky_properties.is_empty():
var sky_apply_err := ResourceHandler._apply_resource_properties(sky_material, sky_properties)
if sky_apply_err != null:
return sky_apply_err
if preset_config.fog:
env.volumetric_fog_enabled = true
env.volumetric_fog_density = 0.03
if not properties.is_empty():
var apply_err := ResourceHandler._apply_resource_properties(env, properties)
if apply_err != null:
return apply_err
if not resource_path.is_empty():
return _save_environment(env, sky, sky_material, resource_path, overwrite, preset)
return _assign_environment(env, sky, sky_material, node_path, preset)
static func _apply_preset(env: Environment, sky_material: ProceduralSkyMaterial, preset: String) -> void:
match preset:
"default", "clear":
if sky_material != null:
sky_material.sky_top_color = Color(0.38, 0.45, 0.55)
sky_material.sky_horizon_color = Color(0.65, 0.67, 0.7)
sky_material.ground_horizon_color = Color(0.65, 0.67, 0.7)
sky_material.ground_bottom_color = Color(0.2, 0.17, 0.13)
sky_material.sun_angle_max = 30.0
env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
env.ambient_light_energy = 1.0
"sunset":
if sky_material != null:
sky_material.sky_top_color = Color(0.25, 0.3, 0.55)
sky_material.sky_horizon_color = Color(1.0, 0.55, 0.3)
sky_material.ground_horizon_color = Color(0.85, 0.4, 0.25)
sky_material.ground_bottom_color = Color(0.2, 0.12, 0.1)
env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
env.ambient_light_color = Color(1.0, 0.75, 0.55)
env.ambient_light_energy = 0.8
"night":
if sky_material != null:
sky_material.sky_top_color = Color(0.02, 0.02, 0.07)
sky_material.sky_horizon_color = Color(0.05, 0.07, 0.15)
sky_material.ground_horizon_color = Color(0.04, 0.05, 0.1)
sky_material.ground_bottom_color = Color(0.0, 0.0, 0.02)
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = Color(0.2, 0.22, 0.35)
env.ambient_light_energy = 0.4
"fog":
if sky_material != null:
sky_material.sky_top_color = Color(0.65, 0.65, 0.7)
sky_material.sky_horizon_color = Color(0.8, 0.8, 0.82)
sky_material.ground_horizon_color = Color(0.7, 0.7, 0.72)
sky_material.ground_bottom_color = Color(0.3, 0.3, 0.32)
env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
env.ambient_light_energy = 0.7
func _assign_environment(env: Environment, sky: Sky, sky_material: ProceduralSkyMaterial, node_path: String, preset: String) -> Dictionary:
var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
if _resolved.has("error"):
return _resolved
var node: Node = _resolved.node
var _scene_root: Node = _resolved.scene_root
if not (node is WorldEnvironment):
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Node at %s is %s — must be WorldEnvironment" % [node_path, node.get_class()]
)
var old_env = (node as WorldEnvironment).environment
_undo_redo.create_action("MCP: Create Environment (%s) for %s" % [preset, node.name])
_undo_redo.add_do_property(node, "environment", env)
_undo_redo.add_undo_property(node, "environment", old_env)
_undo_redo.add_do_reference(env)
if sky != null:
_undo_redo.add_do_reference(sky)
if sky_material != null:
_undo_redo.add_do_reference(sky_material)
_undo_redo.commit_action()
return {
"data": {
"path": node_path,
"preset": preset,
"sky_created": sky != null,
"sky_material_class": sky_material.get_class() if sky_material != null else "",
"undoable": true,
}
}
func _save_environment(env: Environment, _sky: Sky, _sky_material: ProceduralSkyMaterial, resource_path: String, overwrite: bool, preset: String) -> Dictionary:
return McpResourceIO.save_to_disk(env, resource_path, overwrite, "Environment", {
"preset": preset,
}, _connection)
@@ -0,0 +1 @@
uid://b1k7jldwjp5jt
@@ -0,0 +1,312 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const ScriptHandler := preload("res://addons/godot_ai/handlers/script_handler.gd")
## Handles file read/write operations and reimport within the Godot project.
## Bounds for the deferred scan wait. `write_file`/`reimport` register single
## files with `update_file()` (cheap, no global-class rebuild); `scan_filesystem`
## is the heavier, explicit "rebuild the class registry" path agents call after
## adding `class_name` scripts headlessly (no window focus to trigger it).
## Kept under the dispatcher's "scan_filesystem" deferred timeout (30s) so we
## always send a real reply before a DEFERRED_TIMEOUT is synthesised.
const _SCAN_START_GRACE_MSEC := 750
const _SCAN_SETTLE_MAX_MSEC := 28000
## Sidecar the editor writes next to every imported resource. `reimport` reads
## it to tell imported assets from files that merely have a filesystem entry
## (see `_is_imported_resource`).
const IMPORT_SIDECAR_SUFFIX := ".import"
## Shared single-flight latch for scan_filesystem. `is_scanning()` alone can't
## enforce single-flight: `EditorFileSystem.scan()` doesn't flip `is_scanning()`
## for a frame or two (hence _SCAN_START_GRACE_MSEC), so a second request landing
## in that window would observe `false` and stack another scan() — the exact
## stacked-worker SIGABRT this op exists to avoid (dsarno/godot#6). The latch is
## set before the first scan() and cleared when its settle coroutine finishes;
## concurrent requests coalesce onto the running scan instead of starting one.
## `static` so it's shared across handler instances; it resets on plugin reload
## (script re-parse), which self-heals any latch orphaned by a mid-await teardown.
static var _scan_in_flight := false
var _connection: McpConnection
func _init(connection: McpConnection = null) -> void:
_connection = connection
func read_file(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var path_err = McpPathValidator.path_error(path, "path")
if path_err != null:
return path_err
if not FileAccess.file_exists(path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "File not found: %s" % path)
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to open file: %s" % path)
var content := file.get_as_text()
file.close()
return {
"data": {
"path": path,
"content": content,
"size": content.length(),
"line_count": content.count("\n") + (1 if not content.is_empty() else 0),
}
}
func write_file(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var content: String = params.get("content", "")
var path_err = McpPathValidator.path_error(path, "path", true)
if path_err != null:
return path_err
var existed_before := FileAccess.file_exists(path)
# Shared write path (#714): parent mkdir + write/flush + explicit error
# check live on McpResourceIO so this can't drift from create_script.
var write_failure: Variant = McpResourceIO.write_text_to_disk(path, content)
if write_failure != null:
return write_failure
# Single-file register, not a full scan() — a scan() per write stacks
# filesystem WorkerThreadPool tasks under concurrent writes and can SIGABRT
# in the global-class update (see dsarno/godot#6 and create_script in
# script_handler.gd). update_file() is what reimport()/material/theme use.
var efs := EditorInterface.get_resource_filesystem()
if efs != null:
efs.update_file(path)
var data := {
"path": path,
"size": content.length(),
"undoable": false,
"reason": "File system operations cannot be undone via editor undo",
}
var is_gdscript := path.ends_with(".gd")
## A .gd written through the filesystem tool used to skip the parse
## diagnostics create_script attaches (#714) — the agent's broken
## script reported plain success and the parse error surfaced only in
## later editor logs. Same shared check, same response fields. A bare
## ScriptHandler works here: the diagnostics path touches no instance
## state (it stays an instance method only for test stubbing).
if is_gdscript:
ScriptHandler.new(null)._attach_gdscript_diagnostics(data, path, content)
data["committed"] = true
data["import_settled"] = existed_before
data["import_settle"] = "already_known" if existed_before else "not_waited"
McpResourceIO.attach_cleanup_hint(data, existed_before, [path])
## Fresh `.gd` writes take create_script's import-settle deferral (#714,
## #261): reply only once ResourceLoader can see the new resource (or the
## bounded window elapses), so write_file -> script_attach back-to-back
## can't 404 on the not-yet-imported script. This CHANGES write_file's
## response timing for that case — the reply lands up to
## McpResourceIO.IMPORT_SETTLE_MAX_MSEC later instead of immediately.
## Scoped to .gd: ResourceLoader never learns plain text files, so an
## unconditional wait would burn the full window on every fresh .txt.
## Overwrites, batch_execute (no request_id) and unit-test contexts (no
## connection) keep the synchronous reply.
var request_id: String = params.get("_request_id", "")
if is_gdscript and not existed_before and _connection != null and not request_id.is_empty():
McpResourceIO.finish_text_write_deferred(_connection, request_id, path, data)
return McpDispatcher.DEFERRED_RESPONSE
return {"data": data}
func reimport(params: Dictionary) -> Dictionary:
var paths: Array = params.get("paths", [])
if paths.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: paths (non-empty array)")
var efs := EditorInterface.get_resource_filesystem()
if efs == null:
return ErrorCodes.make_not_ready(
ErrorCodes.SUB_EDITOR_UNAVAILABLE,
"EditorFileSystem not available", false)
var reimported: Array[String] = []
var skipped_non_imported: Array[String] = []
var not_found: Array[String] = []
for path_variant in paths:
var path: String = str(path_variant)
var path_err := McpPathValidator.validate_resource_path(path)
if not path_err.is_empty():
not_found.append("%s (%s)" % [path, path_err])
continue
if not FileAccess.file_exists(path):
not_found.append("%s (file does not exist)" % path)
continue
efs.update_file(path)
if _is_imported_resource(path):
reimported.append(path)
else:
skipped_non_imported.append(path)
var data := {
"reimported": reimported,
"skipped_non_imported": skipped_non_imported,
"not_found": not_found,
"reimported_count": reimported.size(),
"skipped_non_imported_count": skipped_non_imported.size(),
"not_found_count": not_found.size(),
"undoable": false,
"reason": "Reimport is a file system operation",
}
## Only when it applies: a hint on every call would cost tokens on the
## all-assets path this op is actually for.
if not skipped_non_imported.is_empty():
data["skipped_non_imported_hint"] = (
"%d path(s) are not imported resources. Their editor filesystem entry was "
+ "refreshed, but no import ran — a success here is not evidence that a "
+ "script parsed or that diagnostics were produced. Use script_patch/"
+ "script_create for GDScript diagnostics, or filesystem_manage(op=\"scan\") "
+ "for an asset the editor has not imported yet."
) % skipped_non_imported.size()
return {"data": data}
## #778: `update_file()` registers a path with the resource pipeline; it only
## runs an *import* for files that have one. Scripts, scenes and hand-written
## `.tres` are not imported resources, so listing them under `reimported` reads
## as proof that a parse or import ran when nothing did.
##
## The `.import` sidecar is the editor's own record that a path goes through
## the import pipeline, so it decides the split. An extension allow-list was
## rejected: importers come and go with plugins, so the list would drift out of
## agreement with the editor it claims to describe.
##
## Known edge: an asset the editor has never imported (just written, no scan
## yet) has no sidecar and reports as non-imported. That is accurate at the
## moment of the call — `update_file()` did not import it either — and the
## hint names `scan` as the way through.
##
## Behaviour is unchanged for every path: `update_file()` still runs on all of
## them, because refreshing an externally-edited `.tscn`/`.tres` is a real use
## of this op. This splits the report, not the work.
static func _is_imported_resource(path: String) -> bool:
if path.ends_with(IMPORT_SIDECAR_SUFFIX):
return false ## The sidecar itself is not an imported resource.
return FileAccess.file_exists(path + IMPORT_SIDECAR_SUFFIX)
## Force a full EditorFileSystem scan and wait for it to settle. This is the
## headless equivalent of the editor regaining window focus: `update_file()`
## (used by write_file/reimport/script_create) registers a single file with the
## resource pipeline but does NOT rebuild the global `class_name` table, so a
## freshly-created `class_name MyThing extends Resource` stays invisible to
## `ClassDB`/`ProjectSettings.get_global_class_list()` until a scan runs. Agents
## driving the editor without focus call this once after a batch of script
## creates to make new types instantiable/referenceable. See issue #83.
func scan_filesystem(params: Dictionary) -> Dictionary:
var efs := EditorInterface.get_resource_filesystem()
if efs == null:
return ErrorCodes.make_not_ready(
ErrorCodes.SUB_EDITOR_UNAVAILABLE,
"EditorFileSystem not available", false)
var request_id: String = params.get("_request_id", "")
# Async path: a scan can't be awaited on the calling frame without freezing
# the editor, so hand control back to the dispatcher (DEFERRED_RESPONSE) and
# push the real reply from a static coroutine once the scan settles — by
# which point new class_names are registered.
if _connection != null and not request_id.is_empty():
_finish_scan_deferred(_connection, request_id, efs)
return McpDispatcher.DEFERRED_RESPONSE
# Synchronous fallback: batch_execute (no request_id) and unit-test contexts
# (no connection) can't await, so kick a single-flight scan and return
# immediately without the settle confirmation. Respect the latch so we don't
# stack onto a deferred scan; don't set it (there's no coroutine here to
# clear it — the brief is_scanning() window covers the rest).
var already := _scan_in_flight or efs.is_scanning()
if not already:
efs.scan()
return {
"data": {
"scan_completed": false,
"scan_settle": "not_waited",
"was_already_scanning": already,
"global_class_count": ProjectSettings.get_global_class_list().size(),
# Present in both paths for a consistent response shape; the sync
# path doesn't await, so it can't measure a delta.
"global_classes_registered_delta": 0,
"undoable": false,
"reason": "Filesystem scan is an editor operation",
}
}
## `static` is load-bearing for the same reason as ScriptHandler's deferred
## finish: the coroutine must outlive the handler RefCounted, which can be freed
## mid-await (e.g. an editor_reload_plugin fired during the scan). Parameterise
## everything; reference no instance state.
static func _finish_scan_deferred(
connection: McpConnection,
request_id: String,
efs: EditorFileSystem,
) -> void:
if not is_instance_valid(connection):
return
var tree := connection.get_tree()
if tree == null:
return
var classes_before := ProjectSettings.get_global_class_list().size()
# Single-flight via the shared `_scan_in_flight` latch (NOT is_scanning(),
# which lags scan() by a frame or two — see the latch declaration). Only the
# request that sets the latch calls scan(); concurrent requests coalesce and
# just await the running scan. This is what actually prevents the stacked
# scan() SIGABRT (dsarno/godot#6), even within the start-grace window.
var was_already_scanning := _scan_in_flight or efs.is_scanning()
var we_started := not was_already_scanning
if we_started:
_scan_in_flight = true
efs.scan()
# Hand back a frame so _dispatch() registers this request as deferred before
# the coroutine can push a reply (mirrors McpResourceIO.finish_text_write_deferred).
await tree.process_frame
var deadline_ms := Time.get_ticks_msec() + _SCAN_SETTLE_MAX_MSEC
var start_grace_ms := Time.get_ticks_msec() + _SCAN_START_GRACE_MSEC
var saw_scanning := efs.is_scanning()
while Time.get_ticks_msec() < deadline_ms:
if efs.is_scanning():
saw_scanning = true
elif saw_scanning or Time.get_ticks_msec() > start_grace_ms:
# Either the scan ran and finished, or it never flipped is_scanning()
# within the grace window (a no-op scan because nothing changed).
break
await tree.process_frame
# Clear the latch in all paths (no try/finally in GDScript): do it before the
# is_instance_valid early-return so a freed connection can't orphan it.
if we_started:
_scan_in_flight = false
if not is_instance_valid(connection):
return
var completed := not efs.is_scanning()
var classes_after := ProjectSettings.get_global_class_list().size()
connection.send_deferred_response(request_id, {
"data": {
"scan_completed": completed,
"scan_settle": "settled" if completed else "timeout",
"was_already_scanning": was_already_scanning,
"global_class_count": classes_after,
"global_classes_registered_delta": classes_after - classes_before,
"undoable": false,
"reason": "Filesystem scan is an editor operation",
}
})
@@ -0,0 +1 @@
uid://c7ovtpdiumtju
+192
View File
@@ -0,0 +1,192 @@
@tool
extends RefCounted
## GridMap authoring — set, fill, clear, and read 3D cells plus mesh-library
## items directly in the editor scene with full undo/redo support.
##
## All ops target GridMap nodes in the currently edited scene by
## scene-relative path (e.g. "/Main/Terrain"). All write ops are undoable
## via EditorUndoRedoManager.
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const MAX_FILL_CELLS := 4096
var _undo_redo: EditorUndoRedoManager
func _init(undo_redo: EditorUndoRedoManager) -> void:
_undo_redo = undo_redo
## Set a single cell item. item = -1 erases the cell.
## params: {path, item, map_x, map_y, map_z, orientation=0}
## Returns: {map_x, map_y, map_z, item, orientation, undoable}
func set_item(params: Dictionary) -> Dictionary:
var gm := _resolve_gridmap(params)
if gm.has("error"): return gm
var node: GridMap = gm.node
var pos := Vector3i(int(params.get("map_x", 0)), int(params.get("map_y", 0)), int(params.get("map_z", 0)))
var item := int(params.get("item", 0))
var orientation := int(params.get("orientation", 0))
if orientation < 0 or orientation > 24:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"orientation must be in 0..24 (GridMap baked rotations), got %d" % orientation)
var prev := _capture_cell_state(node, pos)
_undo_redo.create_action("MCP: GridMap set_item")
_undo_redo.add_do_method(node, "set_cell_item", pos, item, orientation)
_undo_redo.add_undo_method(self, "_restore_cell_state", node, pos, prev)
_undo_redo.commit_action()
return {"data": {"map_x": pos.x, "map_y": pos.y, "map_z": pos.z,
"item": item, "orientation": orientation, "undoable": true}}
## Fill a box region with one item in a single undo action.
## params: {path, item, rect_x, rect_y, rect_z, rect_w, rect_h, rect_d, orientation=0}
## Returns: {cells_filled, rect: {x, y, z, w, h, d}}
func fill(params: Dictionary) -> Dictionary:
var gm := _resolve_gridmap(params)
if gm.has("error"): return gm
var node: GridMap = gm.node
var item := int(params.get("item", 0))
var orientation := int(params.get("orientation", 0))
if orientation < 0 or orientation > 24:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"orientation must be in 0..24 (GridMap baked rotations), got %d" % orientation)
var rx := int(params.get("rect_x", 0)); var ry := int(params.get("rect_y", 0)); var rz := int(params.get("rect_z", 0))
var rw := int(params.get("rect_w", 1)); var rh := int(params.get("rect_h", 1)); var rd := int(params.get("rect_d", 1))
if rw <= 0 or rh <= 0 or rd <= 0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"rect_w, rect_h and rect_d must be > 0 (got %d x %d x %d)" % [rw, rh, rd])
var cell_count := rw * rh * rd
if cell_count > MAX_FILL_CELLS:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Region too large: %d cells exceeds max %d" % [cell_count, MAX_FILL_CELLS])
var snapshot: Array[Dictionary] = []
for x in range(rx, rx + rw):
for y in range(ry, ry + rh):
for z in range(rz, rz + rd):
var pos := Vector3i(x, y, z)
snapshot.append({"pos": pos, "state": _capture_cell_state(node, pos)})
_undo_redo.create_action("MCP: GridMap fill %dx%dx%d" % [rw, rh, rd])
## First callback targets the node so the action lands in the scene undo
## history (first-target routing); _apply_fill batches the rest of the
## region into a single history entry instead of one per cell.
_undo_redo.add_do_method(node, "set_cell_item", snapshot[0].pos, item, orientation)
_undo_redo.add_do_method(self, "_apply_fill", node, snapshot, item, orientation)
_undo_redo.add_undo_method(self, "_restore_rect_snapshot", node, snapshot)
_undo_redo.commit_action()
return {"data": {"cells_filled": snapshot.size(),
"rect": {"x": rx, "y": ry, "z": rz, "w": rw, "h": rh, "d": rd}, "undoable": true}}
## Remove all cells from the GridMap.
## params: {path}
## Returns: {cleared: true}
func clear_layer(params: Dictionary) -> Dictionary:
var gm := _resolve_gridmap(params)
if gm.has("error"): return gm
var node: GridMap = gm.node
var used := node.get_used_cells()
if used.size() > MAX_FILL_CELLS:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"GridMap has %d cells, exceeds max %d for undoable clear"
% [used.size(), MAX_FILL_CELLS])
var snapshot := _capture_used_cells_snapshot(node)
_undo_redo.create_action("MCP: GridMap clear")
_undo_redo.add_do_method(node, "clear")
_undo_redo.add_undo_method(self, "_restore_cells_snapshot", node, snapshot)
_undo_redo.commit_action()
return {"data": {"cleared": true, "undoable": true}}
## Return all used cell coordinates.
## params: {path}
## Returns: {cells: [{x, y, z}, ...], count: int}
func get_used_cells(params: Dictionary) -> Dictionary:
var gm := _resolve_gridmap(params)
if gm.has("error"): return gm
var node: GridMap = gm.node
var result: Array = []
for c in node.get_used_cells():
result.append({"x": c.x, "y": c.y, "z": c.z})
return {"data": {"cells": result, "count": result.size()}}
## List the items available in the GridMap's MeshLibrary, so agents can
## discover item ids and names before placing cells (the 3D analogue of
## tileset atlas inspection).
## params: {path}
## Returns: {library: res:// path or "", items: [{item, name, mesh}...], count}
func list_library_items(params: Dictionary) -> Dictionary:
var gm := _resolve_gridmap(params)
if gm.has("error"): return gm
var node: GridMap = gm.node
var library: MeshLibrary = node.mesh_library
if library == null:
return {"data": {"library": "", "items": [], "count": 0}}
var items: Array = []
var ids := library.get_item_list()
ids.sort()
for item in ids:
var mesh: Mesh = library.get_item_mesh(item)
var mesh_path := mesh.resource_path if mesh != null else ""
items.append({
"item": item,
"name": library.get_item_name(item),
"mesh": mesh_path,
})
return {"data": {"library": library.resource_path, "items": items, "count": items.size()}}
## Resolve a GridMap node from params["path"] in the currently edited
## scene. Returns {"node": GridMap} on success, or an error dict.
func _resolve_gridmap(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var scene_file: String = params.get("scene_file", "")
var resolved := McpNodeValidator.resolve_or_error(path, "path", scene_file)
if resolved.has("error"):
return resolved
var node: Node = resolved.node
if not node is GridMap:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"Node is not a GridMap: %s" % path)
return {"node": node}
func _capture_cell_state(node: GridMap, pos: Vector3i) -> Dictionary:
var item := node.get_cell_item(pos)
if item == -1:
return {"has_item": false}
return {"has_item": true, "item": item, "orientation": node.get_cell_item_orientation(pos)}
func _capture_used_cells_snapshot(node: GridMap) -> Array[Dictionary]:
var snapshot: Array[Dictionary] = []
for pos in node.get_used_cells():
snapshot.append({"pos": pos, "state": _capture_cell_state(node, pos)})
return snapshot
func _restore_cells_snapshot(node: GridMap, snapshot: Array[Dictionary]) -> void:
node.clear()
for entry in snapshot:
_restore_cell_state(node, entry.pos, entry.state)
## Batched do-method for fill: one undo-history entry applies the whole
## region instead of one entry per cell.
func _apply_fill(node: GridMap, snapshot: Array[Dictionary], item: int, orientation: int) -> void:
for entry in snapshot:
node.set_cell_item(entry.pos, item, orientation)
func _restore_rect_snapshot(node: GridMap, snapshot: Array[Dictionary]) -> void:
for entry in snapshot:
_restore_cell_state(node, entry.pos, entry.state)
func _restore_cell_state(node: GridMap, pos: Vector3i, state: Dictionary) -> void:
if not state.get("has_item", false):
node.set_cell_item(pos, -1)
return
node.set_cell_item(pos, int(state.get("item", 0)), int(state.get("orientation", 0)))
@@ -0,0 +1 @@
uid://cdv1yrjeyo5es
+462
View File
@@ -0,0 +1,462 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles input action listing, creation, removal, and event binding.
## Actions are persisted via ProjectSettings so they survive editor restarts.
func list_actions(params: Dictionary) -> Dictionary:
var include_builtin: bool = params.get("include_builtin", false)
## Authoritative source for user-authored actions is the ``[input]``
## section of ``project.godot``. ``ProjectSettings.has_setting`` is not
## reliable here because Godot registers ``ui_*`` defaults via
## ``GLOBAL_DEF_BASIC``, which makes ``has_setting`` return true for
## them. Reading the file via ``ConfigFile`` distinguishes the user's
## entries from engine-registered defaults regardless of namespace.
## See #213.
var user_authored := _read_user_authored_actions()
var actions: Array[Dictionary] = []
var seen := {}
for action_name in InputMap.get_actions():
var name_str := str(action_name)
var is_user_action := user_authored.has(name_str)
if not include_builtin and not is_user_action:
continue
seen[name_str] = true
var events: Array[Dictionary] = []
for event in InputMap.action_get_events(action_name):
events.append(_serialize_event(event))
actions.append({
"name": name_str,
"events": events,
"event_count": events.size(),
"is_builtin": not is_user_action,
"loaded_in_input_map": true,
})
for action_name in user_authored.keys():
var name_str := str(action_name)
if seen.has(name_str):
continue
var setting: Dictionary = user_authored.get(name_str, {})
var events: Array[Dictionary] = []
for event in setting.get("events", []):
if event is InputEvent:
events.append(_serialize_event(event))
else:
events.append({"type": type_string(typeof(event)), "string": str(event)})
actions.append({
"name": name_str,
"events": events,
"event_count": events.size(),
"is_builtin": false,
"loaded_in_input_map": false,
})
return {"data": {"actions": actions, "count": actions.size()}}
func _read_user_authored_actions() -> Dictionary:
var cfg := ConfigFile.new()
if cfg.load("res://project.godot") != OK:
return {}
if not cfg.has_section("input"):
return {}
var result: Dictionary = {}
for key in cfg.get_section_keys("input"):
var value = cfg.get_value("input", key, {})
result[key] = value if value is Dictionary else {}
return result
func add_action(params: Dictionary) -> Dictionary:
var action: String = params.get("action", "")
var deadzone: float = params.get("deadzone", 0.5)
if action.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action")
var deadzone_error := _validate_deadzone(deadzone)
if deadzone_error.has("error"):
return deadzone_error
if InputMap.has_action(action):
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Action '%s' already exists" % action)
InputMap.add_action(action, deadzone)
var key := "input/%s" % action
ProjectSettings.set_setting(key, {
"deadzone": deadzone,
"events": [],
})
var err := ProjectSettings.save()
if err != OK:
InputMap.erase_action(action)
ProjectSettings.clear(key)
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
"Failed to save project settings while adding action '%s': %s (error %d)" % [action, error_string(err), err])
return {
"data": {
"action": action,
"deadzone": deadzone,
"undoable": false,
"reason": "Input actions are saved to project.godot",
}
}
func ensure_action(params: Dictionary) -> Dictionary:
var action: String = params.get("action", "")
var deadzone: float = params.get("deadzone", 0.5)
if action.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action")
var deadzone_error := _validate_deadzone(deadzone)
if deadzone_error.has("error"):
return deadzone_error
var result := _ensure_action_state(action, deadzone)
if result.has("error"):
return result
return {"data": result}
func remove_action(params: Dictionary) -> Dictionary:
var action: String = params.get("action", "")
if action.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action")
var key := "input/%s" % action
var was_loaded := InputMap.has_action(action)
var old_setting = ProjectSettings.get_setting(key) if ProjectSettings.has_setting(key) else null
## An action can live in the editor process's InputMap, in project.godot,
## or both. Actions persisted by a previous editor session exist only on
## disk (`loaded_in_input_map: false` in list_actions) — those must still
## be removable. #632
if not was_loaded and old_setting == null:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Action '%s' not found" % action)
if was_loaded:
InputMap.erase_action(action)
if old_setting != null:
ProjectSettings.clear(key)
var err := ProjectSettings.save()
if err != OK:
if was_loaded:
var dz: float = old_setting.get("deadzone", 0.5) if old_setting is Dictionary else 0.5
InputMap.add_action(action, dz)
if old_setting is Dictionary:
for ev in old_setting.get("events", []):
if ev is InputEvent:
InputMap.action_add_event(action, ev)
ProjectSettings.set_setting(key, old_setting)
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
"Failed to save project settings while removing action '%s': %s (error %d)" % [action, error_string(err), err])
return {
"data": {
"action": action,
"removed": true,
"was_loaded": was_loaded,
"undoable": false,
"reason": "Input actions are saved to project.godot",
}
}
func bind_event(params: Dictionary) -> Dictionary:
var action: String = params.get("action", "")
var event_type: String = params.get("event_type", "")
if action.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action")
if event_type.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: event_type")
if not InputMap.has_action(action):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Action '%s' not found. Call input_map_manage(op='add_action', params={action: '%s'}) first." % [action, action])
var event_or_error = _create_event(event_type, params)
if event_or_error is Dictionary:
return event_or_error
var event: InputEvent = event_or_error
InputMap.action_add_event(action, event)
var err := _save_action_events(action)
if err != OK:
InputMap.action_erase_event(action, event)
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
"Failed to save project settings while binding event to action '%s': %s (error %d)" % [action, error_string(err), err])
return {
"data": {
"action": action,
"event": _serialize_event(event),
"undoable": false,
"reason": "Input bindings are saved to project.godot",
}
}
func ensure_binding(params: Dictionary) -> Dictionary:
var action: String = params.get("action", "")
var event_type: String = params.get("event_type", "")
var deadzone: float = params.get("deadzone", 0.5)
if action.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action")
if event_type.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: event_type")
var deadzone_error := _validate_deadzone(deadzone)
if deadzone_error.has("error"):
return deadzone_error
var event_or_error = _create_event(event_type, params)
if event_or_error is Dictionary:
return event_or_error
var event: InputEvent = event_or_error
var ensured := _ensure_action_state(action, deadzone)
if ensured.has("error"):
return ensured
for existing in InputMap.action_get_events(action):
if _events_match(existing, event):
return {
"data": {
"action": action,
"event": _serialize_event(existing),
"already_bound": true,
"action_created": ensured.get("created", false),
"undoable": false,
"reason": "Input binding already exists",
}
}
InputMap.action_add_event(action, event)
var err := _save_action_events(action)
if err != OK:
InputMap.action_erase_event(action, event)
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
"Failed to save project settings while binding event to action '%s': %s (error %d)" % [action, error_string(err), err])
return {
"data": {
"action": action,
"event": _serialize_event(event),
"already_bound": false,
"action_created": ensured.get("created", false),
"undoable": false,
"reason": "Input bindings are saved to project.godot",
}
}
func _validate_deadzone(deadzone: float) -> Dictionary:
if deadzone < 0.0 or deadzone > 1.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"deadzone must be in [0.0, 1.0] (got %s). Typical values are 0.2-0.5; default is 0.5." % deadzone)
return {}
func _ensure_action_state(action: String, deadzone: float) -> Dictionary:
var key := "input/%s" % action
var user_authored := _read_user_authored_actions()
var existed_in_input_map := InputMap.has_action(action)
var existed_in_project := user_authored.has(action) or ProjectSettings.has_setting(key)
var old_setting = user_authored.get(action, null) if user_authored.has(action) else null
if old_setting == null and ProjectSettings.has_setting(key):
old_setting = ProjectSettings.get_setting(key)
if not existed_in_input_map:
var dz := deadzone
if old_setting is Dictionary:
dz = float(old_setting.get("deadzone", deadzone))
InputMap.add_action(action, dz)
if old_setting is Dictionary:
for ev in old_setting.get("events", []):
if ev is InputEvent:
InputMap.action_add_event(action, ev)
if not existed_in_project:
var err := _save_action_events(action)
if err != OK:
if not existed_in_input_map:
InputMap.erase_action(action)
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
"Failed to save project settings while ensuring action '%s': %s (error %d)" % [action, error_string(err), err])
var stored_deadzone := deadzone
if ProjectSettings.has_setting(key):
var stored = ProjectSettings.get_setting(key)
if stored is Dictionary:
stored_deadzone = float(stored.get("deadzone", deadzone))
return {
"action": action,
"deadzone": stored_deadzone,
"created": not existed_in_input_map and not existed_in_project,
"already_exists": existed_in_input_map or existed_in_project,
"loaded_in_input_map": true,
"persisted": true,
"undoable": false,
"reason": "Input actions are saved to project.godot",
}
## Returns an InputEvent on success, or a Dictionary error on failure.
## Caller must check ``result is Dictionary`` before treating it as an event.
func _create_event(event_type: String, params: Dictionary):
match event_type:
"key":
var ev := InputEventKey.new()
var keycode_str: String = params.get("keycode", "")
if keycode_str.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM,
"event_type='key' requires keycode (e.g. 'Space', 'A', 'Enter', 'Escape', 'F1').")
ev.keycode = OS.find_keycode_from_string(keycode_str)
if ev.keycode == KEY_NONE:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid keycode '%s'. Use Godot keycode names like 'A', 'Space', 'Enter', 'Escape', 'F1', 'Left', 'Right'." % keycode_str)
ev.ctrl_pressed = params.get("ctrl", false)
ev.alt_pressed = params.get("alt", false)
ev.shift_pressed = params.get("shift", false)
ev.meta_pressed = params.get("meta", false)
ev.device = -1
return ev
"mouse_button":
if not params.has("button"):
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM,
"event_type='mouse_button' requires button (1=left, 2=right, 3=middle, 4=wheel up, 5=wheel down).")
var button: int = int(params.get("button", 0))
if button <= 0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"mouse_button button must be > 0 (got %d). Use 1=left, 2=right, 3=middle, 4=wheel up, 5=wheel down." % button)
var ev := InputEventMouseButton.new()
ev.button_index = button
ev.device = -1
return ev
"joy_button":
if not params.has("button"):
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM,
"event_type='joy_button' requires button (JoyButton index, e.g. 0=A/Cross, 1=B/Circle).")
var ev := InputEventJoypadButton.new()
ev.button_index = int(params.get("button", 0))
return ev
"joy_axis":
var axis_param = params.get("axis", null)
if axis_param == null:
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM,
"event_type='joy_axis' requires axis (JoyAxis index, e.g. 0=left stick X, 1=left stick Y).")
var axis: int
match typeof(axis_param):
TYPE_INT:
axis = axis_param
TYPE_FLOAT:
if axis_param != floor(axis_param):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"joy_axis axis must be an integer JoyAxis index (got %s)." % str(axis_param))
axis = int(axis_param)
TYPE_STRING:
var axis_text := str(axis_param)
if not axis_text.is_valid_int():
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"joy_axis axis must be an integer JoyAxis index (got '%s')." % axis_text)
axis = int(axis_text)
_:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"joy_axis axis must be an integer JoyAxis index (got %s)." % type_string(typeof(axis_param)))
var ev := InputEventJoypadMotion.new()
ev.axis = axis
ev.axis_value = float(params.get("axis_value", 1.0))
return ev
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Unsupported event_type: '%s'. Use 'key', 'mouse_button', 'joy_button', or 'joy_axis'." % event_type)
func _serialize_event(event: InputEvent) -> Dictionary:
if event is InputEventKey:
return {
"type": "key",
"keycode": OS.get_keycode_string(event.keycode),
"physical_keycode": OS.get_keycode_string(event.physical_keycode),
"ctrl": event.ctrl_pressed,
"alt": event.alt_pressed,
"shift": event.shift_pressed,
"meta": event.meta_pressed,
}
if event is InputEventMouseButton:
return {
"type": "mouse_button",
"button": event.button_index,
}
if event is InputEventJoypadButton:
return {
"type": "joy_button",
"button": event.button_index,
}
if event is InputEventJoypadMotion:
return {
"type": "joy_axis",
"axis": event.axis,
"axis_value": event.axis_value,
}
return {"type": event.get_class(), "string": str(event)}
func _events_match(a: InputEvent, b: InputEvent) -> bool:
if a is InputEventKey and b is InputEventKey:
return _key_events_match(a as InputEventKey, b as InputEventKey)
return _serialize_event(a) == _serialize_event(b)
func _key_events_match(a: InputEventKey, b: InputEventKey) -> bool:
if a.ctrl_pressed != b.ctrl_pressed:
return false
if a.alt_pressed != b.alt_pressed:
return false
if a.shift_pressed != b.shift_pressed:
return false
if a.meta_pressed != b.meta_pressed:
return false
var a_codes := [a.keycode, a.physical_keycode]
var b_codes := [b.keycode, b.physical_keycode]
for a_code in a_codes:
if int(a_code) == KEY_NONE:
continue
for b_code in b_codes:
if int(b_code) != KEY_NONE and int(a_code) == int(b_code):
return true
return false
func _save_action_events(action: String) -> int:
var events: Array = []
for event in InputMap.action_get_events(action):
events.append(event)
var key := "input/%s" % action
var had_setting := ProjectSettings.has_setting(key)
var old_setting = ProjectSettings.get_setting(key) if had_setting else null
var deadzone: float = 0.5
if old_setting is Dictionary:
deadzone = old_setting.get("deadzone", 0.5)
elif InputMap.has_action(action):
deadzone = InputMap.action_get_deadzone(action)
ProjectSettings.set_setting(key, {
"deadzone": deadzone,
"events": events,
})
var err := ProjectSettings.save()
if err != OK:
if had_setting:
ProjectSettings.set_setting(key, old_setting)
else:
ProjectSettings.clear(key)
return err
@@ -0,0 +1 @@
uid://buk68rbwssqwp
@@ -0,0 +1,809 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles Material authoring: creating .tres files, setting BaseMaterial3D
## properties / shader uniforms, assigning to nodes, high-level presets.
##
## File-resource lifecycle mirrors ThemeHandler (create/load/mutate/save).
## Undo pattern mirrors AnimationHandler (single create_action bundles
## every dependency spawn).
const MaterialValues := preload("res://addons/godot_ai/handlers/material_values.gd")
const MaterialPresets := preload("res://addons/godot_ai/handlers/material_presets.gd")
const _TYPE_TO_CLASS := {
"standard": "StandardMaterial3D",
"orm": "ORMMaterial3D",
"canvas_item": "CanvasItemMaterial",
"shader": "ShaderMaterial",
}
const _SUPPORTED_SUFFIXES := [".tres", ".material", ".res"]
var _undo_redo: EditorUndoRedoManager
var _connection: McpConnection
func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void:
_undo_redo = undo_redo
_connection = connection
# ============================================================================
# material_create
# ============================================================================
func create_material(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var type_str: String = params.get("type", "standard")
var shader_path: String = params.get("shader_path", "")
var overwrite: bool = params.get("overwrite", false)
var err := _validate_material_path(path, "path", true)
if err != null:
return err
if not _TYPE_TO_CLASS.has(type_str):
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid material type '%s'. Valid: %s" % [type_str, ", ".join(_TYPE_TO_CLASS.keys())]
)
var existed_before := FileAccess.file_exists(path)
if existed_before and not overwrite:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Material already exists at %s (pass overwrite=true to replace)" % path
)
var mat := _instantiate_material(type_str)
if mat == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate material")
if type_str == "shader":
if shader_path.is_empty():
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"ShaderMaterial requires shader_path (res:// / uid:// / user:// path to a .gdshader)"
)
var shader_path_err = McpPathValidator.loadable_error(shader_path, "shader_path")
if shader_path_err != null:
return shader_path_err
if not ResourceLoader.exists(shader_path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Shader not found: %s" % shader_path)
var shader_res := ResourceLoader.load(shader_path)
if not (shader_res is Shader):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Shader" % shader_path)
(mat as ShaderMaterial).shader = shader_res
var dir_path := path.get_base_dir()
var mkdir_err := DirAccess.make_dir_recursive_absolute(dir_path)
if mkdir_err != OK and mkdir_err != ERR_ALREADY_EXISTS:
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Failed to create directory: %s (error %d)" % [dir_path, mkdir_err]
)
var save_err := McpResourceIO.guarded_save(mat, path, _connection)
if save_err != OK:
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Failed to save material to %s (error %d)" % [path, save_err]
)
var efs := EditorInterface.get_resource_filesystem()
if efs != null:
efs.update_file(path)
return {
"data": {
"path": path,
"type": type_str,
"class": mat.get_class(),
"shader_path": shader_path,
"overwritten": existed_before,
"undoable": false,
"reason": "File creation is persistent; delete the file manually to revert",
}
}
# ============================================================================
# material_set_param
# ============================================================================
func set_param(params: Dictionary) -> Dictionary:
var load_result := _load_material_from_path(params.get("path", ""), true)
if load_result.has("error"):
return load_result
var mat: Material = load_result.material
var mat_path: String = load_result.path
var property: String = params.get("param", "")
if property.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: param")
if not ("value" in params):
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: value")
var raw_value = params.get("value")
# Probe the property. We allow any property present in get_property_list,
# plus `shader` on ShaderMaterial.
var prop_type: int = TYPE_NIL
var property_exists := false
for prop in mat.get_property_list():
if prop.name == property:
property_exists = true
prop_type = prop.get("type", TYPE_NIL)
break
if not property_exists:
return ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
McpPropertyErrors.build_message(mat, property)
)
var coerced := MaterialValues.coerce_material_value(property, raw_value, prop_type)
if not coerced.ok:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerced.error))
var new_value = coerced.value
var old_value = mat.get(property)
_undo_redo.create_action("MCP: Set material %s.%s" % [mat_path.get_file(), property])
_undo_redo.add_do_method(self, "_apply_param", mat_path, property, new_value, false)
_undo_redo.add_undo_method(self, "_apply_param", mat_path, property, old_value, false)
_undo_redo.commit_action()
return {
"data": {
"path": mat_path,
"property": property,
"value": MaterialValues.serialize_value(new_value),
"previous_value": MaterialValues.serialize_value(old_value),
"undoable": true,
}
}
# ============================================================================
# material_set_shader_param
# ============================================================================
func set_shader_param(params: Dictionary) -> Dictionary:
var load_result := _load_material_from_path(params.get("path", ""), true)
if load_result.has("error"):
return load_result
var mat: Material = load_result.material
var mat_path: String = load_result.path
if not (mat is ShaderMaterial):
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Material at %s is %s, not ShaderMaterial" % [mat_path, mat.get_class()]
)
var shader_mat := mat as ShaderMaterial
if shader_mat.shader == null:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"ShaderMaterial at %s has no shader assigned" % mat_path
)
var param_name: String = params.get("param", "")
if param_name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: param")
if not ("value" in params):
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: value")
# Verify the uniform exists in the shader.
var uniform_type := _shader_uniform_type(shader_mat.shader, param_name)
if uniform_type == TYPE_NIL:
return ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Shader uniform '%s' not declared on shader at %s" % [param_name, shader_mat.shader.resource_path]
)
var raw_value = params.get("value")
var coerced := MaterialValues.coerce_material_value(param_name, raw_value, uniform_type)
if not coerced.ok:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerced.error))
var new_value = coerced.value
var old_value = shader_mat.get_shader_parameter(param_name)
_undo_redo.create_action("MCP: Set shader param %s.%s" % [mat_path.get_file(), param_name])
_undo_redo.add_do_method(self, "_apply_shader_param", mat_path, param_name, new_value)
_undo_redo.add_undo_method(self, "_apply_shader_param", mat_path, param_name, old_value)
_undo_redo.commit_action()
return {
"data": {
"path": mat_path,
"param": param_name,
"value": MaterialValues.serialize_value(new_value),
"previous_value": MaterialValues.serialize_value(old_value),
"undoable": true,
}
}
# ============================================================================
# material_get
# ============================================================================
func get_material(params: Dictionary) -> Dictionary:
var load_result := _load_material_from_path(params.get("path", ""))
if load_result.has("error"):
return load_result
var mat: Material = load_result.material
var mat_path: String = load_result.path
var properties: Array[Dictionary] = []
for prop in mat.get_property_list():
var usage: int = prop.get("usage", 0)
if not (usage & PROPERTY_USAGE_EDITOR):
continue
var name: String = prop.name
if name.begins_with("shader_parameter/"):
continue # handled below
var value = mat.get(name)
if value == null and prop.type != TYPE_NIL:
continue
properties.append({
"name": name,
"type": type_string(prop.type),
"value": MaterialValues.serialize_value(value),
})
var shader_params: Array[Dictionary] = []
if mat is ShaderMaterial:
var shader_mat := mat as ShaderMaterial
if shader_mat.shader != null:
for u in shader_mat.shader.get_shader_uniform_list():
var u_name: String = u.get("name", "")
if u_name.is_empty():
continue
shader_params.append({
"name": u_name,
"type": type_string(u.get("type", TYPE_NIL)),
"value": MaterialValues.serialize_value(shader_mat.get_shader_parameter(u_name)),
})
var reverse_type_map := _reverse_type_map()
var shader_path_str := ""
if mat is ShaderMaterial:
var sm := mat as ShaderMaterial
if sm.shader != null:
shader_path_str = sm.shader.resource_path
return {
"data": {
"path": mat_path,
"class": mat.get_class(),
"type": reverse_type_map.get(mat.get_class(), ""),
"properties": properties,
"property_count": properties.size(),
"shader_parameters": shader_params,
"shader_path": shader_path_str,
}
}
# ============================================================================
# material_list
# ============================================================================
func list_materials(params: Dictionary) -> Dictionary:
var root: String = params.get("root", "res://")
var type_filter: String = params.get("type", "")
var root_err = McpPathValidator.path_error(root, "root")
if root_err != null:
return root_err
var efs := EditorInterface.get_resource_filesystem()
if efs == null:
return ErrorCodes.make_not_ready(
ErrorCodes.SUB_EDITOR_UNAVAILABLE,
"EditorFileSystem not available", false)
var results: Array[Dictionary] = []
var start_dir := efs.get_filesystem_path(root)
if start_dir == null:
start_dir = efs.get_filesystem()
_scan_materials(start_dir, type_filter, root, results)
return {"data": {"materials": results, "count": results.size()}}
func _scan_materials(dir: EditorFileSystemDirectory, type_filter: String, root: String, out: Array[Dictionary]) -> void:
if dir == null:
return
for i in dir.get_file_count():
var file_path := dir.get_file_path(i)
if not file_path.begins_with(root):
continue
var file_type := dir.get_file_type(i)
var is_material := file_type == "Material" or ClassDB.is_parent_class(file_type, "Material")
if not is_material:
# Some material variants serialize as specific classes.
if not (file_type in _TYPE_TO_CLASS.values()):
continue
if not type_filter.is_empty():
if file_type != type_filter and not ClassDB.is_parent_class(file_type, type_filter):
continue
out.append({"path": file_path, "class": file_type})
for i in dir.get_subdir_count():
_scan_materials(dir.get_subdir(i), type_filter, root, out)
# ============================================================================
# material_assign
# ============================================================================
func assign_material(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
if node_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: node_path")
var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
if _resolved.has("error"):
return _resolved
var node: Node = _resolved.node
var _scene_root: Node = _resolved.scene_root
var slot: String = params.get("slot", "override")
var resource_path: String = params.get("resource_path", "")
var create_if_missing: bool = params.get("create_if_missing", false)
var type_str: String = params.get("type", "standard")
var slot_result := _resolve_slot_property(node, slot)
if slot_result.has("error"):
return slot_result
var property: String = slot_result.property
# Load or create the material.
var mat: Material = null
var material_created := false
if not resource_path.is_empty():
var rpath_err = McpPathValidator.loadable_error(resource_path, "resource_path")
if rpath_err != null:
return rpath_err
if not ResourceLoader.exists(resource_path):
if create_if_missing:
# We'd need to create a new file here — refuse; callers should
# use material_create first or omit resource_path to get an
# inline material.
return ErrorCodes.make(
ErrorCodes.RESOURCE_NOT_FOUND,
"Resource not found: %s. Create it first with material_create or omit resource_path for an inline material." % resource_path
)
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % resource_path)
var loaded := ResourceLoader.load(resource_path)
if not (loaded is Material):
var loaded_class := "null"
if loaded != null:
loaded_class = loaded.get_class()
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Resource at %s is not a Material (got %s)" % [resource_path, loaded_class]
)
mat = loaded
else:
if not create_if_missing:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Missing resource_path (pass create_if_missing=true to create a new inline material)"
)
if not _TYPE_TO_CLASS.has(type_str):
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid material type '%s'" % type_str
)
mat = _instantiate_material(type_str)
material_created = true
var old_value = node.get(property)
_undo_redo.create_action("MCP: Assign material to %s.%s" % [node.name, property])
_undo_redo.add_do_property(node, property, mat)
_undo_redo.add_undo_property(node, property, old_value)
if material_created:
_undo_redo.add_do_reference(mat)
_undo_redo.commit_action()
return {
"data": {
"node_path": node_path,
"property": property,
"slot": slot,
"resource_path": resource_path,
"material_class": mat.get_class(),
"material_created": material_created,
"undoable": true,
}
}
# ============================================================================
# material_apply_to_node
# ============================================================================
func apply_to_node(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
if node_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: node_path")
var type_str: String = params.get("type", "standard")
if not _TYPE_TO_CLASS.has(type_str):
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid material type '%s'. Valid: %s" % [type_str, ", ".join(_TYPE_TO_CLASS.keys())]
)
var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
if _resolved.has("error"):
return _resolved
var node: Node = _resolved.node
var _scene_root: Node = _resolved.scene_root
var slot: String = params.get("slot", "override")
var slot_result := _resolve_slot_property(node, slot)
if slot_result.has("error"):
return slot_result
var property: String = slot_result.property
var mat := _instantiate_material(type_str)
var props_to_set: Dictionary = params.get("params", {})
var applied: Array[String] = []
for prop_name in props_to_set:
var apply_err := _apply_one_param_on_instance(mat, String(prop_name), props_to_set[prop_name])
if apply_err != null:
return apply_err
applied.append(String(prop_name))
var save_to: String = params.get("save_to", "")
var saved := false
var overwritten := false
if not save_to.is_empty():
var save_err_validation := _validate_material_path(save_to, "save_to", true)
if save_err_validation != null:
return save_err_validation
# Same clobber guard as create_material/apply_preset: agents reuse
# names like res://materials/metal.tres, and a silent save here
# destroys a hand-authored file that undo can't restore (undo only
# reverts the node's slot assignment, not file contents). See #685.
var existed_before := FileAccess.file_exists(save_to)
if existed_before and not params.get("overwrite", false):
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Material already exists at %s (pass overwrite=true to replace)" % save_to
)
overwritten = existed_before
var dir_path := save_to.get_base_dir()
var mkdir_err := DirAccess.make_dir_recursive_absolute(dir_path)
if mkdir_err != OK and mkdir_err != ERR_ALREADY_EXISTS:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to create directory: %s" % dir_path)
var save_err := McpResourceIO.guarded_save(mat, save_to, _connection)
if save_err != OK:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to save material to %s (error %d)" % [save_to, save_err])
var efs := EditorInterface.get_resource_filesystem()
if efs != null:
efs.update_file(save_to)
# Prefer the on-disk reference (keeps the scene ref small), but fall
# back to the in-memory material if the reload fails — otherwise a null
# would clear the slot and crash mat.get_class() below.
var reloaded := ResourceLoader.load(save_to)
if reloaded != null:
mat = reloaded
saved = true
var old_value = node.get(property)
_undo_redo.create_action("MCP: Apply %s material to %s" % [type_str, node.name])
_undo_redo.add_do_property(node, property, mat)
_undo_redo.add_undo_property(node, property, old_value)
_undo_redo.add_do_reference(mat)
_undo_redo.commit_action()
return {
"data": {
"node_path": node_path,
"property": property,
"slot": slot,
"type": type_str,
"class": mat.get_class(),
"applied_params": applied,
"material_created": true,
"saved_to": save_to if saved else "",
"overwritten": overwritten,
"undoable": true,
}
}
# ============================================================================
# material_apply_preset
# ============================================================================
func apply_preset(params: Dictionary) -> Dictionary:
var preset_name: String = params.get("preset", "")
if preset_name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: preset")
var overrides: Dictionary = params.get("overrides", {})
var blueprint = MaterialPresets.build(preset_name, overrides)
if blueprint == null:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Unknown preset '%s'. Valid: %s" % [preset_name, ", ".join(MaterialPresets.list())]
)
var type_str: String = blueprint.get("type", "standard")
var preset_params: Dictionary = blueprint.get("params", {})
var path: String = params.get("path", "")
var node_path: String = params.get("node_path", "")
if path.is_empty() and node_path.is_empty():
return ErrorCodes.make(
ErrorCodes.MISSING_REQUIRED_PARAM,
"Pass at least one of: path (save to disk), node_path (assign to node)"
)
# If both path and node_path, save to disk, then assign the saved resource.
# If only path, save to disk.
# If only node_path, inline material via apply_to_node.
if not node_path.is_empty() and path.is_empty():
# Inline
var inline_result := apply_to_node({
"node_path": node_path,
"type": type_str,
"params": preset_params,
"slot": params.get("slot", "override"),
})
if inline_result.has("data"):
inline_result.data["preset"] = preset_name
inline_result.data["assigned"] = true
inline_result.data["path"] = ""
inline_result.data["saved_to_disk"] = false
inline_result.data["reason"] = "Inline material assigned to node"
return inline_result
# Save-to-disk path. Validate the path BEFORE the exists/overwrite
# check, matching create_material's order — an invalid path should
# always be reported as invalid, not as an overwrite conflict.
var path_err := _validate_material_path(path, "path", true)
if path_err != null:
return path_err
var existed_before := FileAccess.file_exists(path)
if existed_before and not params.get("overwrite", false):
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Material already exists at %s (pass overwrite=true to replace)" % path
)
var mat := _instantiate_material(type_str)
for prop_name in preset_params:
var apply_err := _apply_one_param_on_instance(mat, String(prop_name), preset_params[prop_name])
if apply_err != null:
return apply_err
var dir_path := path.get_base_dir()
var mkdir_err := DirAccess.make_dir_recursive_absolute(dir_path)
if mkdir_err != OK and mkdir_err != ERR_ALREADY_EXISTS:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to create directory: %s" % dir_path)
var save_err := McpResourceIO.guarded_save(mat, path, _connection)
if save_err != OK:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to save material: %s" % path)
var efs := EditorInterface.get_resource_filesystem()
if efs != null:
efs.update_file(path)
var assigned := false
if not node_path.is_empty():
var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
if _resolved.has("error"):
return _resolved
var node: Node = _resolved.node
var _scene_root: Node = _resolved.scene_root
var slot_result := _resolve_slot_property(node, params.get("slot", "override"))
if slot_result.has("error"):
return slot_result
var property: String = slot_result.property
var saved_mat := ResourceLoader.load(path)
var old_value = node.get(property)
_undo_redo.create_action("MCP: Apply preset %s to %s" % [preset_name, node.name])
_undo_redo.add_do_property(node, property, saved_mat)
_undo_redo.add_undo_property(node, property, old_value)
_undo_redo.commit_action()
assigned = true
return {
"data": {
"preset": preset_name,
"type": type_str,
"path": path,
"node_path": node_path,
"material_created": true,
"assigned": assigned,
"saved_to_disk": true,
"undoable": assigned, # assign is undoable; save is not
"reason": "" if assigned else "File save is not undoable",
}
}
# ============================================================================
# Undo-callable: applies a param on the loaded resource and saves.
# ============================================================================
func _apply_param(mat_path: String, property: String, value: Variant, _is_shader: bool) -> void:
var mat: Material = ResourceLoader.load(mat_path)
if mat == null:
push_warning("MCP: Failed to load material for undo/redo: %s" % mat_path)
return
mat.set(property, value)
McpResourceIO.guarded_save(mat, mat_path, _connection)
func _apply_shader_param(mat_path: String, param_name: String, value: Variant) -> void:
var mat: Material = ResourceLoader.load(mat_path)
if mat == null or not (mat is ShaderMaterial):
push_warning("MCP: Failed to load shader material for undo/redo: %s" % mat_path)
return
(mat as ShaderMaterial).set_shader_parameter(param_name, value)
McpResourceIO.guarded_save(mat, mat_path, _connection)
# ============================================================================
# Helpers
# ============================================================================
static func _instantiate_material(type_str: String) -> Material:
match type_str:
"standard":
return StandardMaterial3D.new()
"orm":
return ORMMaterial3D.new()
"canvas_item":
return CanvasItemMaterial.new()
"shader":
return ShaderMaterial.new()
return null
static func _reverse_type_map() -> Dictionary:
var out := {}
for k in _TYPE_TO_CLASS:
out[_TYPE_TO_CLASS[k]] = k
return out
static func _validate_material_path(path: String, param_name: String, for_write: bool = false) -> Variant:
if path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % param_name)
var path_err := McpPathValidator.validate_resource_path(path, for_write)
if not path_err.is_empty():
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "%s: %s" % [param_name, path_err])
var has_suffix := false
for s in _SUPPORTED_SUFFIXES:
if path.ends_with(s):
has_suffix = true
break
if not has_suffix:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"%s must end with one of %s (got %s)" % [param_name, ", ".join(_SUPPORTED_SUFFIXES), path]
)
return null
func _load_material_from_path(path: String, for_write: bool = false) -> Dictionary:
var err := _validate_material_path(path, "path", for_write)
if err != null:
return err
if not ResourceLoader.exists(path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Material not found: %s" % path)
var res := ResourceLoader.load(path)
if res == null or not (res is Material):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Material" % path)
return {"material": res, "path": path}
## Map a slot name to a Godot property name on the given node.
## Returns {property: "..."} or an error dict.
func _resolve_slot_property(node: Node, slot: String) -> Dictionary:
if slot == "override":
if node is MeshInstance3D or node is CSGShape3D:
return {"property": "material_override"}
if node is CanvasItem:
return {"property": "material"}
if node is GPUParticles3D or node is GPUParticles2D or node is CPUParticles3D or node is CPUParticles2D:
return {"property": "material_override"} if node is GeometryInstance3D else {"property": "material"}
return ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Slot 'override' not supported on %s" % node.get_class()
)
if slot == "canvas":
if node is CanvasItem:
return {"property": "material"}
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Slot 'canvas' requires a CanvasItem (got %s)" % node.get_class()
)
if slot == "process":
if node is GPUParticles3D or node is GPUParticles2D:
return {"property": "process_material"}
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Slot 'process' requires a GPUParticles2D/3D (got %s)" % node.get_class()
)
if slot.begins_with("surface_"):
if not (node is MeshInstance3D):
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Slot '%s' requires a MeshInstance3D (got %s)" % [slot, node.get_class()]
)
var idx_str := slot.substr(len("surface_"))
if not idx_str.is_valid_int():
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid surface slot: %s" % slot)
var idx := int(idx_str)
var mi := node as MeshInstance3D
var surf_count := mi.mesh.get_surface_count() if mi.mesh != null else 0
if idx < 0 or idx >= surf_count:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Surface index %d out of range (mesh has %d surfaces)" % [idx, surf_count]
)
return {"property": "surface_material_override/%d" % idx}
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Unknown slot '%s'. Valid: override, canvas, process, surface_N" % slot
)
## Apply one property to an in-memory material instance; returns null on
## success or an error dict on failure.
func _apply_one_param_on_instance(mat: Material, property: String, raw_value: Variant) -> Variant:
var prop_type: int = TYPE_NIL
var property_exists := false
for prop in mat.get_property_list():
if prop.name == property:
property_exists = true
prop_type = prop.get("type", TYPE_NIL)
break
if not property_exists:
return ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
McpPropertyErrors.build_message(mat, property)
)
var coerced := MaterialValues.coerce_material_value(property, raw_value, prop_type)
if not coerced.ok:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerced.error))
mat.set(property, coerced.value)
return null
## Inspect a shader to get the Variant type of a uniform. Returns TYPE_NIL if
## the uniform is not declared.
static func _shader_uniform_type(shader: Shader, name: String) -> int:
if shader == null:
return TYPE_NIL
for u in shader.get_shader_uniform_list():
if u.get("name", "") == name:
return int(u.get("type", TYPE_NIL))
return TYPE_NIL
@@ -0,0 +1 @@
uid://blh4norn3rjga
@@ -0,0 +1,92 @@
@tool
extends RefCounted
## Curated material preset blueprints.
##
## Each preset returns {type, params}. Handler applies them through the
## normal material build path so they get undo + validation for free.
const _PRESETS := {
"metal": {
"type": "orm",
"params": {
"metallic": 1.0,
"roughness": 0.25,
"albedo_color": {"r": 0.85, "g": 0.85, "b": 0.88, "a": 1.0},
},
},
"glass": {
"type": "standard",
"params": {
"transparency": "alpha",
"albedo_color": {"r": 0.9, "g": 0.95, "b": 1.0, "a": 0.3},
"metallic": 0.0,
"metallic_specular": 0.5,
"roughness": 0.05,
"refraction_enabled": true,
"refraction_scale": 0.05,
},
},
"emissive": {
"type": "standard",
"params": {
"emission_enabled": true,
"emission_energy_multiplier": 3.0,
"emission": {"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0},
"albedo_color": {"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0},
},
},
"unlit": {
"type": "standard",
"params": {
"shading_mode": "unshaded",
"albedo_color": {"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0},
},
},
"matte": {
"type": "standard",
"params": {
"roughness": 1.0,
"metallic": 0.0,
"albedo_color": {"r": 0.7, "g": 0.7, "b": 0.7, "a": 1.0},
},
},
"ceramic": {
"type": "standard",
"params": {
"roughness": 0.4,
"metallic": 0.0,
"clearcoat_enabled": true,
"clearcoat": 0.7,
"clearcoat_roughness": 0.15,
"albedo_color": {"r": 0.95, "g": 0.95, "b": 0.95, "a": 1.0},
},
},
}
static func list() -> Array:
return _PRESETS.keys()
static func has(preset_name: String) -> bool:
return _PRESETS.has(preset_name)
## Returns a deep-copied {type, params} blueprint for the named preset, or
## null if the preset is unknown. Overrides are merged into params.
static func build(preset_name: String, overrides: Dictionary) -> Variant:
if not _PRESETS.has(preset_name):
return null
var entry: Dictionary = _PRESETS[preset_name].duplicate(true)
var params: Dictionary = entry.get("params", {})
# Allow overrides to change type, too.
if overrides.has("type"):
entry["type"] = overrides["type"]
for key in overrides:
if key == "type":
continue
params[key] = overrides[key]
entry["params"] = params
return entry
@@ -0,0 +1 @@
uid://bnuwye1r8ow7g
+194
View File
@@ -0,0 +1,194 @@
@tool
extends RefCounted
## Value coercion helpers for material authoring.
##
## Extends node_handler._coerce_value with material-specific cases:
## - enum-by-name (transparency="alpha" → TRANSPARENCY_ALPHA)
## - texture path → Texture2D
## - {r,g,b,a} dict → Color (also handled by node coerce, but we want it inline)
const _ENUM_TABLES := {
"transparency": {
"disabled": BaseMaterial3D.TRANSPARENCY_DISABLED,
"alpha": BaseMaterial3D.TRANSPARENCY_ALPHA,
"alpha_scissor": BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR,
"alpha_hash": BaseMaterial3D.TRANSPARENCY_ALPHA_HASH,
"alpha_depth_pre_pass": BaseMaterial3D.TRANSPARENCY_ALPHA_DEPTH_PRE_PASS,
},
"shading_mode": {
"unshaded": BaseMaterial3D.SHADING_MODE_UNSHADED,
"per_pixel": BaseMaterial3D.SHADING_MODE_PER_PIXEL,
"per_vertex": BaseMaterial3D.SHADING_MODE_PER_VERTEX,
},
"blend_mode": {
"mix": BaseMaterial3D.BLEND_MODE_MIX,
"add": BaseMaterial3D.BLEND_MODE_ADD,
"sub": BaseMaterial3D.BLEND_MODE_SUB,
"mul": BaseMaterial3D.BLEND_MODE_MUL,
},
"cull_mode": {
"back": BaseMaterial3D.CULL_BACK,
"front": BaseMaterial3D.CULL_FRONT,
"disabled": BaseMaterial3D.CULL_DISABLED,
},
"depth_draw_mode": {
"opaque_only": BaseMaterial3D.DEPTH_DRAW_OPAQUE_ONLY,
"always": BaseMaterial3D.DEPTH_DRAW_ALWAYS,
"disabled": BaseMaterial3D.DEPTH_DRAW_DISABLED,
},
"diffuse_mode": {
"burley": BaseMaterial3D.DIFFUSE_BURLEY,
"lambert": BaseMaterial3D.DIFFUSE_LAMBERT,
"lambert_wrap": BaseMaterial3D.DIFFUSE_LAMBERT_WRAP,
"toon": BaseMaterial3D.DIFFUSE_TOON,
},
"specular_mode": {
"schlick_ggx": BaseMaterial3D.SPECULAR_SCHLICK_GGX,
"toon": BaseMaterial3D.SPECULAR_TOON,
"disabled": BaseMaterial3D.SPECULAR_DISABLED,
},
"billboard_mode": {
"disabled": BaseMaterial3D.BILLBOARD_DISABLED,
"enabled": BaseMaterial3D.BILLBOARD_ENABLED,
"fixed_y": BaseMaterial3D.BILLBOARD_FIXED_Y,
"particles": BaseMaterial3D.BILLBOARD_PARTICLES,
},
"texture_filter": {
"nearest": BaseMaterial3D.TEXTURE_FILTER_NEAREST,
"linear": BaseMaterial3D.TEXTURE_FILTER_LINEAR,
"nearest_mipmap": BaseMaterial3D.TEXTURE_FILTER_NEAREST_WITH_MIPMAPS,
"linear_mipmap": BaseMaterial3D.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS,
},
}
## Return the enum int for (property, string_name), or null if not a known enum string.
static func resolve_enum(property: String, value: Variant) -> Variant:
if not (value is String):
return null
if not _ENUM_TABLES.has(property):
return null
var table: Dictionary = _ENUM_TABLES[property]
var key: String = String(value).to_lower()
if table.has(key):
return table[key]
return null
## Parse a color from Color, "#rrggbb(aa)", named string, {r,g,b[,a]} dict,
## or [r,g,b(,a)] array. Delegates to the canonical parser (#714); returns
## null if the input cannot be parsed.
static func parse_color(value: Variant) -> Variant:
return McpJsonValues.parse_color(value)
static func parse_vector3(value: Variant) -> Variant:
return McpJsonValues.parse_vector3(value)
static func parse_vector2(value: Variant) -> Variant:
return McpJsonValues.parse_vector2(value)
## Load a Texture2D from a res:// / uid:// / user:// path (validate_loadable_path).
## Returns null on failure (including a path that fails confinement / traversal).
static func load_texture(path: String) -> Texture2D:
if not McpPathValidator.validate_loadable_path(path).is_empty():
return null
if not ResourceLoader.exists(path):
return null
var res := ResourceLoader.load(path)
if res is Texture2D:
return res
return null
## Coerce a JSON-shaped value for a material property.
## Returns a dict {ok: true, value: ...} on success, or {ok: false, error: "..."} on failure.
## For properties the coercer doesn't have special logic for, falls back to target_type.
static func coerce_material_value(property: String, value: Variant, target_type: int) -> Dictionary:
# Enum-by-name: must match before generic TYPE_INT coercion.
if _ENUM_TABLES.has(property):
if value is String:
var enum_val = resolve_enum(property, value)
if enum_val == null:
return {
"ok": false,
"error": "Invalid %s value: '%s'. Valid: %s" % [
property, value, ", ".join(_ENUM_TABLES[property].keys())
],
}
return {"ok": true, "value": int(enum_val)}
if value is int or value is float:
return {"ok": true, "value": int(value)}
match target_type:
TYPE_COLOR:
var c = parse_color(value)
if c == null:
return {"ok": false, "error": "Invalid color for %s: %s" % [property, value]}
return {"ok": true, "value": c}
TYPE_VECTOR3:
var v3 = parse_vector3(value)
if v3 == null:
return {"ok": false, "error": "Invalid vector3 for %s: %s" % [property, value]}
return {"ok": true, "value": v3}
TYPE_VECTOR2:
var v2 = parse_vector2(value)
if v2 == null:
return {"ok": false, "error": "Invalid vector2 for %s: %s" % [property, value]}
return {"ok": true, "value": v2}
TYPE_BOOL:
if value is bool:
return {"ok": true, "value": value}
if value is int or value is float:
return {"ok": true, "value": bool(value)}
return {"ok": false, "error": "Expected bool for %s" % property}
TYPE_INT:
if value is int:
return {"ok": true, "value": value}
if value is float:
return {"ok": true, "value": int(value)}
return {"ok": false, "error": "Expected int for %s" % property}
TYPE_FLOAT:
if value is float:
return {"ok": true, "value": value}
if value is int:
return {"ok": true, "value": float(value)}
return {"ok": false, "error": "Expected number for %s" % property}
TYPE_OBJECT:
if value == null:
return {"ok": true, "value": null}
if value is Object:
return {"ok": true, "value": value}
if value is String:
var tex := load_texture(value)
if tex == null:
return {"ok": false, "error": "Resource not found or wrong type: %s" % value}
return {"ok": true, "value": tex}
return {"ok": false, "error": "Expected resource path (string) for %s" % property}
TYPE_STRING:
return {"ok": true, "value": String(value)}
# Unknown target type — pass through.
return {"ok": true, "value": value}
## Serialize a Variant into JSON-friendly shape for responses.
static func serialize_value(value: Variant) -> Variant:
if value == null:
return null
if value is Color:
return {"r": value.r, "g": value.g, "b": value.b, "a": value.a}
if value is Vector3:
return {"x": value.x, "y": value.y, "z": value.z}
if value is Vector2:
return {"x": value.x, "y": value.y}
if value is Resource:
var path := (value as Resource).resource_path
if path.is_empty():
return {"type": value.get_class(), "path": ""}
return {"type": value.get_class(), "path": path}
return value
@@ -0,0 +1 @@
uid://daqgjkflia8nk
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
uid://qhhd5mm5awym
@@ -0,0 +1,860 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles particle emitter authoring (GPU + CPU, 2D + 3D).
##
## All write operations bundle node creation and sub-resource spawns
## (ParticleProcessMaterial, default QuadMesh) in a single create_action
## so Ctrl-Z rolls back the whole effect atomically.
const ParticleValues := preload("res://addons/godot_ai/handlers/particle_values.gd")
const ParticlePresets := preload("res://addons/godot_ai/handlers/particle_presets.gd")
const _VALID_TYPES := {
"gpu_3d": "GPUParticles3D",
"gpu_2d": "GPUParticles2D",
"cpu_3d": "CPUParticles3D",
"cpu_2d": "CPUParticles2D",
}
const _MAIN_KEYS := [
"amount",
"lifetime",
"one_shot",
"explosiveness",
"preprocess",
"speed_scale",
"randomness",
"fixed_fps",
"emitting",
"local_coords",
"interp_to_end",
]
var _undo_redo: EditorUndoRedoManager
func _init(undo_redo: EditorUndoRedoManager) -> void:
_undo_redo = undo_redo
# ============================================================================
# particle_create
# ============================================================================
func create_particle(params: Dictionary) -> Dictionary:
var parent_path: String = params.get("parent_path", "")
var node_name: String = params.get("name", "Particles")
var type_str: String = params.get("type", "gpu_3d")
if not _VALID_TYPES.has(type_str):
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid particle type '%s'. Valid: %s" % [type_str, ", ".join(_VALID_TYPES.keys())]
)
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 parent: Node = scene_root
if not parent_path.is_empty():
parent = McpScenePath.resolve(parent_path, scene_root)
if parent == null:
return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root))
var node := _instantiate_particle(type_str)
if node == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate particle node")
if not node_name.is_empty():
node.name = node_name
var process_mat: ParticleProcessMaterial = null
var process_material_created := false
var draw_mesh: Mesh = null
var draw_material: StandardMaterial3D = null
var draw_pass_mesh_created := false
var draw_material_created := false
if type_str == "gpu_3d" or type_str == "gpu_2d":
process_mat = ParticleProcessMaterial.new()
process_material_created = true
if type_str == "gpu_3d":
draw_mesh = QuadMesh.new()
(draw_mesh as QuadMesh).size = Vector2(0.25, 0.25)
# Without a material, the mesh renders flat white — ignoring
# ParticleProcessMaterial.color_ramp entirely. Give it the standard
# billboard + vertex-color-as-albedo setup so color_ramp works.
draw_material = ParticleValues.build_draw_material({}).material
(draw_mesh as QuadMesh).material = draw_material
draw_pass_mesh_created = true
draw_material_created = true
_undo_redo.create_action("MCP: Create %s '%s'" % [_VALID_TYPES[type_str], node.name])
_undo_redo.add_do_method(parent, "add_child", node, true)
_undo_redo.add_do_method(node, "set_owner", scene_root)
if process_mat != null:
_undo_redo.add_do_property(node, "process_material", process_mat)
_undo_redo.add_do_reference(process_mat)
if draw_mesh != null:
_undo_redo.add_do_property(node, "draw_pass_1", draw_mesh)
_undo_redo.add_do_reference(draw_mesh)
if draw_material != null:
_undo_redo.add_do_reference(draw_material)
_undo_redo.add_do_reference(node)
_undo_redo.add_undo_method(parent, "remove_child", node)
_undo_redo.commit_action()
return {
"data": {
"path": McpScenePath.from_node(node, scene_root),
"parent_path": McpScenePath.from_node(parent, scene_root),
"name": String(node.name),
"type": type_str,
"class": _VALID_TYPES[type_str],
"process_material_created": process_material_created,
"draw_pass_mesh_created": draw_pass_mesh_created,
"draw_material_created": draw_material_created,
"undoable": true,
}
}
# ============================================================================
# particle_set_main
# ============================================================================
func set_main(params: Dictionary) -> Dictionary:
var resolved := _resolve_particle(params)
if resolved.has("error"):
return resolved
var node: Node = resolved.node
var node_path: String = resolved.path
var properties: Dictionary = params.get("properties", {})
if properties.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "properties dict is empty")
var coerced: Dictionary = {}
var old_values: Dictionary = {}
for property in properties:
var prop_name: String = String(property)
if not (prop_name in _MAIN_KEYS):
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Unknown main property '%s'. Valid: %s" % [prop_name, ", ".join(_MAIN_KEYS)]
)
var prop_type := _node_property_type(node, prop_name)
if prop_type == TYPE_NIL:
return ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Property '%s' not present on %s" % [prop_name, node.get_class()]
)
var coerce_result := ParticleValues.coerce(prop_name, properties[prop_name], prop_type)
if not coerce_result.ok:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerce_result.error))
coerced[prop_name] = coerce_result.value
old_values[prop_name] = node.get(prop_name)
_undo_redo.create_action("MCP: Set particle main on %s" % node.name)
for prop_name in coerced:
_undo_redo.add_do_property(node, prop_name, coerced[prop_name])
_undo_redo.add_undo_property(node, prop_name, old_values[prop_name])
_undo_redo.commit_action()
var applied: Array[String] = []
var serialized_values: Dictionary = {}
for prop_name in coerced:
applied.append(prop_name)
serialized_values[prop_name] = ParticleValues.serialize(coerced[prop_name])
return {
"data": {
"path": node_path,
"applied": applied,
"values": serialized_values,
"undoable": true,
}
}
# ============================================================================
# particle_set_process
# ============================================================================
func set_process(params: Dictionary) -> Dictionary:
var resolved := _resolve_particle(params)
if resolved.has("error"):
return resolved
var node: Node = resolved.node
var node_path: String = resolved.path
var properties: Dictionary = params.get("properties", {})
if properties.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "properties dict is empty")
# GPU: work through process_material; CPU: properties live on node directly.
if node is GPUParticles3D or node is GPUParticles2D:
return _set_process_gpu(node, node_path, properties)
return _set_process_cpu(node, node_path, properties)
func _set_process_gpu(node: Node, node_path: String, properties: Dictionary) -> Dictionary:
var existing_mat: ParticleProcessMaterial = node.process_material as ParticleProcessMaterial
var process_material_created := false
var mat: ParticleProcessMaterial = existing_mat
if mat == null:
mat = ParticleProcessMaterial.new()
process_material_created = true
var coerced: Dictionary = {}
for property in properties:
var prop_name: String = String(property)
var prop_type := _object_property_type(mat, prop_name)
if prop_type == TYPE_NIL:
return ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Property '%s' not present on ParticleProcessMaterial" % prop_name
)
var coerce_result := ParticleValues.coerce(prop_name, properties[prop_name], prop_type)
if not coerce_result.ok:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerce_result.error))
coerced[prop_name] = coerce_result.value
_undo_redo.create_action("MCP: Set particle process on %s" % node.name)
if process_material_created:
_undo_redo.add_do_property(node, "process_material", mat)
_undo_redo.add_undo_property(node, "process_material", null)
_undo_redo.add_do_reference(mat)
# Apply new values directly on the (newly created) material. No old values to restore.
for prop_name in coerced:
mat.set(prop_name, coerced[prop_name])
else:
# Use the reusable apply/restore pattern for existing material.
var old_values: Dictionary = {}
for prop_name in coerced:
old_values[prop_name] = mat.get(prop_name)
for prop_name in coerced:
_undo_redo.add_do_property(mat, prop_name, coerced[prop_name])
_undo_redo.add_undo_property(mat, prop_name, old_values[prop_name])
_undo_redo.commit_action()
var applied: Array[String] = []
var serialized: Dictionary = {}
for prop_name in coerced:
applied.append(prop_name)
serialized[prop_name] = ParticleValues.serialize(mat.get(prop_name))
return {
"data": {
"path": node_path,
"applied": applied,
"values": serialized,
"process_material_created": process_material_created,
"undoable": true,
}
}
func _set_process_cpu(node: Node, node_path: String, properties: Dictionary) -> Dictionary:
# CPU particles expose the same property vocabulary directly on the node,
# so property names pass through unchanged.
var coerced: Dictionary = {}
var old_values: Dictionary = {}
for property in properties:
var prop_name: String = String(property)
var prop_type := _node_property_type(node, prop_name)
if prop_type == TYPE_NIL:
return ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Property '%s' not present on %s" % [prop_name, node.get_class()]
)
var coerce_result := ParticleValues.coerce(prop_name, properties[property], prop_type)
if not coerce_result.ok:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerce_result.error))
coerced[prop_name] = coerce_result.value
old_values[prop_name] = node.get(prop_name)
_undo_redo.create_action("MCP: Set particle process on %s" % node.name)
for prop_name in coerced:
_undo_redo.add_do_property(node, prop_name, coerced[prop_name])
_undo_redo.add_undo_property(node, prop_name, old_values[prop_name])
_undo_redo.commit_action()
var applied: Array[String] = []
var serialized: Dictionary = {}
for prop_name in coerced:
applied.append(prop_name)
serialized[prop_name] = ParticleValues.serialize(coerced[prop_name])
return {
"data": {
"path": node_path,
"applied": applied,
"values": serialized,
"process_material_created": false,
"undoable": true,
}
}
# ============================================================================
# particle_set_draw_pass
# ============================================================================
func set_draw_pass(params: Dictionary) -> Dictionary:
var resolved := _resolve_particle(params)
if resolved.has("error"):
return resolved
var node: Node = resolved.node
var node_path: String = resolved.path
var pass_idx: int = int(params.get("pass", 1))
var mesh_path: String = params.get("mesh", "")
var texture_path: String = params.get("texture", "")
var material_path: String = params.get("material", "")
if node is GPUParticles3D:
return _set_draw_pass_gpu_3d(node, node_path, pass_idx, mesh_path, material_path)
if node is CPUParticles3D:
return _set_draw_pass_cpu_3d(node, node_path, mesh_path, material_path)
if node is GPUParticles2D or node is CPUParticles2D:
return _set_draw_pass_2d(node, node_path, texture_path)
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Node %s is not a particle node" % node.get_class())
func _set_draw_pass_gpu_3d(node: GPUParticles3D, node_path: String, pass_idx: int, mesh_path: String, material_path: String) -> Dictionary:
if pass_idx < 1 or pass_idx > 4:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "pass must be 1..4 (got %d)" % pass_idx)
var mesh: Mesh = null
var mesh_created := false
var property_name := "draw_pass_%d" % pass_idx
# draw_pass_N is only a live property when draw_passes >= N. Probe via
# get_property_list so we don't read a ghost value.
var existing_mesh: Mesh = null
if int(node.draw_passes) >= pass_idx:
existing_mesh = node.get(property_name) as Mesh
if not mesh_path.is_empty():
var mesh_path_err = McpPathValidator.loadable_error(mesh_path, "mesh_path")
if mesh_path_err != null:
return mesh_path_err
if not ResourceLoader.exists(mesh_path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Mesh not found: %s" % mesh_path)
var loaded := ResourceLoader.load(mesh_path)
if not (loaded is Mesh):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Mesh" % mesh_path)
mesh = loaded
else:
if existing_mesh == null:
mesh = QuadMesh.new()
(mesh as QuadMesh).size = Vector2(0.25, 0.25)
mesh_created = true
else:
mesh = existing_mesh
var material: Material = null
if not material_path.is_empty():
var material_path_err = McpPathValidator.loadable_error(material_path, "material_path")
if material_path_err != null:
return material_path_err
if not ResourceLoader.exists(material_path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Material not found: %s" % material_path)
var loaded_mat := ResourceLoader.load(material_path)
if not (loaded_mat is Material):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Material" % material_path)
material = loaded_mat
var old_draw_passes: int = int(node.draw_passes)
var new_draw_passes: int = max(old_draw_passes, pass_idx)
var old_value = existing_mesh # Null if draw_passes < pass_idx
var old_material: Material = null
if material != null:
old_material = node.material_override
_undo_redo.create_action("MCP: Set %s.draw_pass_%d" % [node.name, pass_idx])
# Grow draw_passes first so draw_pass_N property exists before we set it.
if new_draw_passes != old_draw_passes:
_undo_redo.add_do_property(node, "draw_passes", new_draw_passes)
_undo_redo.add_undo_property(node, "draw_passes", old_draw_passes)
if not mesh_path.is_empty() or mesh_created:
_undo_redo.add_do_property(node, property_name, mesh)
_undo_redo.add_undo_property(node, property_name, old_value)
if mesh_created:
_undo_redo.add_do_reference(mesh)
if material != null:
_undo_redo.add_do_property(node, "material_override", material)
_undo_redo.add_undo_property(node, "material_override", old_material)
_undo_redo.commit_action()
return {
"data": {
"path": node_path,
"pass": pass_idx,
"mesh_path": mesh_path,
"mesh_class": mesh.get_class() if mesh else "",
"material_path": material_path,
"draw_pass_mesh_created": mesh_created,
"draw_passes_grown": new_draw_passes != old_draw_passes,
"undoable": true,
}
}
func _set_draw_pass_cpu_3d(node: CPUParticles3D, node_path: String, mesh_path: String, material_path: String) -> Dictionary:
if mesh_path.is_empty() and material_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "CPUParticles3D requires mesh or material param")
var mesh: Mesh = node.mesh
var old_mesh: Mesh = mesh
if not mesh_path.is_empty():
var mesh_path_err = McpPathValidator.loadable_error(mesh_path, "mesh_path")
if mesh_path_err != null:
return mesh_path_err
if not ResourceLoader.exists(mesh_path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Mesh not found: %s" % mesh_path)
var loaded := ResourceLoader.load(mesh_path)
if not (loaded is Mesh):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Mesh" % mesh_path)
mesh = loaded
var material: Material = null
var old_material: Material = node.material_override
if not material_path.is_empty():
var material_path_err = McpPathValidator.loadable_error(material_path, "material_path")
if material_path_err != null:
return material_path_err
if not ResourceLoader.exists(material_path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Material not found: %s" % material_path)
var loaded_mat := ResourceLoader.load(material_path)
if not (loaded_mat is Material):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Material" % material_path)
material = loaded_mat
_undo_redo.create_action("MCP: Set CPU particle draw on %s" % node.name)
if not mesh_path.is_empty():
_undo_redo.add_do_property(node, "mesh", mesh)
_undo_redo.add_undo_property(node, "mesh", old_mesh)
if material != null:
_undo_redo.add_do_property(node, "material_override", material)
_undo_redo.add_undo_property(node, "material_override", old_material)
_undo_redo.commit_action()
return {
"data": {
"path": node_path,
"mesh_path": mesh_path,
"material_path": material_path,
"draw_pass_mesh_created": false,
"undoable": true,
}
}
func _set_draw_pass_2d(node: Node, node_path: String, texture_path: String) -> Dictionary:
if texture_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "2D particles require texture param")
var texture_path_err = McpPathValidator.loadable_error(texture_path, "texture_path")
if texture_path_err != null:
return texture_path_err
if not ResourceLoader.exists(texture_path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Texture not found: %s" % texture_path)
var tex := ResourceLoader.load(texture_path)
if not (tex is Texture2D):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Texture2D" % texture_path)
var old_texture: Texture2D = node.get("texture")
_undo_redo.create_action("MCP: Set 2D particle texture on %s" % node.name)
_undo_redo.add_do_property(node, "texture", tex)
_undo_redo.add_undo_property(node, "texture", old_texture)
_undo_redo.commit_action()
return {
"data": {
"path": node_path,
"texture_path": texture_path,
"undoable": true,
}
}
# ============================================================================
# particle_restart
# ============================================================================
func restart_particle(params: Dictionary) -> Dictionary:
var resolved := _resolve_particle(params)
if resolved.has("error"):
return resolved
var node: Node = resolved.node
var node_path: String = resolved.path
if node.has_method("restart"):
node.restart()
return {
"data": {
"path": node_path,
"undoable": false,
"reason": "Restart is a runtime operation, not tracked in undo history",
}
}
# ============================================================================
# particle_get
# ============================================================================
func get_particle(params: Dictionary) -> Dictionary:
var resolved := _resolve_particle(params)
if resolved.has("error"):
return resolved
var node: Node = resolved.node
var node_path: String = resolved.path
var type_str := ""
for key in _VALID_TYPES:
if node.get_class() == _VALID_TYPES[key]:
type_str = key
break
var main_values: Dictionary = {}
var node_prop_names := _property_names(node)
for key in _MAIN_KEYS:
if node_prop_names.has(key):
main_values[key] = ParticleValues.serialize(node.get(key))
var process_data: Dictionary = {}
if node is GPUParticles3D or node is GPUParticles2D:
var mat: ParticleProcessMaterial = node.process_material as ParticleProcessMaterial
if mat != null:
var process_props: Dictionary = {}
for prop in mat.get_property_list():
var usage: int = prop.get("usage", 0)
if not (usage & PROPERTY_USAGE_EDITOR):
continue
var v = mat.get(prop.name)
if v == null:
continue
process_props[prop.name] = ParticleValues.serialize(v)
process_data = {
"class": "ParticleProcessMaterial",
"properties": process_props,
}
var draw_passes: Array[Dictionary] = []
if node is GPUParticles3D:
var active_draw_pass_count: int = min(int(node.draw_passes), 4)
for i in range(1, active_draw_pass_count + 1):
var prop_name := "draw_pass_%d" % i
var m: Mesh = node.get(prop_name) as Mesh
draw_passes.append({
"pass": i,
"mesh_class": m.get_class() if m != null else "",
})
var texture_path := ""
if node is GPUParticles2D or node is CPUParticles2D:
var t: Texture2D = node.get("texture")
if t != null:
texture_path = t.resource_path
return {
"data": {
"path": node_path,
"type": type_str,
"class": node.get_class(),
"main": main_values,
"process": process_data,
"draw_passes": draw_passes,
"texture_path": texture_path,
}
}
# ============================================================================
# particle_apply_preset
# ============================================================================
func apply_preset(params: Dictionary) -> Dictionary:
var preset_name: String = params.get("preset", "")
if preset_name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: preset")
var overrides: Dictionary = params.get("overrides", {})
var blueprint = ParticlePresets.build(preset_name, overrides)
if blueprint == null:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Unknown preset '%s'. Valid: %s" % [preset_name, ", ".join(ParticlePresets.list())]
)
var parent_path: String = params.get("parent_path", "")
var node_name: String = params.get("name", "")
var type_str: String = params.get("type", "gpu_3d")
if node_name.is_empty():
node_name = preset_name.capitalize()
if not _VALID_TYPES.has(type_str):
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid particle type '%s'. Valid: %s" % [type_str, ", ".join(_VALID_TYPES.keys())]
)
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 parent: Node = scene_root
if not parent_path.is_empty():
parent = McpScenePath.resolve(parent_path, scene_root)
if parent == null:
return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root))
var node := _instantiate_particle(type_str)
node.name = node_name
var is_gpu := type_str == "gpu_3d" or type_str == "gpu_2d"
var is_3d := type_str == "gpu_3d" or type_str == "cpu_3d"
var process_mat: ParticleProcessMaterial = null
var process_material_created := false
if is_gpu:
process_mat = ParticleProcessMaterial.new()
process_material_created = true
# User-supplied override keys per group. Preset-blueprint keys may skip
# silently on types they don't apply to (presets are cross-type by
# design); user-requested overrides must apply or error (#770).
var user_keys: Dictionary = blueprint.get("user_keys", {})
var user_main: Dictionary = user_keys.get("main", {})
var user_process: Dictionary = user_keys.get("process", {})
var user_draw: Dictionary = user_keys.get("draw", {})
var draw_mesh: Mesh = null
var draw_material: StandardMaterial3D = null
var draw_pass_mesh_created := false
var draw_material_created := false
var applied_draw: Array[String] = []
var draw_config: Dictionary = blueprint.get("draw", {})
if type_str == "gpu_3d":
var draw_result := ParticleValues.build_draw_material(draw_config)
if not draw_result.ok:
node.free()
var msg := String(draw_result.error)
if draw_result.get("unknown_key", false):
msg = _draw_key_unsupported_message(String(draw_result.key), type_str)
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, msg)
draw_mesh = QuadMesh.new()
(draw_mesh as QuadMesh).size = Vector2(0.25, 0.25)
draw_material = draw_result.material
(draw_mesh as QuadMesh).material = draw_material
draw_pass_mesh_created = true
draw_material_created = true
for applied_key in draw_result.applied:
applied_draw.append(String(applied_key))
elif type_str == "gpu_2d":
# GPUParticles2D has no draw-pass material; the one draw override it
# supports is draw.texture → the node's texture property.
for key in user_draw:
if String(key) != "texture":
node.free()
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
_draw_key_unsupported_message(String(key), type_str)
)
if user_draw.has("texture"):
var tex_result := _load_draw_texture(draw_config.get("texture"))
if tex_result.has("error"):
node.free()
return tex_result
node.set("texture", tex_result.texture)
applied_draw.append("texture")
else:
# cpu_3d / cpu_2d: no draw support — reject user draw overrides
# instead of dropping them.
for key in user_draw:
node.free()
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
_draw_key_unsupported_message(String(key), type_str)
)
# Pre-apply preset values to in-memory targets (no undo needed; nodes not in tree yet).
var main_values: Dictionary = blueprint.get("main", {})
var process_values: Dictionary = blueprint.get("process", {})
var applied_main: Array[String] = []
var applied_process: Array[String] = []
for prop in main_values:
var prop_name := String(prop)
var prop_type := _object_property_type(node, prop_name)
if prop_type == TYPE_NIL:
if user_main.has(prop_name):
var node_class := node.get_class()
node.free()
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Override main.%s does not apply to type '%s' (no such property on %s)" % [
prop_name, type_str, node_class
]
)
continue # Blueprint key: not all main keys apply to all types.
var coerce_result := ParticleValues.coerce(prop_name, main_values[prop_name], prop_type)
if not coerce_result.ok:
node.free()
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerce_result.error))
node.set(prop_name, coerce_result.value)
applied_main.append(prop_name)
# Apply process: GPU targets the ParticleProcessMaterial; CPU targets the node.
var process_target: Object = process_mat if is_gpu else node
for prop in process_values:
var prop_name := String(prop)
var prop_type := _object_property_type(process_target, prop_name)
if prop_type == TYPE_NIL:
if user_process.has(prop_name):
var target_class := process_target.get_class()
node.free()
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Override process.%s does not apply to type '%s' (no such property on %s)" % [
prop_name, type_str, target_class
]
)
continue # Blueprint key: preset property doesn't apply to this variant.
var coerce_result := ParticleValues.coerce(prop_name, process_values[prop_name], prop_type)
if not coerce_result.ok:
node.free()
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerce_result.error))
process_target.set(prop_name, coerce_result.value)
applied_process.append(prop_name)
_undo_redo.create_action("MCP: Apply preset %s" % preset_name)
_undo_redo.add_do_method(parent, "add_child", node, true)
_undo_redo.add_do_method(node, "set_owner", scene_root)
_undo_redo.add_do_reference(node)
if process_mat != null:
_undo_redo.add_do_property(node, "process_material", process_mat)
_undo_redo.add_do_reference(process_mat)
if draw_mesh != null:
_undo_redo.add_do_property(node, "draw_pass_1", draw_mesh)
_undo_redo.add_do_reference(draw_mesh)
if draw_material != null:
_undo_redo.add_do_reference(draw_material)
_undo_redo.add_undo_method(parent, "remove_child", node)
_undo_redo.commit_action()
return {
"data": {
"path": McpScenePath.from_node(node, scene_root),
"parent_path": McpScenePath.from_node(parent, scene_root),
"name": node_name,
"preset": preset_name,
"type": type_str,
"class": _VALID_TYPES[type_str],
"applied_main": applied_main,
"applied_process": applied_process,
"applied_draw": applied_draw,
"process_material_created": process_material_created,
"draw_pass_mesh_created": draw_pass_mesh_created,
"draw_material_created": draw_material_created,
"is_3d": is_3d,
"undoable": true,
}
}
# ============================================================================
# Helpers
# ============================================================================
## Actionable rejection for a user draw override that can't apply to the
## selected particle type: name the key and where it IS supported.
static func _draw_key_unsupported_message(key: String, type_str: String) -> String:
if key == "texture":
return "draw.texture is only supported for gpu_2d (got type '%s')" % type_str
var probe := StandardMaterial3D.new()
if _object_property_type(probe, key) != TYPE_NIL:
return "draw.%s is only supported for gpu_3d (got type '%s')" % [key, type_str]
return (
"Unknown draw key '%s' (draw overrides configure the gpu_3d "
+ "draw-pass StandardMaterial3D; gpu_2d supports only draw.texture)"
) % key
## Load a Texture2D for the gpu_2d draw.texture override. Returns
## {texture: Texture2D} or an error dict (same validation as set_draw_pass).
static func _load_draw_texture(value: Variant) -> Dictionary:
if not (value is String) or String(value).is_empty():
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"draw.texture must be a non-empty res:// path string (got %s)" % type_string(typeof(value))
)
var texture_path := String(value)
var texture_path_err = McpPathValidator.loadable_error(texture_path, "draw.texture")
if texture_path_err != null:
return texture_path_err
if not ResourceLoader.exists(texture_path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Texture not found: %s" % texture_path)
var tex := ResourceLoader.load(texture_path)
if not (tex is Texture2D):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Texture2D" % texture_path)
return {"texture": tex}
static func _instantiate_particle(type_str: String) -> Node:
match type_str:
"gpu_3d":
return GPUParticles3D.new()
"gpu_2d":
return GPUParticles2D.new()
"cpu_3d":
return CPUParticles3D.new()
"cpu_2d":
return CPUParticles2D.new()
return null
func _resolve_particle(params: Dictionary) -> Dictionary:
var resolved := McpNodeValidator.resolve_or_error(
params.get("node_path", ""), "node_path",
)
if resolved.has("error"):
return resolved
var node: Node = resolved.node
var node_path: String = resolved.path
var is_particle := node is GPUParticles3D or node is GPUParticles2D \
or node is CPUParticles3D or node is CPUParticles2D
if not is_particle:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Node %s is not a particle node (got %s)" % [node_path, node.get_class()]
)
return {"node": node, "path": node_path}
static func _node_property_type(node: Object, name: String) -> int:
return _object_property_type(node, name)
static func _object_property_type(obj: Object, name: String) -> int:
if obj == null:
return TYPE_NIL
for prop in obj.get_property_list():
if prop.name == name:
return int(prop.get("type", TYPE_NIL))
return TYPE_NIL
static func _property_names(obj: Object) -> Dictionary:
var out: Dictionary = {}
if obj == null:
return out
for prop in obj.get_property_list():
out[prop.name] = true
return out
@@ -0,0 +1 @@
uid://byfc0pnyb5qww
@@ -0,0 +1,293 @@
@tool
extends RefCounted
## Curated particle effect blueprints.
##
## Each preset returns {main, process, draw}. The handler applies them
## through the normal write path (one undo action wraps all spawns).
## Each preset has {main, process, draw}. `draw` configures the StandardMaterial3D
## attached to the auto-created QuadMesh in draw_pass_1 (GPU 3D only); if
## omitted, the handler falls back to a sensible billboard-particles default.
## `blend_mode: "add"` is what makes fire/magic/explosion glow — without
## additive blending, additively-layered particles just stack to gray.
const _PRESETS := {
"fire": {
"main": {
"amount": 80,
"lifetime": 1.2,
"one_shot": false,
"explosiveness": 0.0,
"preprocess": 0.5,
"local_coords": false,
},
"process": {
"emission_shape": "sphere",
"emission_sphere_radius": 0.3,
"direction": {"x": 0.0, "y": 1.0, "z": 0.0},
"spread": 15.0,
"initial_velocity_min": 2.0,
"initial_velocity_max": 4.0,
"gravity": {"x": 0.0, "y": 1.0, "z": 0.0}, # buoyancy
"scale_min": 0.4,
"scale_max": 0.8,
"color_ramp": {
"stops": [
{"time": 0.0, "color": [1.0, 1.0, 0.9, 1.0]},
{"time": 0.3, "color": [1.0, 0.6, 0.1, 1.0]},
{"time": 0.7, "color": [0.8, 0.1, 0.05, 0.7]},
{"time": 1.0, "color": [0.2, 0.05, 0.05, 0.0]},
]
},
},
"draw": {"blend_mode": "add"},
},
"smoke": {
"main": {
"amount": 40,
"lifetime": 3.0,
"one_shot": false,
"explosiveness": 0.0,
"local_coords": false,
},
"process": {
"emission_shape": "sphere",
"emission_sphere_radius": 0.4,
"direction": {"x": 0.0, "y": 1.0, "z": 0.0},
"spread": 20.0,
"initial_velocity_min": 0.5,
"initial_velocity_max": 1.5,
"gravity": {"x": 0.0, "y": 0.2, "z": 0.0},
"scale_min": 0.6,
"scale_max": 1.4,
"color_ramp": {
"stops": [
{"time": 0.0, "color": [0.3, 0.3, 0.3, 0.0]},
{"time": 0.25, "color": [0.35, 0.35, 0.35, 0.7]},
{"time": 0.75, "color": [0.2, 0.2, 0.2, 0.5]},
{"time": 1.0, "color": [0.1, 0.1, 0.1, 0.0]},
]
},
},
# Smoke uses regular alpha blending so it darkens the background.
"draw": {"blend_mode": "mix"},
},
"spark_burst": {
"main": {
"amount": 60,
"lifetime": 0.8,
"one_shot": true,
"explosiveness": 1.0,
"local_coords": false,
},
"process": {
"emission_shape": "point",
"direction": {"x": 0.0, "y": 1.0, "z": 0.0},
"spread": 180.0,
"initial_velocity_min": 5.0,
"initial_velocity_max": 12.0,
"gravity": {"x": 0.0, "y": -9.8, "z": 0.0},
"scale_min": 0.05,
"scale_max": 0.12,
"color": {"r": 1.0, "g": 0.9, "b": 0.2, "a": 1.0},
},
"draw": {
"blend_mode": "add",
"emission_enabled": true,
"emission": {"r": 1.0, "g": 0.8, "b": 0.2, "a": 1.0},
"emission_energy_multiplier": 2.0,
},
},
"magic_swirl": {
"main": {
"amount": 120,
"lifetime": 2.0,
"one_shot": false,
"explosiveness": 0.0,
"local_coords": false,
},
"process": {
"emission_shape": "ring",
"emission_ring_radius": 0.8,
"emission_ring_inner_radius": 0.6,
"emission_ring_height": 0.0,
"direction": {"x": 0.0, "y": 1.0, "z": 0.0},
"spread": 30.0,
"initial_velocity_min": 1.0,
"initial_velocity_max": 2.0,
"gravity": {"x": 0.0, "y": 0.0, "z": 0.0},
"angular_velocity_min": 90.0,
"angular_velocity_max": 180.0,
"scale_min": 0.1,
"scale_max": 0.2,
"color_ramp": {
"stops": [
{"time": 0.0, "color": [0.4, 0.9, 1.0, 0.0]},
{"time": 0.3, "color": [0.5, 0.7, 1.0, 1.0]},
{"time": 0.7, "color": [1.0, 0.4, 0.9, 1.0]},
{"time": 1.0, "color": [0.8, 0.2, 0.7, 0.0]},
]
},
},
"draw": {"blend_mode": "add"},
},
"rain": {
"main": {
"amount": 500,
"lifetime": 1.5,
"one_shot": false,
"explosiveness": 0.0,
"local_coords": false,
},
"process": {
"emission_shape": "box",
"emission_box_extents": {"x": 10.0, "y": 0.1, "z": 10.0},
"direction": {"x": 0.0, "y": -1.0, "z": 0.0},
"spread": 2.0,
"initial_velocity_min": 15.0,
"initial_velocity_max": 18.0,
"gravity": {"x": 0.0, "y": -2.0, "z": 0.0},
"scale_min": 0.02,
"scale_max": 0.04,
"color": {"r": 0.7, "g": 0.85, "b": 1.0, "a": 0.5},
},
# Rain drops render as streaks; fixed_y aligns them vertically.
"draw": {"billboard_mode": "fixed_y", "blend_mode": "mix"},
},
"explosion": {
"main": {
"amount": 200,
"lifetime": 1.5,
"one_shot": true,
"explosiveness": 1.0,
"local_coords": false,
},
"process": {
"emission_shape": "sphere",
"emission_sphere_radius": 0.1,
"direction": {"x": 0.0, "y": 1.0, "z": 0.0},
"spread": 180.0,
"initial_velocity_min": 6.0,
"initial_velocity_max": 10.0,
"gravity": {"x": 0.0, "y": -4.0, "z": 0.0},
"scale_min": 0.3,
"scale_max": 0.7,
"color_ramp": {
"stops": [
{"time": 0.0, "color": [1.0, 0.95, 0.5, 1.0]},
{"time": 0.2, "color": [1.0, 0.4, 0.1, 1.0]},
{"time": 0.7, "color": [0.3, 0.15, 0.1, 0.7]},
{"time": 1.0, "color": [0.1, 0.1, 0.1, 0.0]},
]
},
},
"draw": {
"blend_mode": "add",
"emission_enabled": true,
"emission": {"r": 1.0, "g": 0.5, "b": 0.1, "a": 1.0},
"emission_energy_multiplier": 1.5,
},
},
"lightning": {
# Short, bright, electric-blue spark burst. One-shot — call
# particle_restart to re-trigger. Pairs well with a scene-wide flash.
"main": {
"amount": 40,
"lifetime": 0.35,
"one_shot": true,
"explosiveness": 1.0,
"local_coords": false,
},
"process": {
"emission_shape": "box",
"emission_box_extents": {"x": 0.1, "y": 1.5, "z": 0.1},
"direction": {"x": 0.0, "y": -1.0, "z": 0.0},
"spread": 8.0,
"initial_velocity_min": 18.0,
"initial_velocity_max": 28.0,
"gravity": {"x": 0.0, "y": 0.0, "z": 0.0},
"scale_min": 0.08,
"scale_max": 0.18,
"color_ramp": {
"stops": [
{"time": 0.0, "color": [1.0, 1.0, 1.0, 1.0]},
{"time": 0.2, "color": [0.6, 0.85, 1.0, 1.0]},
{"time": 0.6, "color": [0.3, 0.5, 1.0, 0.9]},
{"time": 1.0, "color": [0.1, 0.2, 0.7, 0.0]},
]
},
},
"draw": {
"blend_mode": "add",
"emission_enabled": true,
"emission": {"r": 0.5, "g": 0.8, "b": 1.0, "a": 1.0},
"emission_energy_multiplier": 4.0,
},
},
}
static func list() -> Array:
return _PRESETS.keys()
static func has(preset_name: String) -> bool:
return _PRESETS.has(preset_name)
## Return deep-copied {main, process, draw} blueprint with overrides merged in,
## plus "user_keys" ({main/process/draw: {key: true}}) recording which keys the
## caller supplied. The handler needs that distinction: preset-blueprint keys
## may skip silently on types they don't apply to (presets are cross-type by
## design), but user-requested overrides must apply or error (#770).
## Overrides may include top-level "main" / "process" / "draw" dicts, or bare
## keys routed to main (_MAIN_KEYS) or process — draw keys must be nested.
static func build(preset_name: String, overrides: Dictionary) -> Variant:
if not _PRESETS.has(preset_name):
return null
var entry: Dictionary = _PRESETS[preset_name].duplicate(true)
var main: Dictionary = entry.get("main", {})
var process: Dictionary = entry.get("process", {})
var draw: Dictionary = entry.get("draw", {})
var user_keys := {"main": {}, "process": {}, "draw": {}}
for key in overrides:
var val = overrides[key]
if key == "main" and val is Dictionary:
for k in val:
main[k] = val[k]
user_keys.main[String(k)] = true
elif key == "process" and val is Dictionary:
for k in val:
process[k] = val[k]
user_keys.process[String(k)] = true
elif key == "draw" and val is Dictionary:
for k in val:
draw[k] = val[k]
user_keys.draw[String(k)] = true
elif _MAIN_KEYS.has(key):
main[key] = val
user_keys.main[String(key)] = true
else:
process[key] = val
user_keys.process[String(key)] = true
entry["main"] = main
entry["process"] = process
entry["draw"] = draw
entry["user_keys"] = user_keys
return entry
const _MAIN_KEYS := {
"amount": true,
"lifetime": true,
"one_shot": true,
"explosiveness": true,
"preprocess": true,
"speed_scale": true,
"randomness": true,
"fixed_fps": true,
"emitting": true,
"local_coords": true,
"interp_to_end": true,
}
@@ -0,0 +1 @@
uid://bss2ccpmsxo4p
+246
View File
@@ -0,0 +1,246 @@
@tool
extends RefCounted
## Value coercion + gradient/curve builders for particle properties.
const MaterialValues := preload("res://addons/godot_ai/handlers/material_values.gd")
const _EMISSION_SHAPES := {
"point": ParticleProcessMaterial.EMISSION_SHAPE_POINT,
"sphere": ParticleProcessMaterial.EMISSION_SHAPE_SPHERE,
"sphere_surface": ParticleProcessMaterial.EMISSION_SHAPE_SPHERE_SURFACE,
"box": ParticleProcessMaterial.EMISSION_SHAPE_BOX,
"points": ParticleProcessMaterial.EMISSION_SHAPE_POINTS,
"directed_points": ParticleProcessMaterial.EMISSION_SHAPE_DIRECTED_POINTS,
"ring": ParticleProcessMaterial.EMISSION_SHAPE_RING,
}
## Resolve a shape name to the int enum, or return null.
static func resolve_emission_shape(value: Variant) -> Variant:
if value is int:
return value
if value is float:
return int(value)
if value is String:
var key := String(value).to_lower()
if _EMISSION_SHAPES.has(key):
return _EMISSION_SHAPES[key]
return null
static func emission_shape_names() -> Array:
return _EMISSION_SHAPES.keys()
## Build a Gradient from {stops: [{time, color}]} dict.
static func build_gradient(value: Variant) -> Variant:
if value is Gradient:
return value
if value is GradientTexture1D:
return (value as GradientTexture1D).gradient
if not (value is Dictionary):
return null
var d: Dictionary = value
if not d.has("stops"):
return null
var stops_array = d.get("stops")
if not (stops_array is Array):
return null
var offsets := PackedFloat32Array()
var colors := PackedColorArray()
for stop in stops_array:
if not (stop is Dictionary):
return null
offsets.append(float(stop.get("time", 0.0)))
var c = MaterialValues.parse_color(stop.get("color"))
if c == null:
return null
colors.append(c)
var grad := Gradient.new()
grad.offsets = offsets
grad.colors = colors
return grad
## Build a GradientTexture1D wrapping a Gradient (what ParticleProcessMaterial.color_ramp wants).
static func build_gradient_texture(value: Variant) -> Variant:
if value is GradientTexture1D:
return value
var grad = build_gradient(value)
if grad == null:
return null
var tex := GradientTexture1D.new()
tex.gradient = grad
return tex
## Build a Curve from [{time, value}] or {points: [...]} (float-over-time).
static func build_curve(value: Variant) -> Variant:
if value is Curve:
return value
if value is CurveTexture:
return (value as CurveTexture).curve
var points_array: Variant = null
if value is Array:
points_array = value
elif value is Dictionary and value.has("points"):
points_array = value["points"]
if not (points_array is Array):
return null
var curve := Curve.new()
for pt in points_array:
if not (pt is Dictionary):
return null
var t := float(pt.get("time", 0.0))
var v := float(pt.get("value", 0.0))
curve.add_point(Vector2(t, v))
return curve
static func build_curve_texture(value: Variant) -> Variant:
if value is CurveTexture:
return value
var curve = build_curve(value)
if curve == null:
return null
var tex := CurveTexture.new()
tex.curve = curve
return tex
## Coerce a particle property value to the appropriate type.
## Handles: Vector3/gravity/direction, Color, float, int, bool, enum strings.
## For color_ramp returns a GradientTexture1D; for *_curve returns CurveTexture.
static func coerce(property: String, value: Variant, target_type: int) -> Dictionary:
# Special-cased properties.
if property == "emission_shape":
var shape = resolve_emission_shape(value)
if shape == null:
return {
"ok": false,
"error": "Invalid emission_shape '%s'. Valid: %s" % [
value, ", ".join(emission_shape_names())
],
}
return {"ok": true, "value": int(shape)}
if property == "color_ramp" or property == "color_initial_ramp":
var tex = build_gradient_texture(value)
if tex == null:
return {"ok": false, "error": "Invalid gradient for %s (expected {stops: [{time, color}]})" % property}
return {"ok": true, "value": tex}
if property == "color" and value is Dictionary and not (value as Dictionary).has("stops"):
# color is a single Color, not a ramp.
var c = MaterialValues.parse_color(value)
if c == null:
return {"ok": false, "error": "Invalid color"}
return {"ok": true, "value": c}
if property.ends_with("_curve"):
var tex = build_curve_texture(value)
if tex == null:
return {"ok": false, "error": "Invalid curve for %s (expected [{time, value}])" % property}
return {"ok": true, "value": tex}
# Fall through to the material coercer (handles Color/Vec3/Vec2/float/int/bool/enum).
return MaterialValues.coerce_material_value(property, value, target_type)
## Build a StandardMaterial3D suitable for GPUParticles3D draw-pass rendering.
##
## Godot's default Mesh has no material, which means ParticleProcessMaterial's
## color_ramp (which drives the COLOR varying) gets ignored and particles
## render as flat white squares that don't face the camera. A correct default
## must have vertex_color_use_as_albedo=true, billboard=particles, unshaded,
## and alpha transparency so the gradient actually modulates the pixels.
##
## Config is an optional dict that overrides individual properties. Supported
## keys match BaseMaterial3D properties (plus enum-by-name via MaterialValues):
## blend_mode: "mix" | "add" | "sub" | "mul"
## transparency: "disabled" | "alpha" | "alpha_scissor" | "alpha_hash" | "alpha_depth_pre_pass"
## shading_mode: "unshaded" | "per_pixel" | "per_vertex"
## billboard_mode: "disabled" | "enabled" | "fixed_y" | "particles"
## vertex_color_use_as_albedo: bool
## emission_enabled: bool
## emission: Color
## emission_energy_multiplier: float
## albedo_color: Color
## albedo_texture: res:// path
## (anything else accepted by BaseMaterial3D.set())
##
## Returns {ok: true, material: StandardMaterial3D, applied: Array[String]},
## or {ok: false, key, error, unknown_key} when a config key is not a
## StandardMaterial3D property or its value fails coercion — draw config must
## not disappear silently (#770).
static func build_draw_material(config: Dictionary) -> Dictionary:
var mat := StandardMaterial3D.new()
# Sensible defaults for particle draw-pass rendering.
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.vertex_color_use_as_albedo = true
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.billboard_mode = BaseMaterial3D.BILLBOARD_PARTICLES
mat.billboard_keep_scale = true
# Configure from dict overrides.
var applied: Array[String] = []
for key in config:
var prop_name := String(key)
var prop_type := _object_property_type(mat, prop_name)
if prop_type == TYPE_NIL:
return {
"ok": false,
"key": prop_name,
"unknown_key": true,
"error": "Unknown draw key '%s' (not a StandardMaterial3D property)" % prop_name,
}
var coerce_result := MaterialValues.coerce_material_value(
prop_name, config[prop_name], prop_type
)
if not coerce_result.ok:
return {
"ok": false,
"key": prop_name,
"unknown_key": false,
"error": "draw.%s: %s" % [prop_name, coerce_result.error],
}
mat.set(prop_name, coerce_result.value)
applied.append(prop_name)
return {"ok": true, "material": mat, "applied": applied}
static func _object_property_type(obj: Object, name: String) -> int:
if obj == null:
return TYPE_NIL
for prop in obj.get_property_list():
if prop.name == name:
return int(prop.get("type", TYPE_NIL))
return TYPE_NIL
## Serialize for response.
static func serialize(value: Variant) -> Variant:
if value == null:
return null
if value is GradientTexture1D:
var grad := (value as GradientTexture1D).gradient
if grad == null:
return {"type": "GradientTexture1D", "stops": []}
var stops: Array = []
for i in grad.offsets.size():
var c: Color = grad.colors[i]
stops.append({
"time": grad.offsets[i],
"color": {"r": c.r, "g": c.g, "b": c.b, "a": c.a},
})
return {"type": "GradientTexture1D", "stops": stops}
if value is CurveTexture:
var curve := (value as CurveTexture).curve
if curve == null:
return {"type": "CurveTexture", "points": []}
var points: Array = []
for i in curve.get_point_count():
var p := curve.get_point_position(i)
points.append({"time": p.x, "value": p.y})
return {"type": "CurveTexture", "points": points}
return MaterialValues.serialize_value(value)
@@ -0,0 +1 @@
uid://bnnnjq06dmclc
@@ -0,0 +1,338 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Sizes a CollisionShape2D/CollisionShape3D to match a visual sibling's
## bounds. Auto-creates the concrete Shape subclass when the slot is empty
## or the requested type differs — bundling creation and sizing in a single
## undo action.
##
## Shape type defaults: Box for 3D, Rectangle for 2D.
var _undo_redo: EditorUndoRedoManager
func _init(undo_redo: EditorUndoRedoManager) -> void:
_undo_redo = undo_redo
const _SHAPE_3D_CLASSES := {
"box": "BoxShape3D",
"sphere": "SphereShape3D",
"capsule": "CapsuleShape3D",
"cylinder": "CylinderShape3D",
}
const _SHAPE_2D_CLASSES := {
"rectangle": "RectangleShape2D",
"circle": "CircleShape2D",
"capsule": "CapsuleShape2D",
}
func autofit(params: Dictionary) -> Dictionary:
var node_path: String = params.get("path", "")
if node_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
if _resolved.has("error"):
return _resolved
var node: Node = _resolved.node
var scene_root: Node = _resolved.scene_root
var is_3d := node is CollisionShape3D
var is_2d := node is CollisionShape2D
if not (is_3d or is_2d):
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Node at %s is %s — must be CollisionShape3D or CollisionShape2D" % [node_path, node.get_class()]
)
var source_path: String = params.get("source_path", "")
var source: Node = null
if source_path.is_empty():
var search := _find_bounds_visual(node, is_3d, scene_root)
if search.has("error"):
return search.error
source = search.source
else:
source = McpScenePath.resolve(source_path, scene_root)
if source == null:
return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND,
"source_path: %s" % McpScenePath.format_node_error(source_path, scene_root))
var shape_type: String = params.get("shape_type", "box" if is_3d else "rectangle")
var type_map := _SHAPE_3D_CLASSES if is_3d else _SHAPE_2D_CLASSES
# Accept either the short form ("box") or the matching Godot class name
# ("BoxShape3D") — every other tool in the server takes class names, and
# resource_get_info(type="Shape3D") surfaces concrete_subclasses by class.
if not type_map.has(shape_type):
for short_form in type_map:
if type_map[short_form] == shape_type:
shape_type = short_form
break
if not type_map.has(shape_type):
var valid_pairs: Array[String] = []
for short_form in type_map:
valid_pairs.append("%s (%s)" % [short_form, type_map[short_form]])
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid shape_type '%s' for %s. Valid: %s" % [shape_type, node.get_class(), ", ".join(valid_pairs)]
)
var shape_class: String = type_map[shape_type]
# Measure the visual.
var bounds := _measure_bounds(source, is_3d)
if bounds.has("error"):
return bounds.error
# Reuse the existing shape if it already matches the requested class;
# otherwise create a fresh one of the right type in the same undo action.
var existing_shape: Shape3D = null
var existing_shape_2d: Shape2D = null
if is_3d:
existing_shape = node.shape
else:
existing_shape_2d = node.shape
var needs_new_shape := false
if is_3d:
needs_new_shape = existing_shape == null or existing_shape.get_class() != shape_class
else:
needs_new_shape = existing_shape_2d == null or existing_shape_2d.get_class() != shape_class
var target_shape: Resource
if needs_new_shape:
var instance := ClassDB.instantiate(shape_class)
if instance == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % shape_class)
target_shape = instance
else:
target_shape = existing_shape if is_3d else existing_shape_2d
# Compute and apply size.
var size_info := _apply_shape_size(target_shape, shape_type, bounds, is_3d)
var old_shape = existing_shape if is_3d else existing_shape_2d
_undo_redo.create_action("MCP: Autofit %s on %s" % [shape_class, node.name])
if needs_new_shape:
_undo_redo.add_do_property(node, "shape", target_shape)
_undo_redo.add_undo_property(node, "shape", old_shape)
_undo_redo.add_do_reference(target_shape)
else:
# Existing shape stays, but its size changes — snapshot size for undo.
for key in size_info.applied:
var new_val = target_shape.get(key)
var old_val = size_info.previous.get(key)
_undo_redo.add_do_property(target_shape, key, new_val)
_undo_redo.add_undo_property(target_shape, key, old_val)
_undo_redo.commit_action()
return {
"data": {
"path": node_path,
"source_path": McpScenePath.from_node(source, scene_root) if source_path.is_empty() else source_path,
"shape_type": shape_type,
"shape_class": shape_class,
"shape_created": needs_new_shape,
"size": size_info.size_response,
"undoable": true,
}
}
## Returns `{source: Node}` on success, `{error: <error dict>}` on failure.
## Ambiguous tier-2 matches put candidate scene paths in
## `error.data.candidates` so callers can pick one explicitly.
static func _find_bounds_visual(collision_node: Node, is_3d: bool, scene_root: Node) -> Dictionary:
var parent := collision_node.get_parent()
if parent == null:
return {"error": _no_visual_error(is_3d)}
# Tier 1: direct siblings of the collision shape. Uses the broad
# VisualInstance3D filter for backwards compatibility — callers who put
# the visual directly next to the collision picked it on purpose.
var siblings := _measurable_visuals(parent.get_children(), collision_node, is_3d, false)
if not siblings.is_empty():
return {"source": siblings[0]}
# Tier 2: parent siblings (uncles). Tighten the filter to
# GeometryInstance3D so we don't auto-pick a Light3D / DirectionalLight3D
# as a collision source. Auto-pick only when unambiguous; surface
# multiple candidates so the agent chooses.
var grandparent := parent.get_parent()
if grandparent == null:
return {"error": _no_visual_error(is_3d)}
var uncles := _measurable_visuals(grandparent.get_children(), parent, is_3d, true)
if uncles.size() == 1:
return {"source": uncles[0]}
if uncles.size() > 1:
var paths: Array[String] = []
for n in uncles:
paths.append(McpScenePath.from_node(n, scene_root))
var msg := "Multiple visual candidates near %s — pass source_path explicitly. Candidates: %s" % [
McpScenePath.from_node(collision_node, scene_root),
", ".join(paths),
]
var err := ErrorCodes.make(ErrorCodes.INVALID_PARAMS, msg)
err["error"]["data"] = {"candidates": paths}
return {"error": err}
return {"error": _no_visual_error(is_3d)}
## Filter `nodes` for ones we can measure as a collision source. When
## `strict` is true (tier 2 / uncles) only GeometryInstance3D counts in 3D —
## avoids picking up lights as accidental sources. 2D filter is already
## narrow enough that strictness doesn't change behavior.
static func _measurable_visuals(nodes: Array, exclude: Node, is_3d: bool, strict: bool) -> Array[Node]:
var out: Array[Node] = []
for n in nodes:
if n == exclude:
continue
if is_3d:
if strict:
if n is GeometryInstance3D:
out.append(n)
elif n is VisualInstance3D:
out.append(n)
elif n is Sprite2D or n is TextureRect:
out.append(n)
return out
static func _no_visual_error(is_3d: bool) -> Dictionary:
var hint := "MeshInstance3D" if is_3d else "Sprite2D"
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"No visual found near collision shape — searched siblings and parent-siblings. Pass source_path explicitly (e.g. a %s)" % hint,
)
## Measure the visual bounds of `source`. Returns {aabb: AABB} for 3D or
## {rect: Rect2} for 2D on success, or {error: ...} on failure.
## Bounds are returned in world-ish size (local extents scaled by the source
## node's own transform scale) so a MeshInstance3D at scale=(2,2,2) gives an
## 8× volume collider, not a unit collider.
static func _measure_bounds(source: Node, is_3d: bool) -> Dictionary:
if is_3d:
if source is VisualInstance3D:
var aabb: AABB = (source as VisualInstance3D).get_aabb()
# get_aabb() is local-space; pre-multiply by the source's scale
# so the collider tracks what you actually see in the viewport.
var scale_3d: Vector3 = (source as Node3D).transform.basis.get_scale()
aabb.position = aabb.position * scale_3d
aabb.size = aabb.size * scale_3d
return {"aabb": aabb}
return {"error": ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Source %s has no measurable 3D bounds (must be VisualInstance3D subclass)" % source.get_class()
)}
# 2D
if source is Sprite2D:
var s: Sprite2D = source
var srect: Rect2 = s.get_rect()
# get_rect() reports the local texture rect and ignores scale.
srect.position = srect.position * s.scale
srect.size = srect.size * s.scale
return {"rect": srect}
if source is TextureRect:
var tr: TextureRect = source
# tr.size is the Control's laid-out size, which is Vector2.ZERO
# before the first layout pass (e.g. just after the node was created
# via MCP). Fall back to the texture's own size when that happens,
# so autofit doesn't silently produce a zero-sized shape.
var tr_size: Vector2 = tr.size
if tr_size.is_zero_approx():
if tr.texture != null:
tr_size = tr.texture.get_size() * tr.scale
else:
return {"error": ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"TextureRect at %s has zero layout size and no texture to fall back to — autofit would produce a zero-sized shape" % source.name
)}
return {"rect": Rect2(Vector2.ZERO, tr_size)}
return {"error": ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Source %s has no measurable 2D bounds (must be Sprite2D or TextureRect)" % source.get_class()
)}
## Apply size to `shape` based on `bounds` and the requested shape_type.
## Returns {applied: [property_names], previous: {name: old_value}, size_response: dict}.
static func _apply_shape_size(shape: Resource, shape_type: String, bounds: Dictionary, is_3d: bool) -> Dictionary:
var applied: Array[String] = []
var previous := {}
var size_response := {}
if is_3d:
var aabb: AABB = bounds.aabb
var size_v: Vector3 = aabb.size
match shape_type:
"box":
previous["size"] = shape.get("size")
(shape as BoxShape3D).size = size_v
applied.append("size")
size_response = {"x": size_v.x, "y": size_v.y, "z": size_v.z}
"sphere":
var r := maxf(maxf(size_v.x, size_v.y), size_v.z) * 0.5
previous["radius"] = shape.get("radius")
(shape as SphereShape3D).radius = r
applied.append("radius")
size_response = {"radius": r}
"capsule":
var cap := shape as CapsuleShape3D
var r2 := maxf(size_v.x, size_v.z) * 0.5
var h := size_v.y
previous["radius"] = cap.radius
previous["height"] = cap.height
# CapsuleShape3D enforces height >= 2*radius and silently
# clamps setters that would violate it. Read back the
# stored values so the response reflects reality.
cap.radius = r2
cap.height = h
applied.append("radius")
applied.append("height")
size_response = {"radius": cap.radius, "height": cap.height}
"cylinder":
var cyl := shape as CylinderShape3D
var r3 := maxf(size_v.x, size_v.z) * 0.5
var ch := size_v.y
previous["radius"] = cyl.radius
previous["height"] = cyl.height
cyl.radius = r3
cyl.height = ch
applied.append("radius")
applied.append("height")
size_response = {"radius": cyl.radius, "height": cyl.height}
else:
var rect: Rect2 = bounds.rect
var sz: Vector2 = rect.size
match shape_type:
"rectangle":
previous["size"] = shape.get("size")
(shape as RectangleShape2D).size = sz
applied.append("size")
size_response = {"x": sz.x, "y": sz.y}
"circle":
var cr := maxf(sz.x, sz.y) * 0.5
previous["radius"] = shape.get("radius")
(shape as CircleShape2D).radius = cr
applied.append("radius")
size_response = {"radius": cr}
"capsule":
var cap2 := shape as CapsuleShape2D
var cr2 := sz.x * 0.5
var ch2 := sz.y
previous["radius"] = cap2.radius
previous["height"] = cap2.height
# CapsuleShape2D has the same height >= 2*radius invariant
# as its 3D counterpart; read back what Godot actually kept.
cap2.radius = cr2
cap2.height = ch2
applied.append("radius")
applied.append("height")
size_response = {"radius": cap2.radius, "height": cap2.height}
return {"applied": applied, "previous": previous, "size_response": size_response}
@@ -0,0 +1 @@
uid://cdg8kthqla1cj
+572
View File
@@ -0,0 +1,572 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles project settings and filesystem search commands.
const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd")
const RUN_READY_WAIT_SEC := 3.0
## ProjectSettings keys that decide what the engine EXECUTES at project
## startup. `McpPathValidator._reject_sensitive_write` already refuses direct
## writes to `res://project.godot`, `res://override.cfg` and `res://.godot/`
## for exactly this reason — but `set_project_setting` writes that same
## manifest through the settings API, so without this list the guard is
## trivially side-steppable:
##
## settings_set key="autoload/Boot" value="*res://../../evil.gd"
##
## would land arbitrary code on the next project open, and a `project.godot`
## diff is easy to miss in review. Autoloads have a validated route of their
## own (`autoload_handler.add_autoload`, which runs the path through
## `McpPathValidator`); the rest are refused outright because there is no
## legitimate agent workflow for repointing the engine's startup execution.
##
## Deliberately NARROW. A denylist that over-blocks turns settings_set into a
## tool agents can't use, so only keys that actually carry code (or a command
## line) are listed — not their whole section. `application/run/` is an exact
## entry for `main_scene` precisely because its siblings (`max_fps`,
## `low_processor_mode`, …) are inert and must stay writable; likewise
## `application/boot_splash/image` is data, not code, and is NOT blocked.
##
## Prefix entries match the key and anything beneath it, and are used only
## where every key under the prefix is code-bearing. Exact entries match only
## themselves. Comparison is case-folded — ProjectSettings keys are
## case-sensitive, but a case variant that Godot would reject is still a
## clearer error coming from here than from a half-applied save.
const STARTUP_EXECUTION_KEY_PREFIXES: Array[String] = [
"autoload/", ## every key under it is a script path
"editor_plugins/", ## enabled-plugin list; each entry is loaded as code
]
const STARTUP_EXECUTION_KEYS_EXACT: Array[String] = [
"application/run/main_scene", ## the scene the game boots into
"editor/script/templates_search_path", ## where the editor loads templates from
"editor/run/main_run_args", ## command line for the run
]
## Returns "" when `key` may be written via set_project_setting, or a
## human-readable refusal reason otherwise. Static so it is unit-testable
## without instancing the handler.
static func startup_execution_key_refusal(key: String) -> String:
var lowered := key.strip_edges().to_lower()
for prefix in STARTUP_EXECUTION_KEY_PREFIXES:
if lowered.begins_with(prefix):
if prefix == "autoload/":
return (
"Refusing to set '%s' — autoloads run code at project startup. " % key
+ "Use autoload_manage(op='add'), which validates the script path."
)
return (
"Refusing to set '%s' — keys under '%s' are loaded as code " % [key, prefix]
+ "at project startup."
)
for exact in STARTUP_EXECUTION_KEYS_EXACT:
if lowered == exact:
return (
"Refusing to set '%s' — this key controls what the engine loads " % key
+ "or executes at project startup."
)
return ""
var _connection: McpConnection
var _debugger_plugin
var _editor_log_buffer
func _init(connection: McpConnection = null, debugger_plugin = null, editor_log_buffer = null) -> void:
_connection = connection
_debugger_plugin = debugger_plugin
_editor_log_buffer = editor_log_buffer
func get_project_setting(params: Dictionary) -> Dictionary:
var key: String = params.get("key", "")
if key.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: key")
if not ProjectSettings.has_setting(key):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Setting not found: %s" % key)
var value = ProjectSettings.get_setting(key)
return {
"data": {
"key": key,
"value": NodeHandler._serialize_value(value),
"type": type_string(typeof(value)),
}
}
func set_project_setting(params: Dictionary) -> Dictionary:
var key: String = params.get("key", "")
if key.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: key")
if not params.has("value"):
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: value")
## Refuse the startup-execution surface before touching ProjectSettings —
## see STARTUP_EXECUTION_KEY_PREFIXES for why this guard exists here and
## not only in McpPathValidator.
var refusal := startup_execution_key_refusal(key)
if not refusal.is_empty():
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, refusal)
var value = params.get("value")
var had_setting := ProjectSettings.has_setting(key)
var old_value = ProjectSettings.get_setting(key) if had_setting else null
# JSON has no distinct int type: Godot parses `1920` as float. If the
# existing setting is TYPE_INT, coerce whole-number floats back to int so
# we don't silently flip typed-int settings (viewport_width, etc.) to
# floats on disk. See issue #31.
if had_setting and typeof(old_value) == TYPE_INT and typeof(value) == TYPE_FLOAT and float(int(value)) == value:
value = int(value)
ProjectSettings.set_setting(key, value)
var err := ProjectSettings.save()
if err != OK:
if had_setting:
ProjectSettings.set_setting(key, old_value)
else:
ProjectSettings.clear(key)
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to save project settings (error %d)" % err)
return {
"data": {
"key": key,
"value": NodeHandler._serialize_value(value),
"old_value": NodeHandler._serialize_value(old_value),
"type": type_string(typeof(value)),
"undoable": false,
"reason": "ProjectSettings changes are saved to disk",
}
}
func run_project(params: Dictionary) -> Dictionary:
var mode: String = params.get("mode", "main")
var autosave: bool = params.get("autosave", true)
# Idempotent: a project that's already running satisfies the caller's intent.
# Returning INVALID_PARAMS here punished agents that legitimately called run
# to ensure the project is playing (87+ installs/day hit the matching
# stop-not-running case in telemetry). Surface state via was_already_running
# so a caller wanting a *different* scene can detect and stop+restart.
if EditorInterface.is_playing_scene():
return _run_project_current_liveness_response(
_run_project_base_data(
mode,
str(params.get("scene", "")),
autosave,
true,
"Project was already running; no action taken"
)
)
var validation_error: Variant = null
if mode == "custom":
var custom_scene: String = params.get("scene", "")
if custom_scene.is_empty():
validation_error = ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: scene (required when mode='custom')")
else:
## play_custom_scene() was the last path-taking op in the plugin
## with no containment check; every sibling that accepts a scene
## path validates it (scene_handler.open_scene,
## node_handler.create_node's scene_path).
validation_error = McpPathValidator.loadable_error(custom_scene, "scene")
elif mode != "main" and mode != "current":
validation_error = ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid mode '%s' — use 'main', 'current', or 'custom'" % mode)
if validation_error != null:
return validation_error
# play_*_scene internally triggers try_autosave() → _save_scene_with_preview()
# which renders a preview thumbnail and calls frame processing. If our
# WebSocket connection's _process() re-enters during that render, the
# engine crashes (SIGABRT in _save_scene_with_preview). Pause processing
# around the play call — same pattern as SceneHandler.save_scene.
if _connection:
_connection.pause_processing = true
# try_autosave() reads run/auto_save/save_before_running every call, so
# toggling it off around the play call suppresses the save without
# touching the user's persisted preference. Issue #81.
var autosave_key := "run/auto_save/save_before_running"
var editor_settings: EditorSettings = null
if not autosave:
editor_settings = EditorInterface.get_editor_settings()
var prior_autosave: bool = true
var restore_setting := false
if editor_settings != null and editor_settings.has_setting(autosave_key):
prior_autosave = bool(editor_settings.get_setting(autosave_key))
editor_settings.set_setting(autosave_key, false)
restore_setting = true
if _debugger_plugin != null:
_debugger_plugin.begin_game_run(_editor_log_cursor(), _game_helper_autoload_expected())
match mode:
"main":
EditorInterface.play_main_scene()
"current":
EditorInterface.play_current_scene()
"custom":
var scene_path: String = params.get("scene", "")
EditorInterface.play_custom_scene(scene_path)
if restore_setting:
editor_settings.set_setting(autosave_key, prior_autosave)
if _connection:
_connection.pause_processing = false
var base_data := _run_project_base_data(
mode,
str(params.get("scene", "")),
autosave,
false,
"Play/stop is a runtime action"
)
var request_id: String = params.get("_request_id", "")
if _connection != null and _debugger_plugin != null and not request_id.is_empty():
_finish_run_project_deferred(request_id, base_data, _connection, _debugger_plugin)
return McpDispatcher.DEFERRED_RESPONSE
return _run_project_current_liveness_response(base_data)
func _editor_log_cursor() -> int:
return _editor_log_buffer.appended_total() if _editor_log_buffer != null else 0
func _game_helper_autoload_expected() -> bool:
return ProjectSettings.has_setting("autoload/_mcp_game_helper")
static func _run_project_base_data(
mode: String,
scene: String,
autosave: bool,
was_already_running: bool,
reason: String
) -> Dictionary:
return {
"mode": mode,
"scene": scene,
"autosave": autosave,
"was_already_running": was_already_running,
"undoable": false,
"reason": reason,
}
func _run_project_current_liveness_response(base_data: Dictionary) -> Dictionary:
if _debugger_plugin == null:
return {"data": base_data}
var status: Dictionary = _debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC)
## One-shot read — force a Debugger-tab scan so boot errors that landed
## after the last gated scan are in this response (#641).
var errors_info: Dictionary = _debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)), true)
return _run_project_response(base_data, _run_project_liveness_decision(status, errors_info))
## `static` is load-bearing (#712, same rationale as the script/filesystem
## handlers and editor_handler._do_reload_plugin): this coroutine awaits
## across frames, and a plugin reload frees this RefCounted handler
## mid-await — resuming an instance coroutine on a freed object errors.
## `connection` and `debugger_plugin` are parameterized explicitly and
## re-validated after every await; the response is dropped silently when
## either died (the server's command timeout surfaces the failure).
static func _finish_run_project_deferred(
request_id: String, base_data: Dictionary, connection, debugger_plugin
) -> void:
var tree: SceneTree = connection.get_tree()
while true:
await tree.process_frame
if not is_instance_valid(connection) or not is_instance_valid(debugger_plugin):
return
var pre_status: Dictionary = debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC)
if (
not EditorInterface.is_playing_scene()
and int(pre_status.get("elapsed_msec", 0)) > 100
and str(pre_status.get("status", "stopped")) == "launching"
):
debugger_plugin.end_game_run()
var status: Dictionary = debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC)
var errors_info: Dictionary = debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)))
var decision := _run_project_liveness_decision(status, errors_info)
if not bool(decision.get("resolve", false)):
continue
## #641: the loop above polls with gated (cheap) scans; boot parse
## errors can land in the Errors tab in the same frames the run goes
## live. Re-gather once with a forced scan before replying so the
## response reports them instead of leaving them to a later
## logs_read. Rebuilding the decision with strictly-more errors can
## only keep it resolved (errors never un-resolve a decision).
errors_info = debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)), true)
decision = _run_project_liveness_decision(status, errors_info)
connection.send_deferred_response(request_id, _run_project_response(base_data, decision))
return
static func _run_project_response(base_data: Dictionary, decision: Dictionary) -> Dictionary:
var data := base_data.duplicate(true)
var game_status: Dictionary = decision.get("game_status", {})
data["game_status"] = game_status
data["helper_live"] = bool(game_status.get("helper_live", false))
data["session_active"] = bool(game_status.get("session_active", false))
if bool(data.get("was_already_running", false)):
data["reason"] = _run_project_already_running_message(decision)
else:
data["reason"] = decision.get("message", data.get("reason", "Play/stop is a runtime action"))
data["recent_errors"] = decision.get("recent_errors", [])
data["recent_errors_scope"] = decision.get("recent_errors_scope", "none")
data["recent_errors_may_predate_run"] = decision.get("recent_errors_may_predate_run", false)
data["recent_errors_truncated"] = decision.get("recent_errors_truncated", false)
data.merge(McpDebuggerPlugin.split_errors_by_scope(data["recent_errors"], data["recent_errors_scope"]), true)
return {"data": data}
static func _run_project_already_running_message(decision: Dictionary) -> String:
var state := str(decision.get("liveness_status", "unknown"))
match state:
"live":
var live_errors: Array = decision.get("recent_errors", [])
if not live_errors.is_empty() and str(decision.get("recent_errors_scope", "none")) == "run":
return (
"Project was already running; the Godot AI game helper is live, but %d editor error%s surfaced during this run (first: %s). Check logs_read(source='editor', include_details=true)."
% [live_errors.size(), "s" if live_errors.size() != 1 else "", _format_editor_error_summary(live_errors[0])]
)
return "Project was already running; the Godot AI game helper is live."
"not_live":
var errors: Array = decision.get("recent_errors", [])
var scope := str(decision.get("recent_errors_scope", "none"))
if not errors.is_empty() and scope == "run":
return "Project was already running but failed to load before the Godot AI game helper registered: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(errors[0])
if not errors.is_empty():
return "Project was already running but is not responding. A recent editor error may be related, but may predate this run: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(errors[0])
return "Project was already running but did not become live before the helper-ready window elapsed. Check logs_read(source='editor', include_details=true) and poll editor_state."
"break":
var break_errors: Array = decision.get("recent_errors", [])
if not break_errors.is_empty() and str(decision.get("recent_errors_scope", "none")) == "run":
return "Project was already running but the game is parked at a debugger break: %s. Call project_manage(op='stop') to end the run, fix the error, and relaunch." % _format_editor_error_summary(break_errors[0])
if not break_errors.is_empty():
return "Project was already running but the game is parked at a debugger break. A recent editor error may be related, but may predate this run: %s. Call project_manage(op='stop') to end the run." % _format_editor_error_summary(break_errors[0])
return "Project was already running but the game is parked at a debugger break. Call project_manage(op='stop') to end the run; the break reason is in the editor's Debugger panel."
"no_helper":
return "Project was already running, but no _mcp_game_helper autoload is expected. Headless or custom-main-loop projects cannot confirm helper liveness."
"launching":
return "Project was already running and is still waiting for the Godot AI game helper to register. Poll editor_state shortly."
"stopped":
return "Project was already marked playing by the editor, but no active game liveness run exists."
_:
return "Project was already running; current liveness status is %s." % state
## Static (with the rest of the deferred-finisher chain) so the #712
## load-bearing-static coroutines above can call it after their owner
## handler was freed. Uses no instance state.
static func _run_project_liveness_decision(status: Dictionary, errors_info: Dictionary = {}) -> Dictionary:
var enriched_status := McpDebuggerPlugin.with_liveness_flags(status)
var state := str(status.get("status", "stopped"))
var recent_errors: Array = errors_info.get("errors", [])
var errors_scope := str(errors_info.get("scope", "none"))
var truncated := bool(errors_info.get("truncated", false))
## Clear errors that predate this run's window — they don't belong in
## a successful launch response and only add noise. They remain
## reachable via logs_read(source='editor') and the retained buffer so
## failed-run debugging is unaffected.
## #635 tradeoff: a genuine in-run error whose Errors-tab row carries
## an empty or byte-identical time text can be misclassified as
## retained_recent and will be dropped here. Still reachable via
## logs_read.
if state == "live" and errors_scope == "retained_recent":
recent_errors = []
errors_scope = "none"
var correlated_error := not recent_errors.is_empty() and errors_scope == "run"
var elapsed_msec := int(status.get("elapsed_msec", 0))
var ready_wait_msec := int(status.get("ready_wait_msec", int(RUN_READY_WAIT_SEC * 1000.0)))
var decision := {
"resolve": false,
"game_status": enriched_status,
"liveness_status": state,
"recent_errors": recent_errors,
"recent_errors_scope": errors_scope,
"recent_errors_may_predate_run": errors_scope == "retained_recent",
"recent_errors_truncated": truncated,
"message": "",
}
if state == "live":
decision["resolve"] = true
if correlated_error:
## #641: "live" only means the helper autoload registered — scripts
## can still have failed to parse or load during boot (a broken
## node script does not stop the game from running). Surface those
## errors in the success message so agents don't read a clean
## launch into a run that silently lost scripts.
decision["message"] = (
"Game launched and the Godot AI game helper is live, but %d editor error%s surfaced during startup (first: %s) — likely a script that failed to parse or load. Check logs_read(source='editor', include_details=true)."
% [recent_errors.size(), "s" if recent_errors.size() != 1 else "", _format_editor_error_summary(recent_errors[0])]
)
if truncated:
decision["message"] += " Editor logs since this run may be truncated; showing retained errors."
else:
decision["message"] = "Game launched and the Godot AI game helper is live."
elif state == "break":
## #645: the game process is parked in a remote-debugger break. A
## boot-time parse error (GDScriptLanguage::debug_break_parse) produces
## no Errors-tab row, no Logger entry, and no game-log line — the
## synthesized break record is the only evidence, and it lands a
## moment after the break signal (stack frames arrive async). Wait for
## it (correlated_error) before resolving; the ready window is the
## fallback if synthesis never lands.
var break_info: Dictionary = status.get("break", {})
var break_reason := str(break_info.get("reason", ""))
if bool(break_info.get("pre_live", true)):
decision["resolve"] = correlated_error or elapsed_msec >= ready_wait_msec
var summary := break_reason
if correlated_error:
summary = _format_editor_error_summary(recent_errors[0])
if summary.is_empty():
summary = "script parse/load error (reason not captured)"
decision["message"] = "Game hit a script error during startup and is frozen at a debugger break before the Godot AI game helper registered: %s. The run cannot continue; call project_manage(op='stop'), fix the error, and relaunch. Check logs_read(source='editor', include_details=true)." % summary
else:
var reason_suffix := (": %s" % break_reason) if not break_reason.is_empty() else ""
decision["resolve"] = true
decision["message"] = "Game is paused at a debugger break%s. Resume it from the editor's Debugger panel or call project_manage(op='stop')." % reason_suffix
elif correlated_error:
decision["resolve"] = true
decision["liveness_status"] = "not_live"
decision["message"] = "Game launched but failed to load before the Godot AI game helper registered: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0])
if truncated:
decision["message"] += " Editor logs since this run may be truncated; showing retained errors."
elif state == "not_live":
decision["resolve"] = true
if not recent_errors.is_empty():
decision["message"] = "Game launched but is not responding. A recent editor error may be related, but may predate this run: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0])
else:
decision["message"] = "Game launched but did not become live before the helper-ready window elapsed. It may still be booting or may have failed silently; check logs_read(source='editor', include_details=true) and poll editor_state."
elif state == "no_helper":
decision["resolve"] = true
decision["message"] = "Game launched, but no _mcp_game_helper autoload is expected. Headless or custom-main-loop projects cannot confirm helper liveness; use editor_state and viewport/editor tools where applicable."
elif state == "stopped":
decision["resolve"] = true
decision["message"] = "The play session stopped, or no active game liveness run exists, before the Godot AI game helper became live."
elif state == "launching" and elapsed_msec >= ready_wait_msec:
decision["resolve"] = true
decision["message"] = "Game launched but is not yet live after %.1fs; it may still be booting. Poll editor_state and check logs_read(source='editor', include_details=true)." % (float(elapsed_msec) / 1000.0)
return decision
static func _format_editor_error_summary(entry: Dictionary) -> String:
return McpSurfacedErrorTracker.format_editor_error_summary(entry)
func stop_project(params: Dictionary) -> Dictionary:
# Idempotent: a project that's already stopped satisfies the caller's intent.
# Returning INVALID_PARAMS here was the largest single source of fleet-wide
# project_manage failures (87 installs/24h). was_running=false lets callers
# distinguish a no-op stop from one that actually halted a running session.
if not EditorInterface.is_playing_scene():
return {
"data": {
"stopped": true,
"was_running": false,
"undoable": false,
"reason": "Project was not running; no action taken",
}
}
if _debugger_plugin != null:
_debugger_plugin.end_game_run()
EditorInterface.stop_playing_scene()
# stop_playing_scene() is async — is_playing_scene() only flips to false on
# the next frame, and readiness_changed follows in _process. Defer the
# response so we can reply with authoritative readiness instead of letting
# the server poll for the event. Issue #29.
var request_id: String = params.get("_request_id", "")
if _connection != null and not request_id.is_empty():
_finish_stop_project_deferred(request_id, _connection)
return McpDispatcher.DEFERRED_RESPONSE
# Fallback for contexts without a connection (e.g. batch_execute via
# dispatch_direct, or unit tests that instantiate the handler with null).
return {
"data": {
"stopped": true,
"was_running": true,
"undoable": false,
"reason": "Play/stop is a runtime action",
}
}
# Wait two frames so Godot can tick the stop-play state change. After this
# is_playing_scene() reflects truth and get_readiness() is authoritative.
# If the plugin tears down (_exit_tree frees _connection) during the await,
# is_instance_valid() goes false and we drop the response silently — the
# server's 5s request timeout will surface the failure to the caller.
# `static` is load-bearing (#712): see _finish_run_project_deferred.
static func _finish_stop_project_deferred(request_id: String, connection) -> void:
var tree: SceneTree = connection.get_tree()
await tree.process_frame
await tree.process_frame
if not is_instance_valid(connection):
return
connection.send_deferred_response(request_id, {
"data": {
"stopped": true,
"was_running": true,
"undoable": false,
"reason": "Play/stop is a runtime action",
"readiness_after": McpConnection.get_readiness(),
}
})
func search_filesystem(params: Dictionary) -> Dictionary:
var name_filter: String = params.get("name", "")
var type_filter: String = params.get("type", "")
var path_filter: String = params.get("path", "")
if name_filter.is_empty() and type_filter.is_empty() and path_filter.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "At least one filter (name, type, path) is required")
var efs := EditorInterface.get_resource_filesystem()
if efs == null:
return ErrorCodes.make_not_ready(
ErrorCodes.SUB_EDITOR_UNAVAILABLE,
"EditorFileSystem not available", false)
var results: Array[Dictionary] = []
_scan_directory(efs.get_filesystem(), name_filter, type_filter, path_filter, results)
return {"data": {"files": results, "count": results.size()}}
func _scan_directory(dir: EditorFileSystemDirectory, name_filter: String, type_filter: String, path_filter: String, out: Array[Dictionary]) -> void:
for i in dir.get_file_count():
var file_path := dir.get_file_path(i)
var file_type := dir.get_file_type(i)
var matches := true
if not name_filter.is_empty():
if file_path.get_file().to_lower().find(name_filter.to_lower()) == -1:
matches = false
if matches and not type_filter.is_empty():
if file_type != type_filter:
matches = false
if matches and not path_filter.is_empty():
if file_path.to_lower().find(path_filter.to_lower()) == -1:
matches = false
if matches:
out.append({
"path": file_path,
"type": file_type,
})
for i in dir.get_subdir_count():
_scan_directory(dir.get_subdir(i), name_filter, type_filter, path_filter, out)
@@ -0,0 +1 @@
uid://brf8u32hvha68
@@ -0,0 +1,591 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const ClassIntrospection := preload("res://addons/godot_ai/utils/class_introspection.gd")
## Handles resource search, inspection, and assignment to nodes.
const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd")
var _undo_redo: EditorUndoRedoManager
var _connection: McpConnection
func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void:
_undo_redo = undo_redo
_connection = connection
func search_resources(params: Dictionary) -> Dictionary:
var type_filter: String = params.get("type", "")
var path_filter: String = params.get("path", "")
if type_filter.is_empty() and path_filter.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "At least one filter (type, path) is required")
var efs := EditorInterface.get_resource_filesystem()
if efs == null:
return ErrorCodes.make_not_ready(
ErrorCodes.SUB_EDITOR_UNAVAILABLE,
"EditorFileSystem not available", false)
var results: Array[Dictionary] = []
_scan_resources(efs.get_filesystem(), type_filter, path_filter, results)
return {"data": {"resources": results, "count": results.size()}}
func _scan_resources(dir: EditorFileSystemDirectory, type_filter: String, path_filter: String, out: Array[Dictionary]) -> void:
for i in dir.get_file_count():
var file_path := dir.get_file_path(i)
var file_type := dir.get_file_type(i)
var matches := true
if not type_filter.is_empty():
# Check if the file type matches or is a subclass of the requested type
if file_type != type_filter and not ClassDB.is_parent_class(file_type, type_filter):
matches = false
if matches and not path_filter.is_empty():
if file_path.to_lower().find(path_filter.to_lower()) == -1:
matches = false
if matches:
out.append({
"path": file_path,
"type": file_type,
})
for i in dir.get_subdir_count():
_scan_resources(dir.get_subdir(i), type_filter, path_filter, out)
func load_resource(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 path_err = McpPathValidator.loadable_error(path, "path")
if path_err != null:
return path_err
if not ResourceLoader.exists(path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % path)
var res: Resource = load(path)
if res == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load resource: %s" % path)
var properties: Array[Dictionary] = []
for prop in res.get_property_list():
var usage: int = prop.get("usage", 0)
if not (usage & PROPERTY_USAGE_EDITOR):
continue
var value = res.get(prop.name)
if value == null and prop.type != TYPE_NIL:
continue
properties.append({
"name": prop.name,
"type": type_string(prop.type),
"value": NodeHandler._serialize_value(value),
})
return {
"data": {
"path": path,
"type": res.get_class(),
"properties": properties,
"property_count": properties.size(),
}
}
func assign_resource(params: Dictionary) -> Dictionary:
var node_path: String = params.get("path", "")
var property: String = params.get("property", "")
var resource_path: String = params.get("resource_path", "")
if node_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
if property.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: property")
if resource_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: resource_path")
var rpath_err = McpPathValidator.loadable_error(resource_path, "resource_path")
if rpath_err != null:
return rpath_err
var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
if _resolved.has("error"):
return _resolved
var node: Node = _resolved.node
var _scene_root: Node = _resolved.scene_root
# Verify property exists
var found := false
for prop in node.get_property_list():
if prop.name == property:
found = true
break
if not found:
return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, McpPropertyErrors.build_message(node, property))
if not ResourceLoader.exists(resource_path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % resource_path)
var res: Resource = load(resource_path)
if res == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load resource: %s" % resource_path)
var old_value = node.get(property)
_undo_redo.create_action("MCP: Assign %s to %s.%s" % [resource_path.get_file(), node.name, property])
_undo_redo.add_do_property(node, property, res)
_undo_redo.add_undo_property(node, property, old_value)
_undo_redo.commit_action()
return {
"data": {
"path": node_path,
"property": property,
"resource_path": resource_path,
"resource_type": res.get_class(),
"undoable": true,
}
}
## Instantiate a built-in Resource subclass, optionally apply `properties`,
## and either assign it to a node slot (undoable) or save it to a .tres file
## (not undoable — mirrors material_create). Exactly one home is required;
## a resource with no home would be GC'd after the handler returns.
func create_resource(params: Dictionary) -> Dictionary:
var type_str: String = params.get("type", "")
if type_str.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: type")
var properties: Dictionary = params.get("properties", {})
var node_path: String = params.get("path", "")
var property: String = params.get("property", "")
var resource_path: String = params.get("resource_path", "")
var overwrite: bool = params.get("overwrite", false)
var home_err := McpResourceIO.validate_home(params)
if home_err != null:
return home_err
var has_file_target := not resource_path.is_empty()
var made := _instantiate_resource(type_str)
if made is Dictionary:
return made
var res: Resource = made
if not properties.is_empty():
var apply_err := _apply_resource_properties(res, properties)
if apply_err != null:
return apply_err
if has_file_target:
return _save_created_resource(res, type_str, resource_path, overwrite, properties.size())
return _assign_created_resource(res, type_str, node_path, property, properties.size())
## Validate that `type_str` names a concrete Resource subclass that we can
## instantiate. Returns an error dict on failure, or null on success.
static func _validate_resource_class(type_str: String) -> Variant:
if not ClassDB.class_exists(type_str):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown resource type: %s" % type_str)
if ClassDB.is_parent_class(type_str, "Node"):
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"%s is a Node type, not a Resource — use node_create instead" % type_str
)
if not ClassDB.is_parent_class(type_str, "Resource"):
var parent := ClassDB.get_parent_class(type_str)
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"%s is not a Resource type (extends %s)" % [type_str, parent]
)
if not ClassDB.can_instantiate(type_str):
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"%s is abstract and cannot be instantiated — use a concrete subclass (e.g. BoxMesh, BoxShape3D, StyleBoxFlat)" % type_str
)
return null
## Build the "Unknown resource type" error with a steer toward op="scan". A type
## that reaches here is neither an engine built-in (ClassDB) nor a registered
## project class (the global script-class registry). In an agent-driven workflow
## the most common cause is a `class_name` script just made via script_create
## that isn't registered yet — the global class table only rebuilds on a
## filesystem scan (normally an editor-focus event). Point the caller at the one
## cheap call that fixes that, so it doesn't fall back to a full plugin reload.
## See #614 for the headless scan op.
static func _unknown_resource_type_error(type_str: String) -> Dictionary:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
(
"Unknown resource type: %s — not an engine built-in or a registered project class. "
+ "If you just created it with script_create, the global class table is stale until a "
+ "scan: call filesystem_manage(op=\"scan\"), then retry. Otherwise check the spelling."
) % type_str
)
## Resolve a resource type name to a fresh instance. Handles engine built-ins
## (ClassDB) and project `class_name` Resources (the global script-class
## registry). Returns a Resource on success, or an error dict on failure.
static func _instantiate_resource(type_str: String) -> Variant:
if ClassDB.class_exists(type_str):
var class_err: Variant = _validate_resource_class(type_str)
if class_err != null:
return class_err
var built_in := ClassDB.instantiate(type_str)
if built_in == null or not (built_in is Resource):
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s as a Resource" % type_str)
return built_in
for entry in ProjectSettings.get_global_class_list():
if entry.get("class", "") == type_str:
var script_path: String = entry.get("path", "")
var scr: Variant = load(script_path)
# Reject non-Resource script classes BEFORE constructing them:
# scr.new() runs _init(), and an @tool class_name extending a
# non-RefCounted type (e.g. Node) would otherwise build — and leak —
# an orphan instance this path never frees. get_instance_base_type()
# resolves to the native base, so multi-level custom Resource
# hierarchies (B extends A extends Resource) still pass.
var base_or_err: Variant = _script_base_type_or_error(scr, type_str, script_path)
if base_or_err is Dictionary:
return base_or_err
var base_type: StringName = base_or_err
if not ClassDB.is_parent_class(base_type, "Resource"):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Resource type (extends %s)" % [type_str, base_type])
if not scr.can_instantiate():
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s cannot be instantiated in the editor (abstract, or a non-@tool script — add @tool to instantiate it here)" % type_str)
# Reject scripts whose _init() requires arguments BEFORE scr.new():
# scr.new() passes no args, so a required-arg _init raises and aborts
# this handler mid-call, null-cascading into a generic "malformed
# result" error instead of a clean rejection. get_script_method_list()
# reports the effective (incl. inherited) _init; required args =
# args - default_args. Statically detectable only — a _init that runs
# but throws still falls through to scr.new() and the dispatcher catch.
for method in scr.get_script_method_list():
if method.get("name", "") == "_init":
var required_args: int = (method.get("args", []) as Array).size() - (method.get("default_args", []) as Array).size()
if required_args > 0:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s cannot be instantiated: its _init() requires arguments" % type_str)
break
var made: Variant = scr.new()
if made == null or not (made is Resource):
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s as a Resource" % type_str)
return made
return _unknown_resource_type_error(type_str)
## Maximum nesting depth for the {"__class__": ...} sub-resource shortcut.
## Caller-supplied dicts recurse through _apply_resource_properties; without a
## cap a deeply nested payload overflows the GDScript call stack and crashes
## the editor (#536). 32 is far beyond any legitimate sub-resource chain.
const MAX_NESTED_RESOURCE_DEPTH := 32
## Apply a dict of property values to a freshly-instantiated Resource,
## reusing NodeHandler's coercion so Vector3/Color/etc. dicts land typed.
## Returns null on success or an error dict on failure.
## `depth` is internal recursion bookkeeping for the nested {"__class__": ...}
## shortcut — external callers use the default of 0.
static func _apply_resource_properties(res: Resource, properties: Dictionary, depth: int = 0) -> Variant:
if depth > MAX_NESTED_RESOURCE_DEPTH:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Nested resource properties exceed the maximum depth of %d — flatten the {\"__class__\": ...} nesting or create the deep sub-resources in separate calls" % MAX_NESTED_RESOURCE_DEPTH
)
var prop_types := {}
for prop in res.get_property_list():
prop_types[prop.name] = prop.get("type", TYPE_NIL)
for key in properties.keys():
if not prop_types.has(key):
var valid: Array[String] = []
for prop in res.get_property_list():
if prop.get("usage", 0) & PROPERTY_USAGE_EDITOR:
valid.append(prop.name)
valid.sort()
# Name the script's class_name (e.g. MyTestResource) rather than the
# native base (Resource) so the hint names the type the agent created,
# and point at the real MCP verb — resource_manage(op="get_info") now
# answers for project class_name Resources too.
var type_label := res.get_class()
var res_script: Variant = res.get_script()
if res_script is Script and not String(res_script.get_global_name()).is_empty():
type_label = String(res_script.get_global_name())
var err := ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Property '%s' not found on %s. Call resource_manage(op=\"get_info\", params={\"type\": \"%s\"}) to list available properties." % [key, type_label, type_label]
)
err["error"]["data"] = {"valid_properties": valid}
return err
var target_type: int = prop_types[key]
if target_type == TYPE_NIL:
target_type = typeof(res.get(key))
var v = properties[key]
if target_type == TYPE_OBJECT and v is String:
if v == "":
v = null
else:
var vpath_err = McpPathValidator.loadable_error(v, "property '%s'" % key)
if vpath_err != null:
return vpath_err
var loaded := ResourceLoader.load(v)
if loaded == null:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Resource not found at path '%s' for property '%s'" % [v, key]
)
v = loaded
elif target_type == TYPE_OBJECT and v is Dictionary and v.has("__class__"):
# Nested shortcut: the same {"__class__": "X", ...} form that
# node_handler.set_property accepts, now also supported here so
# resource_create/environment_create callers can populate
# sub-resource slots (ShaderMaterial.shader, etc.) in one shot.
var sub_type: String = v.get("__class__", "")
# Resolve via the shared helper so the nested shortcut accepts both
# engine built-ins (ClassDB) and project `class_name` Resources,
# exactly like the top-level resource_create path.
var sub_made := _instantiate_resource(sub_type)
if sub_made is Dictionary:
# Preserve the property-slot context the inline path used to add.
sub_made["error"]["message"] = "%s (for property '%s')" % [sub_made["error"]["message"], key]
return sub_made
var sub_res: Resource = sub_made
var remaining: Dictionary = (v as Dictionary).duplicate()
remaining.erase("__class__")
if not remaining.is_empty():
var nested_err := _apply_resource_properties(sub_res, remaining, depth + 1)
if nested_err != null:
return nested_err
v = sub_res
else:
var slot_value: Variant = res.get(key)
if target_type == TYPE_ARRAY and slot_value is Array and (slot_value as Array).is_typed():
## Typed Array[T] slot (#612): mirror set_property's dispatch —
## the generic passthrough would hand an untyped Array to the
## typed setter, which drops it silently while we report success.
var typed_out: Variant = NodeHandler._coerce_typed_array(
v, slot_value, "Property '%s'" % key
)
if typed_out is Dictionary:
return typed_out
v = typed_out
elif (
target_type == TYPE_DICTIONARY
and slot_value is Dictionary
and (slot_value as Dictionary).is_typed()
):
## Typed Dictionary[K, V] slot (#612 stage 3): success is a
## typed duplicate of the slot; the error envelope is untyped.
var typed_dict_out: Dictionary = NodeHandler._coerce_typed_dictionary(
v, slot_value, "Property '%s'" % key
)
if not typed_dict_out.is_typed():
return typed_dict_out
v = typed_dict_out
else:
v = NodeHandler._coerce_value(v, target_type)
## Mirror set_property's coerce check: wrong-shape dicts (#123) and
## non-dict inputs that don't land as the target compound Variant
## (#191) both error here instead of writing zero-filled Variants.
var coerce_err := NodeHandler._check_coerced(v, target_type, "Property '%s'" % key)
if coerce_err != null:
return coerce_err
res.set(key, v)
return null
func _assign_created_resource(res: Resource, type_str: String, node_path: String, property: String, applied_count: int) -> Dictionary:
var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
if _resolved.has("error"):
return _resolved
var node: Node = _resolved.node
var _scene_root: Node = _resolved.scene_root
var found := false
var prop_type: int = TYPE_NIL
for prop in node.get_property_list():
if prop.name == property:
found = true
prop_type = prop.get("type", TYPE_NIL)
break
if not found:
return ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
McpPropertyErrors.build_message(node, property)
)
if prop_type != TYPE_NIL and prop_type != TYPE_OBJECT:
return ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Property '%s' on %s is not an Object slot (type %s)" % [property, node.get_class(), type_string(prop_type)]
)
var old_value = node.get(property)
_undo_redo.create_action("MCP: Create %s for %s.%s" % [type_str, node.name, property])
_undo_redo.add_do_property(node, property, res)
_undo_redo.add_undo_property(node, property, old_value)
_undo_redo.add_do_reference(res)
_undo_redo.commit_action()
return {
"data": {
"path": node_path,
"property": property,
"type": type_str,
"resource_class": res.get_class(),
"properties_applied": applied_count,
"undoable": true,
}
}
func _save_created_resource(res: Resource, type_str: String, resource_path: String, overwrite: bool, applied_count: int) -> Dictionary:
return McpResourceIO.save_to_disk(res, resource_path, overwrite, "Resource", {
"type": type_str,
"resource_class": res.get_class(),
"properties_applied": applied_count,
}, _connection)
## Introspect a Resource class — return its editor-visible properties, parent,
## whether it's abstract, and (for abstract bases) the list of concrete
## subclasses that resource_create can instantiate. Read-only.
func get_resource_info(params: Dictionary) -> Dictionary:
var type_str: String = params.get("type", "")
if type_str.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: type")
if not ClassDB.class_exists(type_str):
# Project class_name Resources aren't in ClassDB; resolve them through the
# global script-class registry so get_info answers for the same custom
# types resource_create can make. Read-only — never instantiates.
var custom_info: Variant = _custom_resource_info(type_str)
if custom_info != null:
return custom_info
return _unknown_resource_type_error(type_str)
if ClassDB.is_parent_class(type_str, "Node"):
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"%s is a Node type, not a Resource — use node_* tools for node introspection" % type_str
)
if not ClassDB.is_parent_class(type_str, "Resource") and type_str != "Resource":
var parent := ClassDB.get_parent_class(type_str)
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"%s is not a Resource type (extends %s)" % [type_str, parent]
)
var can_instantiate: bool = ClassDB.can_instantiate(type_str)
var class_info := ClassIntrospection.build(type_str, {
"sections": ["properties"],
"include_inherited": true,
"include_inheritors": not can_instantiate,
"limit": 0,
})
var data: Dictionary = {
"type": type_str,
"parent_class": class_info.parent_class,
"can_instantiate": can_instantiate,
"is_abstract": not can_instantiate,
"properties": class_info.properties,
"property_count": class_info.property_count,
}
# For abstract bases (Shape3D, Material, Texture, StyleBox, ...) surface
# the concrete Resource subclasses an agent could try next.
if not can_instantiate:
data["concrete_subclasses"] = class_info.concrete_inheritors
return {"data": data}
## Resolve a loaded global-class script to its native base type, or an error if
## the script failed to load (not a Script) or to compile (empty base type).
## Shared by the create and get_info custom-Resource paths so both report a
## compile failure rather than a misleading "is not a Resource type (extends )".
static func _script_base_type_or_error(scr: Variant, type_str: String, script_path: String) -> Variant:
if not (scr is Script):
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load script class %s from %s" % [type_str, script_path])
var base_type: StringName = scr.get_instance_base_type()
if String(base_type).is_empty():
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "%s failed to compile or parse (script %s)" % [type_str, script_path])
return base_type
## get_info for a project `class_name` Resource (not in ClassDB). Returns an info
## dict, an error dict (for a class_name whose native base is not a Resource), or
## null if `type_str` is not a registered global class. Read-only: resolves
## properties from the script + its native base WITHOUT instantiating (no _init()).
static func _custom_resource_info(type_str: String) -> Variant:
for entry in ProjectSettings.get_global_class_list():
if entry.get("class", "") != type_str:
continue
var script_path: String = entry.get("path", "")
var scr: Variant = load(script_path)
var base_or_err: Variant = _script_base_type_or_error(scr, type_str, script_path)
if base_or_err is Dictionary:
return base_or_err
var base_type: StringName = base_or_err
if not ClassDB.is_parent_class(base_type, "Resource"):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Resource type (extends %s)" % [type_str, base_type])
var can_instantiate: bool = scr.can_instantiate()
# Inherited (native) properties come from the engine base via ClassDB...
var class_info := ClassIntrospection.build(String(base_type), {
"sections": ["properties"],
"include_inherited": true,
"limit": 0,
})
var props: Array = []
for native_prop in class_info.properties:
props.append(native_prop)
# ...and the script's own (and inherited script) exported properties come
# from the Script itself, so we never construct the resource. A real
# default isn't available without instantiating, so script props carry an
# explicit null — keeping one uniform key set across the array (native
# props carry their real default).
for raw_prop in scr.get_script_property_list():
var prop: Dictionary = raw_prop
var usage := int(prop.get("usage", 0))
if not (usage & PROPERTY_USAGE_EDITOR):
continue
props.append({
"name": str(prop.get("name", "")),
"type": type_string(int(prop.get("type", TYPE_NIL))),
"class_name": str(prop.get("class_name", "")),
"hint": int(prop.get("hint", PROPERTY_HINT_NONE)),
"hint_string": str(prop.get("hint_string", "")),
"usage": usage,
"default": null,
})
props.sort_custom(func(a, b): return a.name < b.name)
# parent_class is the immediate script parent when there is one (so a
# multi-level chain B -> A -> Resource reports A), else the native base.
var parent_name := String(base_type)
var base_script: Variant = scr.get_base_script()
if base_script is Script and not String(base_script.get_global_name()).is_empty():
parent_name = String(base_script.get_global_name())
return {"data": {
"type": type_str,
"parent_class": parent_name,
"can_instantiate": can_instantiate,
# is_abstract reflects real abstractness (the @abstract annotation),
# NOT editor-instantiability — a non-@tool concrete Resource has
# can_instantiate()==false in-editor but is not abstract.
"is_abstract": scr.is_abstract(),
"properties": props,
"property_count": props.size(),
}}
return null
@@ -0,0 +1 @@
uid://dwwd0n3c56ir
+420
View File
@@ -0,0 +1,420 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles scene tree reading and node search.
var _connection: McpConnection
var _save_scene_callable: Callable = Callable()
var _save_scene_as_callable: Callable = Callable()
func _init(connection: McpConnection = null) -> void:
_connection = connection
func get_scene_tree(params: Dictionary) -> Dictionary:
var max_depth: int = params.get("depth", 10)
var offset: int = maxi(0, int(params.get("offset", 0)))
# limit <= 0 means "no limit" (the hierarchy resource reads the whole tree);
# the scene_get_hierarchy tool passes an explicit positive limit. Paginating
# here — rather than walking + serializing the full tree and slicing on the
# Python side — means only the requested window builds node dicts and clean
# scene paths, and only the window crosses the WebSocket.
var limit: int = int(params.get("limit", 0))
var scene_root := EditorInterface.get_edited_scene_root()
if scene_root == null:
return {"data": {
"nodes": [],
"total_count": 0,
"offset": offset,
"limit": limit,
"has_more": false,
"message": "No scene open",
}}
var nodes: Array[Dictionary] = []
# index_ref[0] is the running DFS index shared across the recursion (Arrays
# pass by reference in GDScript). The walk still visits every node to get an
# accurate total_count, but only materializes those inside the window.
var index_ref: Array[int] = [0]
# _walk_tree self-seeds the root's path for full reads; pass "" explicitly.
_walk_tree(scene_root, nodes, 0, max_depth, scene_root, offset, limit, index_ref, "")
var total: int = index_ref[0]
return {"data": {
"nodes": nodes,
"total_count": total,
"offset": offset,
"limit": limit,
"has_more": limit > 0 and offset + limit < total,
}}
func get_open_scenes(_params: Dictionary) -> Dictionary:
var scene_paths := EditorInterface.get_open_scenes()
var scene_root := EditorInterface.get_edited_scene_root()
var current := scene_root.scene_file_path if scene_root else ""
return {
"data": {
"scenes": scene_paths,
"current_scene": current,
"count": scene_paths.size(),
}
}
func find_nodes(params: Dictionary) -> Dictionary:
var name_filter: String = params.get("name", "")
var type_filter: String = params.get("type", "")
var group_filter: String = params.get("group", "")
if name_filter.is_empty() and type_filter.is_empty() and group_filter.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "At least one filter (name, type, group) is required")
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 results: Array[Dictionary] = []
_find_recursive(scene_root, scene_root, name_filter, type_filter, group_filter, results)
return {"data": {"nodes": results, "count": results.size()}}
func _find_recursive(node: Node, scene_root: Node, name_filter: String, type_filter: String, group_filter: String, out: Array[Dictionary]) -> void:
var matches := true
if not name_filter.is_empty():
if node.name.to_lower().find(name_filter.to_lower()) == -1:
matches = false
if matches and not type_filter.is_empty():
if node.get_class() != type_filter:
matches = false
if matches and not group_filter.is_empty():
if not node.is_in_group(group_filter):
matches = false
if matches:
out.append({
"name": node.name,
"type": node.get_class(),
"path": McpScenePath.from_node(node, scene_root),
})
for child in node.get_children():
_find_recursive(child, scene_root, name_filter, type_filter, group_filter, out)
## Create a new scene with the given root node type, save to disk, and open it.
func create_scene(params: Dictionary) -> Dictionary:
var root_type: String = params.get("root_type", "Node3D")
var path: String = params.get("path", "")
if path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
var path_err = McpPathValidator.path_error(path, "path", true)
if path_err != null:
return path_err
if not path.ends_with(".tscn") and not path.ends_with(".scn"):
path += ".tscn"
if not ClassDB.class_exists(root_type):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown node type: %s" % root_type)
if not ClassDB.is_parent_class(root_type, "Node"):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Node type" % root_type)
# Ensure parent directory exists
var dir_path := path.get_base_dir()
if not DirAccess.dir_exists_absolute(dir_path):
var err := DirAccess.make_dir_recursive_absolute(dir_path)
if err != OK:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to create directory: %s" % dir_path)
var root: Node = ClassDB.instantiate(root_type)
if root == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % root_type)
var root_name: String = params.get("root_name", "")
if root_name.is_empty():
root_name = path.get_file().get_basename()
root.name = root_name
if _connection:
_connection.pause_processing = true
var err := _pack_and_save_with_uid(root, path)
if err == OK:
EditorInterface.open_scene_from_path(path)
if _connection:
_connection.pause_processing = false
if err != OK:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to save scene: %s" % error_string(err))
return {
"data": {
"path": path,
"root_type": root_type,
"root_name": root_name,
"undoable": false,
"reason": "Scene creation involves file system operations",
}
}
## Pack `root` and save it to `path`, embedding a fresh uid or preserving the
## one `path` already had — the exact save sequence `create_scene` runs,
## minus the `pause_processing` guard (the caller owns that, since it also
## needs to bracket `open_scene_from_path`) and minus opening the scene
## (switching the editor's active scene isn't safe inside the shared test
## runner, so tests call this directly instead of going through
## `create_scene` end-to-end). Frees `root`. Returns `OK`, or the first
## `Error` encountered.
func _pack_and_save_with_uid(root: Node, path: String) -> Error:
var packed := PackedScene.new()
packed.pack(root)
root.free()
# Captured BEFORE the save below overwrites the file — see
# McpResourceIO.ensure_uid's doc comment.
var prior_uid := ResourceLoader.get_resource_uid(path) if FileAccess.file_exists(path) else ResourceUID.INVALID_ID
var err := ResourceSaver.save(packed, path)
if err == OK:
err = McpResourceIO.ensure_uid(path, prior_uid)
return err
## How long open_scene waits for the editor to actually switch to the
## requested scene before replying switched=false. Tab switches normally land
## within a few frames; keep this under the dispatcher's 4500 ms deferred
## default so the coroutine always answers before DEFERRED_TIMEOUT fires.
const _OPEN_SETTLE_MAX_MSEC := 3000
## Open an existing scene by file path.
func open_scene(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var force_reload: bool = params.get("force_reload", false)
if path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
var path_err = McpPathValidator.loadable_error(path, "path")
if path_err != null:
return path_err
if not ResourceLoader.exists(path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Scene not found: %s" % path)
var scene_root := EditorInterface.get_edited_scene_root()
var current_path := scene_root.scene_file_path if scene_root else ""
## Instance id of the root at call time. A completed open OR reload always
## replaces the edited-scene root with a NEW instance, so this is the
## reliable completion signal — unlike scene_file_path, which is unchanged
## across a force_reload of the already-open scene (#633 review).
var prev_root_id := scene_root.get_instance_id() if scene_root else 0
var payload := {
"path": path,
"force_reload": force_reload,
"reloaded_from_disk": false,
"previous_scene_path": current_path,
"undoable": false,
"reason": "Scene navigation cannot be undone via editor undo",
}
if current_path == path and not force_reload:
## Already the edited scene — nothing switches, reply immediately.
payload["switched"] = true
payload["settle"] = "already_current"
return {"data": payload}
if force_reload and current_path == path:
EditorInterface.reload_scene_from_path(path)
payload["reloaded_from_disk"] = true
else:
EditorInterface.open_scene_from_path(path)
## The tab switch completes asynchronously; replying now lets an immediate
## follow-up write land on the PREVIOUS scene (#633 — a scene_save issued
## right after open_scene saved the old scene). Defer the reply until the
## edited scene actually is `path` AND its root is a fresh instance, so
## success means "the editor is now editing the (re)loaded scene".
var request_id: String = params.get("_request_id", "")
if _connection != null and not request_id.is_empty():
_finish_open_scene_deferred(_connection, request_id, path, prev_root_id, payload)
return McpDispatcher.DEFERRED_RESPONSE
## Synchronous fallback (batch_execute and unit-test contexts can't await):
## preserve the old reply-immediately behavior, flagged as not waited on.
payload["switched"] = false
payload["settle"] = "not_waited"
return {"data": payload}
## `static` is load-bearing (same reason as FilesystemHandler's deferred scan
## finish): the coroutine must outlive this RefCounted handler, which can be
## freed mid-await by an editor_reload_plugin. Parameterise everything;
## reference no instance state.
static func _finish_open_scene_deferred(
connection: McpConnection,
request_id: String,
path: String,
prev_root_id: int,
payload: Dictionary,
) -> void:
if not is_instance_valid(connection):
return
var tree := connection.get_tree()
if tree == null:
return
# Hand back a frame so _dispatch() registers this request as deferred
# before the coroutine can push a reply.
await tree.process_frame
var deadline_ms := Time.get_ticks_msec() + _OPEN_SETTLE_MAX_MSEC
while Time.get_ticks_msec() < deadline_ms:
var root := EditorInterface.get_edited_scene_root()
# Require BOTH the target path AND a fresh root instance: a
# force_reload keeps scene_file_path == path across the reload, so the
# instance swap is what proves the (re)load actually completed rather
# than the coroutine settling on the stale pre-reload root.
if root != null and root.scene_file_path == path and root.get_instance_id() != prev_root_id:
if not is_instance_valid(connection):
return
payload["switched"] = true
payload["settle"] = "settled"
connection.send_deferred_response(request_id, {"data": payload})
return
await tree.process_frame
if not is_instance_valid(connection):
return
payload["switched"] = false
payload["settle"] = "timeout"
connection.send_deferred_response(request_id, {"data": payload})
## Save the currently edited scene.
## Pauses WebSocket processing during save to prevent re-entrant _process()
## calls during EditorNode::_save_scene_with_preview's thumbnail render.
func save_scene(_params: Dictionary) -> Dictionary:
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 path := scene_root.scene_file_path
if path.is_empty():
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Current scene has never been saved; call scene_manage(op='save_as') with a res://... path ending in .tscn or .scn."
)
if _connection:
_connection.pause_processing = true
var err := _save_current_scene()
if _connection:
_connection.pause_processing = false
if err != OK:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to save scene: %s" % error_string(err))
return {
"data": {
"path": path,
"undoable": false,
"reason": "File save cannot be undone via editor undo",
}
}
## Save the currently edited scene to a new file path.
func save_scene_as(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 path_err = McpPathValidator.path_error(path, "path", true)
if path_err != null:
return path_err
if not path.ends_with(".tscn") and not path.ends_with(".scn"):
path += ".tscn"
var _scene_check := McpNodeValidator.require_scene_or_error()
if _scene_check.has("error"):
return _scene_check
var scene_root: Node = _scene_check.scene_root
# Ensure parent directory exists
var dir_path := path.get_base_dir()
if not DirAccess.dir_exists_absolute(dir_path):
var err := DirAccess.make_dir_recursive_absolute(dir_path)
if err != OK:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to create directory: %s" % dir_path)
if _connection:
_connection.pause_processing = true
_save_current_scene_as(path)
if _connection:
_connection.pause_processing = false
return {
"data": {
"path": path,
"undoable": false,
"reason": "File save cannot be undone via editor undo",
}
}
func _save_current_scene() -> int:
if _save_scene_callable.is_valid():
return int(_save_scene_callable.call())
return EditorInterface.save_scene()
func _save_current_scene_as(path: String) -> void:
if _save_scene_as_callable.is_valid():
_save_scene_as_callable.call(path)
return
EditorInterface.save_scene_as(path)
func _walk_tree(node: Node, out: Array[Dictionary], depth: int, max_depth: int, scene_root: Node, offset: int, limit: int, index_ref: Array[int], node_path: String) -> void:
if depth > max_depth:
return
var idx: int = index_ref[0]
index_ref[0] = idx + 1
# Materialize only nodes inside the [offset, offset+limit) window. Outside
# it we still recurse (to count total_count) but skip the per-node dict.
#
# Path build strategy depends on the read shape (identical output either way):
# * A whole-tree read (offset == 0 and limit <= 0 — the resource-style read
# backing godot://scene/hierarchy) threads the parent's clean path down the
# DFS: each node's path is one O(1) concat reusing the descent, instead of
# McpScenePath.from_node's two native walks back up (is_ancestor_of +
# get_path_to). Benchmarked ~1.8x faster on a ~1.5k-node tree, up to ~5x on
# deep chains.
# * Any windowed read (limit > 0, or an offset > 0 skip) keeps from_node for
# just the emitted nodes: threading would concatenate a path for every node
# visited for total_count, which benchmarks ~20% slower for a small window.
#
# `node_path` is self-seeded at the scene root below, so a caller cannot leave
# a full read unseeded (it has no default — pass "" for windowed reads).
var incremental := limit <= 0 and offset == 0
if incremental and node == scene_root:
node_path = "/" + String(scene_root.name)
var in_window := idx >= offset and (limit <= 0 or idx < offset + limit)
if in_window:
out.append({
"name": node.name,
"type": node.get_class(),
"path": node_path if incremental else McpScenePath.from_node(node, scene_root),
"children_count": node.get_child_count(),
})
for child in node.get_children():
var child_path := (node_path + "/" + String(child.name)) if incremental else ""
_walk_tree(child, out, depth + 1, max_depth, scene_root, offset, limit, index_ref, child_path)
@@ -0,0 +1 @@
uid://7ms40gm6t2r4
+501
View File
@@ -0,0 +1,501 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const DiagnosticsCapture := preload("res://addons/godot_ai/utils/diagnostics_capture.gd")
const ValidationLogger := preload("res://addons/godot_ai/runtime/validation_logger.gd")
## Handles script creation, reading, attaching, detaching, and symbol inspection.
var _undo_redo: EditorUndoRedoManager
var _connection: McpConnection
# The bounded import-settle window and the deferred completion coroutine
# live on McpResourceIO since #714 — write_file's fresh-`.gd` path shares
# them, so create_script and write_file can't drift apart again (#261).
func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void:
_undo_redo = undo_redo
_connection = connection
func create_script(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var content: String = params.get("content", "")
var path_err = McpPathValidator.path_error(path, "path", true)
if path_err != null:
return path_err
if not path.ends_with(".gd"):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Path must end with .gd")
var existed_before := FileAccess.file_exists(path)
# Shared write path (#714): parent mkdir + write/flush + explicit error
# check live on McpResourceIO so write_file can't drift from this again.
var write_failure: Variant = McpResourceIO.write_text_to_disk(path, content)
if write_failure != null:
return write_failure
var data := {
"path": path,
"size": content.length(),
"committed": true,
"import_settled": existed_before,
"import_settle": "already_known" if existed_before else "not_waited",
"undoable": false,
"reason": "File system operations cannot be undone via editor undo",
}
_attach_gdscript_diagnostics(data, path, content)
# A freshly-declared `class_name` is NOT in the global class table until a
# filesystem scan runs — update_file() below registers the file with the
# resource pipeline but not the class registry (see the scan() comment).
# Surface that precisely (only when the class isn't already registered) so a
# headless caller knows to follow up with filesystem_manage(op="scan")
# instead of hitting a confusing "Unknown type" / "Unknown resource type" on
# the very next call. We don't scan here — a scan() per create is the exact
# SIGABRT race documented below; the explicit op is single-flight.
# Skip the hint when the script failed to parse: a scan won't register a
# class from a broken script, so pointing at op="scan" would steer the caller
# away from the real fix (the parse error already attached above).
var declared_class := _extract_class_name(content)
if (
not declared_class.is_empty()
and not _script_has_error_diagnostics(data)
and not _class_name_registered(declared_class)
):
data["class_name"] = declared_class
data["class_registration"] = "scan_required"
data["class_registration_hint"] = (
"New class_name '%s' isn't in the global class table yet. " % declared_class
+ "Call filesystem_manage(op=\"scan\") if it won't resolve on the next "
+ "call (e.g. resource_manage op=\"create\", or used as a type in another "
+ "script). The editor also registers it on its next filesystem scan or "
+ "when its window regains focus."
)
# Register just this file with the editor instead of a full recursive
# scan(). A scan() per write stacks `update_scripts_classes` /
# `update_script_paths_documentation` WorkerThreadPool tasks under concurrent
# script creation ("Task ... already exists" / "!tasks.has(p_task)"), which
# races the global-class registry and can SIGABRT in
# ScriptServer::remove_global_class_by_path (see dsarno/godot#6).
# update_file() is the single-file path the rest of the plugin already uses.
var efs := EditorInterface.get_resource_filesystem()
if efs != null:
efs.update_file(path)
# `.gd.uid` is the sidecar Godot generates on scan; list both so the caller
# can rm the full set in one go.
McpResourceIO.attach_cleanup_hint(data, existed_before, [path, path + ".uid"])
# scan() is async — ResourceLoader.exists(path) returns false until Godot's
# filesystem pipeline finishes. If we reply now, an immediate attach_script
# races and 404s (#261). Defer the response until the resource is visible
# (or a bounded timeout elapses). For freshly-created files we wait; on
# overwrite the resource was already known to ResourceLoader, so reply now.
var request_id: String = params.get("_request_id", "")
if not existed_before and _connection != null and not request_id.is_empty():
McpResourceIO.finish_text_write_deferred(_connection, request_id, path, data)
return McpDispatcher.DEFERRED_RESPONSE
# Synchronous fallback: batch_execute (no request_id) and unit-test contexts
# (no connection) get the immediate reply that the previous behaviour gave.
return {"data": data}
## Extract the `class_name` a script declares, or "" if none. A cheap line scan
## (no full parse) for create_script's "scan_required" hint. Stops at the first
## space/tab or comma so all three valid forms yield just the name:
## `class_name Foo`, `class_name Foo extends Bar`, and the icon form
## `class_name Foo, "res://icon.svg"`.
static func _extract_class_name(content: String) -> String:
for raw_line in content.split("\n"):
var line := raw_line.strip_edges()
if line.begins_with("class_name "):
var rest := line.substr(11).strip_edges()
var cut := rest.length()
for i in rest.length():
var ch := rest[i]
if ch == " " or ch == "\t" or ch == ",":
cut = i
break
return rest.substr(0, cut)
return ""
## True if create_script's diagnostics captured a parse error for this script.
## Used to suppress the "scan_required" hint when the class can't register
## anyway — see create_script.
static func _script_has_error_diagnostics(data: Dictionary) -> bool:
for diag in data.get("diagnostics", []):
if diag is Dictionary and diag.get("level", "") == "error":
return true
return false
## True if `cn` is already usable as a type — an engine built-in (ClassDB) or an
## already-registered project global class. A brand-new class_name returns false
## until a filesystem scan registers it.
static func _class_name_registered(cn: String) -> bool:
if ClassDB.class_exists(cn):
return true
for entry in ProjectSettings.get_global_class_list():
if entry.get("class", "") == cn:
return true
return false
func read_script(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var path_err = McpPathValidator.path_error(path, "path")
if path_err != null:
return path_err
if not FileAccess.file_exists(path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "File not found: %s" % path)
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to open file: %s" % path)
var content := file.get_as_text()
file.close()
return {
"data": {
"path": path,
"content": content,
"size": content.length(),
"line_count": content.count("\n") + (1 if not content.is_empty() else 0),
}
}
## Instance (not static) despite using no instance state: tests stub
## `_capture_gdscript_load_diagnostics` via subclass override, and static
## calls bind lexically — see test_script.gd. filesystem_handler shares
## this by instantiating a bare ScriptHandler (#714).
func _attach_gdscript_diagnostics(data: Dictionary, path: String, content: String) -> void:
var validation := _validate_gdscript_source(content)
var diagnostics: Array = []
var diagnostics_detail := "none"
var diagnostics_status := "checked"
if not validation.get("ok", true):
var capture := _capture_gdscript_load_diagnostics(path)
diagnostics = capture.get("diagnostics", [])
diagnostics_detail = capture.get("diagnostics_detail", "none")
diagnostics_status = capture.get("diagnostics_status", "checked")
if not validation.get("ok", true) and diagnostics.is_empty():
diagnostics.append(_fallback_gdscript_diagnostic(path, validation.get("error_code", FAILED), content))
diagnostics_detail = "fallback"
data["diagnostics"] = diagnostics
data["diagnostics_detail"] = diagnostics_detail
data["diagnostics_scope"] = "this_file"
data["diagnostics_status"] = diagnostics_status
static func _validate_gdscript_source(content: String) -> Dictionary:
var script := GDScript.new()
script.source_code = content
## Keep validation off the live cached resource: assigning resource_path to
## this ephemeral Script can collide with loaded instances. reload() still
## performs normal GDScript analysis, including static initializer work, so
## this check is intentionally scoped to `.gd` writes where the editor would
## compile the file on scan anyway.
var err := script.reload()
return {
"ok": err == OK,
"error_code": err,
}
func _capture_gdscript_load_diagnostics(path: String) -> Dictionary:
var buffer := McpEditorLogBuffer.new()
var logger := ValidationLogger.new(buffer)
var capture := DiagnosticsCapture.capture_this_file(buffer, path, func() -> Dictionary:
OS.add_logger(logger)
# ResourceLoader.load() reports parse failure instead of throwing, and
# a failed GDScript parse does not execute user code; remove immediately
# after the synchronous load to keep the private capture window tiny.
ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE)
OS.remove_logger(logger)
return {}
)
return capture
static func _fallback_gdscript_diagnostic(path: String, error_code: int, content: String) -> Dictionary:
var line := _fallback_gdscript_error_line(content)
return {
"source": "editor",
"level": "error",
"text": "GDScript reload failed with error code %d." % error_code,
"path": path,
"line": line,
"function": "GDScript::reload",
"details": {
"code": "gdscript_reload_failed",
"error_code": error_code,
"fallback_line": true,
"source": {
"path": path,
"line": line,
},
},
}
static func _fallback_gdscript_error_line(content: String) -> int:
var lines := content.split("\n")
for i in range(lines.size() - 1, -1, -1):
if not str(lines[i]).strip_edges().is_empty():
return i + 1
return 1
func patch_script(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var old_text: String = params.get("old_text", "")
var new_text: String = params.get("new_text", "")
var replace_all: bool = params.get("replace_all", false)
var path_err = McpPathValidator.path_error(path, "path", true)
if path_err != null:
return path_err
if not "old_text" in params:
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: old_text")
if not "new_text" in params:
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: new_text")
if not path.ends_with(".gd"):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Path must end with .gd (use filesystem_write_text for other text files)")
if old_text.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "old_text must not be empty")
var read := FileAccess.open(path, FileAccess.READ)
if read == null:
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "File not found or unreadable: %s" % path)
var content := read.get_as_text()
read.close()
var match_count := content.count(old_text)
if match_count == 0:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "old_text not found in %s" % path)
if match_count > 1 and not replace_all:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"old_text matches %d times; pass replace_all=true or provide a more specific snippet" % match_count,
)
var new_content: String
var replacements: int
if replace_all:
new_content = content.replace(old_text, new_text)
replacements = match_count
else:
var idx := content.find(old_text)
new_content = content.substr(0, idx) + new_text + content.substr(idx + old_text.length())
replacements = 1
# Shared write path (#714). No import-settle deferral here: the file
# already exists, so ResourceLoader knows it and there is no scan to wait
# for — same rationale as create_script's overwrite arm.
var write_failure: Variant = McpResourceIO.write_text_to_disk(path, new_content)
if write_failure != null:
return write_failure
var data := {
"path": path,
"replacements": replacements,
"size": new_content.length(),
"old_size": content.length(),
"undoable": false,
"reason": "File system operations cannot be undone via editor undo",
}
_attach_gdscript_diagnostics(data, path, new_content)
# Single-file register, not a full scan() — see create_script (dsarno/godot#6).
var efs := EditorInterface.get_resource_filesystem()
if efs != null:
efs.update_file(path)
return {"data": data}
func attach_script(params: Dictionary) -> Dictionary:
var node_path: String = params.get("path", "")
var script_path: String = params.get("script_path", "")
if node_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
if script_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: script_path")
var spath_err = McpPathValidator.loadable_error(script_path, "script_path")
if spath_err != null:
return spath_err
var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
if _resolved.has("error"):
return _resolved
var node: Node = _resolved.node
var _scene_root: Node = _resolved.scene_root
if not ResourceLoader.exists(script_path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Script not found: %s" % script_path)
var script: Script = load(script_path)
if script == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load script: %s" % script_path)
var old_script: Script = node.get_script()
_undo_redo.create_action("MCP: Attach script to %s" % node.name)
_undo_redo.add_do_method(node, "set_script", script)
_undo_redo.add_undo_method(node, "set_script", old_script)
_undo_redo.commit_action()
return {
"data": {
"path": node_path,
"script_path": script_path,
"had_previous_script": old_script != null,
"undoable": true,
}
}
func detach_script(params: Dictionary) -> Dictionary:
var node_path: String = params.get("path", "")
if node_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
if _resolved.has("error"):
return _resolved
var node: Node = _resolved.node
var _scene_root: Node = _resolved.scene_root
var old_script: Script = node.get_script()
if old_script == null:
return {"data": {"path": node_path, "had_script": false, "undoable": false, "reason": "No script attached"}}
_undo_redo.create_action("MCP: Detach script from %s" % node.name)
_undo_redo.add_do_method(node, "set_script", null)
_undo_redo.add_undo_method(node, "set_script", old_script)
_undo_redo.commit_action()
return {
"data": {
"path": node_path,
"removed_script": old_script.resource_path if old_script.resource_path else "(inline)",
"undoable": true,
}
}
func find_symbols(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var path_err = McpPathValidator.path_error(path, "path")
if path_err != null:
return path_err
if not FileAccess.file_exists(path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "File not found: %s" % path)
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to open file: %s" % path)
var content := file.get_as_text()
file.close()
var functions: Array[Dictionary] = []
var signals_list: Array[String] = []
var exports: Array[Dictionary] = []
var class_name_str := ""
var extends_str := ""
var lines := content.split("\n")
for i in lines.size():
var line := lines[i].strip_edges()
# class_name — same cut logic as _extract_class_name so the
# `extends Bar` / icon-form tails don't leak into the symbol name.
if line.begins_with("class_name "):
var cn_rest := line.substr(11).strip_edges()
var cn_cut := cn_rest.length()
for ci in cn_rest.length():
var cn_ch := cn_rest[ci]
if cn_ch == " " or cn_ch == "\t" or cn_ch == ",":
cn_cut = ci
break
class_name_str = cn_rest.substr(0, cn_cut)
# extends
if line.begins_with("extends "):
extends_str = line.substr(8).strip_edges()
# signal
if line.begins_with("signal "):
var sig_text := line.substr(7).strip_edges()
# Strip any parameters for the name
var paren_idx := sig_text.find("(")
if paren_idx >= 0:
signals_list.append(sig_text.substr(0, paren_idx).strip_edges())
else:
signals_list.append(sig_text)
# func (including `static func` — strip the leading `static ` first)
var func_line := line.substr(7).strip_edges() if line.begins_with("static func ") else line
if func_line.begins_with("func "):
var func_text := func_line.substr(5).strip_edges()
var paren_idx := func_text.find("(")
if paren_idx >= 0:
functions.append({
"name": func_text.substr(0, paren_idx).strip_edges(),
"line": i + 1,
})
# @export
if line.begins_with("@export"):
# Next non-empty line should have the var declaration
# But often export and var are on the same logical flow
# Try to find "var" on the same line or the next line
var var_line := line
if var_line.find("var ") == -1 and i + 1 < lines.size():
var_line = lines[i + 1].strip_edges()
var var_idx := var_line.find("var ")
if var_idx >= 0:
var rest := var_line.substr(var_idx + 4).strip_edges()
# Extract variable name (up to : or = or end)
var end_idx := rest.length()
for ch_idx in rest.length():
if rest[ch_idx] == ":" or rest[ch_idx] == "=" or rest[ch_idx] == " ":
end_idx = ch_idx
break
exports.append({
"name": rest.substr(0, end_idx),
"line": i + 1,
})
return {
"data": {
"path": path,
"class_name": class_name_str,
"extends": extends_str,
"functions": functions,
"signals": signals_list,
"exports": exports,
"function_count": functions.size(),
"signal_count": signals_list.size(),
"export_count": exports.size(),
}
}
@@ -0,0 +1 @@
uid://dhub87454jxb3
+274
View File
@@ -0,0 +1,274 @@
@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_value: Variant = params.get("path", "")
var path_type_err = McpParamValidators.require_string("path", path_value)
if path_type_err != null:
return path_type_err
## String(...) conversion: require_string accepts StringName too, and
## a bare typed assignment from StringName would defeat the guard.
var path: String = String(path_value)
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,
}
@@ -0,0 +1 @@
uid://b4n8byjeqeddm
+309
View File
@@ -0,0 +1,309 @@
@tool
extends RefCounted
## Discovers and runs McpTestSuite scripts from res://tests/.
## Exposes run_tests and get_test_results as MCP commands.
##
## Live MCP runs service the WebSocket transport between tests
## (McpConnection.service_transport_during_exclusive_run) so a long suite
## can no longer starve the server's keepalive, and abort at a per-call
## ceiling derived from the server-provided time budget. Direct callers,
## unit-test fixtures, and batch contexts (no request id / no connection)
## keep the legacy fully-synchronous behavior with no ceiling.
## See docs/test-run-transport-starvation-plan.md.
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Clamp bounds for the server-provided ``timeout_budget_sec`` param. The
## floor is purely defensive (a malformed or buggy server value must not
## abort every run instantly); the param is not user-facing.
const BUDGET_MIN_SEC := 30.0
const BUDGET_MAX_SEC := 3600.0
## Conservative default when the server sent no (or an invalid) budget: an
## old server's own test_run timeout is 120s, and the plugin must abort
## and reply before that future expires.
const BUDGET_DEFAULT_SEC := 110.0
## Abort this long before the server would time the call out, so the
## partial-results reply beats the server-side timeout.
const CEILING_MARGIN_SEC := 10.0
var _runner: McpTestRunner
var _undo_redo: EditorUndoRedoManager
var _log_buffer: McpLogBuffer
## Live plugin dispatcher, exposed to suites via ctx so tests can prove the
## lazy handler registrations (#736) materialize with their real ctor args.
## Optional third arg keeps old two-arg fixtures working; untyped because
## the dispatcher constructs this handler (avoids a load-time type cycle).
var _dispatcher
## Live connection for exclusive-run transport servicing. Null in unit-test
## fixtures and batch contexts, which keep the legacy synchronous path.
var _connection: McpConnection
func _init(
undo_redo: EditorUndoRedoManager,
log_buffer: McpLogBuffer,
dispatcher = null,
connection: McpConnection = null,
) -> void:
_runner = McpTestRunner.new()
_undo_redo = undo_redo
_log_buffer = log_buffer
_dispatcher = dispatcher
_connection = connection
func run_tests(params: Dictionary) -> Dictionary:
var suite_filter: String = params.get("suite", "")
var test_filter: String = params.get("test_name", "")
var exclude_test_filter: String = params.get("exclude_test_name", "")
var verbose: bool = params.get("verbose", false)
var request_id: String = params.get("_request_id", "")
var live := _connection != null and not request_id.is_empty()
var service_cb := Callable()
var deadline_ticks_ms := 0
var budget_sec := 0.0
var started_ms := Time.get_ticks_msec()
var run_state := {}
if live:
budget_sec = _validated_budget_sec(params)
service_cb = Callable(_connection, "service_transport_during_exclusive_run")
deadline_ticks_ms = started_ms + int((budget_sec - CEILING_MARGIN_SEC) * 1000.0)
## Clear the previous run's results BEFORE discovery so an abort at any
## later point can never expose a stale prior run via get_test_results.
_runner.clear()
var discovery := _discover_suites(service_cb, deadline_ticks_ms, run_state)
var discovery_outcome: String = discovery.get("outcome", "")
if not discovery_outcome.is_empty():
## Aborted during discovery: no suite has begun, so there is no
## suite teardown to run. Same outcome mapping as the run itself.
var empty_results: Dictionary = _runner.get_results(verbose)
if not discovery.errors.is_empty():
empty_results["load_errors"] = discovery.errors
return _map_outcome(
discovery_outcome, "discovery", empty_results, 0, started_ms, budget_sec
)
var suites: Array = discovery.suites
if suites.is_empty():
var msg := "No test suites found in res://tests/"
if not discovery.errors.is_empty():
msg += " (%d script(s) failed to load: %s)" % [
discovery.errors.size(),
", ".join(discovery.errors),
]
var no_suites := {"error": msg, "total": 0, "load_errors": discovery.errors}
## Keep the edited_scene annotation on the no-suites error payload too,
## so the response contract is consistent across every return path.
_annotate_edited_scene(no_suites)
return {"data": no_suites}
var ctx := {
"undo_redo": _undo_redo,
"log_buffer": _log_buffer,
"dispatcher": _dispatcher,
}
var run: Dictionary = _runner.run_suites_serviced(
suites, suite_filter, test_filter, ctx, verbose, exclude_test_filter,
service_cb, deadline_ticks_ms, run_state
)
var results: Dictionary = run["results"]
if not discovery.errors.is_empty():
results["load_errors"] = discovery.errors
return _map_outcome(
run["outcome"], "run", results, run["tests_not_run"], started_ms, budget_sec
)
## Map a runner/discovery outcome onto the response envelope. Ownership is
## deliberately here, not in the runner: the runner reports WHAT happened,
## the handler decides how it goes over the wire (plan D2).
func _map_outcome(
outcome: String,
phase: String,
results: Dictionary,
tests_not_run: int,
started_ms: int,
budget_sec: float,
) -> Dictionary:
var elapsed_ms := Time.get_ticks_msec() - started_ms
match outcome:
"completed":
_annotate_edited_scene(results)
return {"data": results}
"transport_lost":
## The peer is gone (or flood-closed); the send will fail against
## the dead socket regardless, but a sync handler must return an
## envelope. Partials stay retrievable via get_test_results after
## the plugin reconnects.
results["aborted"] = "transport_lost"
results["tests_not_run"] = tests_not_run
_annotate_edited_scene(results)
return {"data": results}
"paused":
var depth := _connection.pause_depth() if _connection != null else 0
if _log_buffer != null:
_log_buffer.log(
"[error] test run aborted in %s: transport paused at checkpoint (depth %d)"
% [phase, depth]
)
var paused_err := ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
(
"Test run aborted: the MCP transport was paused at a between-test "
+ "checkpoint (pause depth %d) — a paused transport cannot service "
+ "the WebSocket heartbeat, so continuing would starve the session. "
+ "Partial results: test_manage(op=\"results_get\")."
) % depth
)
paused_err["error"]["data"] = _abort_data(
phase, results, tests_not_run, elapsed_ms, budget_sec, {"pause_depth": depth}
)
return paused_err
"timeout":
var timeout_err := ErrorCodes.make(
ErrorCodes.TEST_RUN_TIMEOUT,
(
"Test run hit its abort ceiling after %.1fs (budget %.0fs, ceiling = "
+ "budget - %.0fs): %d passed, %d failed, %d of the selected tests "
+ "never ran. Narrow the run with suite=/test_name= filters, or fetch "
+ "the partial results with test_manage(op=\"results_get\")."
) % [
elapsed_ms / 1000.0, budget_sec, CEILING_MARGIN_SEC,
int(results.get("passed", 0)), int(results.get("failed", 0)),
tests_not_run,
]
)
timeout_err["error"]["data"] = _abort_data(
phase, results, tests_not_run, elapsed_ms, budget_sec, {}
)
return timeout_err
## Unknown outcome is a runner bug — surface it loudly.
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR, "Unknown test run outcome '%s'" % outcome
)
func _abort_data(
phase: String,
results: Dictionary,
tests_not_run: int,
elapsed_ms: int,
budget_sec: float,
extra: Dictionary,
) -> Dictionary:
var data := {
"phase": phase,
"elapsed_ms": elapsed_ms,
"budget_sec": budget_sec,
"passed": int(results.get("passed", 0)),
"failed": int(results.get("failed", 0)),
"skipped": int(results.get("skipped", 0)),
"total": int(results.get("total", 0)),
"tests_not_run": tests_not_run,
}
data.merge(extra)
return data
## Strict validation of the server-provided per-call budget: numeric,
## finite, positive, then clamped to [BUDGET_MIN_SEC, BUDGET_MAX_SEC].
## Everything else (missing, wrong type, NaN/inf, non-positive) falls back
## to BUDGET_DEFAULT_SEC. typeof() so bool never sneaks through as int.
func _validated_budget_sec(params: Dictionary) -> float:
var raw: Variant = params.get("timeout_budget_sec", null)
var t := typeof(raw)
if t == TYPE_FLOAT or t == TYPE_INT:
var v := float(raw)
if is_finite(v) and v > 0.0:
return clampf(v, BUDGET_MIN_SEC, BUDGET_MAX_SEC)
return BUDGET_DEFAULT_SEC
## Many suites assume the project's main scene is the edited scene (they read
## /Main/... nodes directly). Running with another scene open produces a flood
## of phantom failures that look like real regressions. Surface the edited
## scene and a warning when it differs from run/main_scene so the failures are
## attributable at a glance instead of costing a debugging round (#635).
func _annotate_edited_scene(results: Dictionary) -> void:
var scene_root := EditorInterface.get_edited_scene_root()
var edited := scene_root.scene_file_path if scene_root else ""
results["edited_scene"] = edited
var main_scene := str(ProjectSettings.get_setting("application/run/main_scene", ""))
if main_scene.is_empty() or edited == main_scene:
return
if int(results.get("failed", 0)) <= 0:
return
results["scene_warning"] = (
"Edited scene is '%s' but the project main scene is '%s'. Many suites "
% [edited if not edited.is_empty() else "<none>", main_scene]
+ "assume the main scene is open and will report phantom failures "
+ "otherwise. If these failures are unexpected, scene_open('%s') and re-run." % main_scene
)
func get_test_results(params: Dictionary) -> Dictionary:
var verbose: bool = params.get("verbose", false)
return {"data": _runner.get_results(verbose)}
## Returns {"suites": Array, "errors": Array[String], "outcome": String}.
## Resilient: a broken script doesn't kill discovery of the rest. A
## non-empty outcome ("timeout" / "transport_lost" / "paused") means a
## between-load checkpoint aborted discovery — script loading is itself an
## atomic phase, and a directory of heavy scripts must neither starve the
## heartbeat nor escape the run budget.
func _discover_suites(
service_cb: Callable = Callable(),
deadline_ticks_ms: int = 0,
run_state: Dictionary = {},
) -> Dictionary:
var suites := []
var errors: Array[String] = []
var dir := DirAccess.open("res://tests")
if dir == null:
return {
"suites": suites,
"errors": ["DirAccess.open('res://tests') returned null — directory may not exist"],
"outcome": "",
}
dir.list_dir_begin()
var file_name := dir.get_next()
while not file_name.is_empty():
if file_name.begins_with("test_") and file_name.ends_with(".gd"):
var stop := _discovery_checkpoint(service_cb, deadline_ticks_ms, run_state)
if not stop.is_empty():
return {"suites": suites, "errors": errors, "outcome": stop}
var path := "res://tests/" + file_name
var script = ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE)
if script == null:
errors.append("%s (load failed — check for parse errors or duplicate methods)" % file_name)
elif script.can_instantiate():
var instance = script.new()
if instance is McpTestSuite:
suites.append(instance)
else:
errors.append("%s (not a McpTestSuite subclass)" % file_name)
else:
errors.append("%s (cannot instantiate — abstract or broken)" % file_name)
file_name = dir.get_next()
## Sort by suite name for deterministic order.
suites.sort_custom(func(a, b) -> bool:
return a.suite_name() < b.suite_name()
)
return {"suites": suites, "errors": errors, "outcome": ""}
## Discovery-phase twin of McpTestRunner._checkpoint. Both delegate to the
## shared McpConnection.exclusive_run_checkpoint so the outcome mapping
## cannot drift between the discovery and between-test paths.
func _discovery_checkpoint(
service_cb: Callable, deadline_ticks_ms: int, run_state: Dictionary
) -> String:
return McpConnection.exclusive_run_checkpoint(service_cb, deadline_ticks_ms, run_state)
@@ -0,0 +1 @@
uid://bfg3c6iinhwmx
+199
View File
@@ -0,0 +1,199 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Creates procedural textures — GradientTexture2D (wrapping a Gradient)
## and NoiseTexture2D (wrapping a FastNoiseLite). Assigns to a node slot
## (undoable, bundles sub-resources) or saves to a .tres file.
const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd")
var _undo_redo: EditorUndoRedoManager
var _connection: McpConnection
func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void:
_undo_redo = undo_redo
_connection = connection
const _FILL_MODES := {
"linear": GradientTexture2D.FILL_LINEAR,
"radial": GradientTexture2D.FILL_RADIAL,
"square": GradientTexture2D.FILL_SQUARE,
}
const _NOISE_TYPES := {
"simplex": FastNoiseLite.TYPE_SIMPLEX,
"simplex_smooth": FastNoiseLite.TYPE_SIMPLEX_SMOOTH,
"perlin": FastNoiseLite.TYPE_PERLIN,
"cellular": FastNoiseLite.TYPE_CELLULAR,
"value": FastNoiseLite.TYPE_VALUE,
"value_cubic": FastNoiseLite.TYPE_VALUE_CUBIC,
}
# ============================================================================
# gradient_texture_create
# ============================================================================
func create_gradient_texture(params: Dictionary) -> Dictionary:
var stops: Array = params.get("stops", [])
var width: int = params.get("width", 256)
var height: int = params.get("height", 1)
var fill: String = params.get("fill", "linear")
if stops.size() < 2:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"gradient_texture_create requires at least 2 stops, got %d" % stops.size()
)
if not _FILL_MODES.has(fill):
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid fill '%s'. Valid: %s" % [fill, ", ".join(_FILL_MODES.keys())]
)
var home_err := McpResourceIO.validate_home(params)
if home_err != null:
return home_err
var gradient := Gradient.new()
var offsets := PackedFloat32Array()
var colors := PackedColorArray()
for i in range(stops.size()):
var stop = stops[i]
if not stop is Dictionary:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"stops[%d] must be a dict with 'offset' and 'color' keys" % i
)
if not stop.has("offset") or not stop.has("color"):
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"stops[%d] missing 'offset' or 'color' key" % i
)
offsets.append(float(stop["offset"]))
var color_value = NodeHandler._coerce_value(stop["color"], TYPE_COLOR)
var color_err := NodeHandler._check_coerced(color_value, TYPE_COLOR, "stops[%d].color" % i)
if color_err != null:
return color_err
colors.append(color_value)
gradient.offsets = offsets
gradient.colors = colors
var tex := GradientTexture2D.new()
tex.gradient = gradient
tex.width = width
tex.height = height
tex.fill = _FILL_MODES[fill]
return _finalize(tex, [gradient], params, "Gradient texture", {
"texture_class": "GradientTexture2D",
"gradient_class": "Gradient",
"stop_count": stops.size(),
"fill": fill,
})
# ============================================================================
# noise_texture_create
# ============================================================================
func create_noise_texture(params: Dictionary) -> Dictionary:
var noise_type: String = params.get("noise_type", "simplex_smooth")
var width: int = params.get("width", 512)
var height: int = params.get("height", 512)
var frequency: float = params.get("frequency", 0.01)
var seed_value: int = params.get("seed", 0)
var fractal_octaves: int = params.get("fractal_octaves", 0) # 0 = leave default
if not _NOISE_TYPES.has(noise_type):
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid noise_type '%s'. Valid: %s" % [noise_type, ", ".join(_NOISE_TYPES.keys())]
)
var home_err := McpResourceIO.validate_home(params)
if home_err != null:
return home_err
var noise := FastNoiseLite.new()
noise.noise_type = _NOISE_TYPES[noise_type]
noise.frequency = frequency
noise.seed = seed_value
if fractal_octaves > 0:
noise.fractal_octaves = fractal_octaves
var tex := NoiseTexture2D.new()
tex.noise = noise
tex.width = width
tex.height = height
return _finalize(tex, [noise], params, "Noise texture", {
"texture_class": "NoiseTexture2D",
"noise_class": "FastNoiseLite",
"noise_type": noise_type,
})
# ============================================================================
# shared helpers
# ============================================================================
func _finalize(tex: Resource, sub_resources: Array, params: Dictionary, label: String, extra: Dictionary) -> Dictionary:
var node_path: String = params.get("path", "")
var property: String = params.get("property", "")
var resource_path: String = params.get("resource_path", "")
var overwrite: bool = params.get("overwrite", false)
if not resource_path.is_empty():
return McpResourceIO.save_to_disk(tex, resource_path, overwrite, label, extra, _connection)
return _assign_texture(tex, sub_resources, node_path, property, label, extra)
func _assign_texture(tex: Resource, sub_resources: Array, node_path: String, property: String, label: String, extra: Dictionary) -> Dictionary:
var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
if _resolved.has("error"):
return _resolved
var node: Node = _resolved.node
var _scene_root: Node = _resolved.scene_root
var found := false
var prop_type: int = TYPE_NIL
for prop in node.get_property_list():
if prop.name == property:
found = true
prop_type = prop.get("type", TYPE_NIL)
break
if not found:
return ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
McpPropertyErrors.build_message(node, property)
)
if prop_type != TYPE_NIL and prop_type != TYPE_OBJECT:
return ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Property '%s' on %s is not an Object slot" % [property, node.get_class()]
)
var old_value = node.get(property)
_undo_redo.create_action("MCP: Create %s for %s.%s" % [label, node.name, property])
_undo_redo.add_do_property(node, property, tex)
_undo_redo.add_undo_property(node, property, old_value)
_undo_redo.add_do_reference(tex)
for sub in sub_resources:
_undo_redo.add_do_reference(sub)
_undo_redo.commit_action()
var data := {
"path": node_path,
"property": property,
"undoable": true,
}
data.merge(extra)
return {"data": data}
@@ -0,0 +1 @@
uid://cmloikhre8lhe
+476
View File
@@ -0,0 +1,476 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles Theme resource authoring: creating, modifying color/constant/font-size/
## stylebox slots, and applying a theme to a Control subtree.
##
## Themes are Godot's equivalent of USS: a Theme holds (class, name) -> value
## entries (colors, constants, fonts, font_sizes, styleboxes, icons) which
## cascade down a Control subtree when the theme is assigned at any ancestor.
## One well-authored theme replaces hundreds of per-node property sets.
const _COLOR_HINT := "expected hex #rrggbb, named color, or {r,g,b,a} dict"
var _undo_redo: EditorUndoRedoManager
var _connection: McpConnection
func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void:
_undo_redo = undo_redo
_connection = connection
# ============================================================================
# theme_create
# ============================================================================
func create_theme(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var overwrite: bool = params.get("overwrite", false)
var err := _validate_res_path(path, ".tres", "path", true)
if err != null:
return err
# Capture whether the file was already there BEFORE the save so we can
# report `overwritten` accurately (after save the file always exists).
var existed_before := FileAccess.file_exists(path)
if existed_before and not overwrite:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Theme already exists at %s (pass overwrite=true to replace)" % path
)
# Ensure parent directory exists. make_dir_recursive is idempotent —
# no need to check dir_exists first (avoids TOCTOU race).
var dir_path := path.get_base_dir()
var mkdir_err := DirAccess.make_dir_recursive_absolute(dir_path)
if mkdir_err != OK and mkdir_err != ERR_ALREADY_EXISTS:
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Failed to create directory: %s (error %d)" % [dir_path, mkdir_err]
)
var theme := Theme.new()
var save_err := McpResourceIO.guarded_save(theme, path, _connection)
if save_err != OK:
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Failed to save theme to %s: %s (error %d)" % [path, error_string(save_err), save_err]
)
# Make sure the editor's filesystem picks up the new file.
var efs := EditorInterface.get_resource_filesystem()
if efs != null:
efs.update_file(path)
return {
"data": {
"path": path,
"overwritten": existed_before,
"undoable": false,
"reason": "File creation is persistent; delete the file manually to revert",
}
}
# ============================================================================
# theme_set_color / theme_set_constant / theme_set_font_size
# ============================================================================
func set_color(params: Dictionary) -> Dictionary:
return _set_scalar(params, "color", func(theme, name, cls): return theme.get_color(name, cls),
func(theme, name, cls, val): theme.set_color(name, cls, val),
func(theme, name, cls): theme.clear_color(name, cls),
func(theme, name, cls): return theme.has_color(name, cls),
func(v): return _parse_color(v))
# constant / font_size parsers validate before coercing: int("abc")/int({})/int([])
# all return 0 in GDScript (never null), so a bare `int(v)` would silently store
# garbage as 0 and report success. Returning null for non-numeric input lets
# _set_scalar's null guard surface a VALUE_OUT_OF_RANGE error, matching the
# color path's contract.
func set_constant(params: Dictionary) -> Dictionary:
return _set_scalar(params, "constant", func(theme, name, cls): return theme.get_constant(name, cls),
func(theme, name, cls, val): theme.set_constant(name, cls, int(val)),
func(theme, name, cls): theme.clear_constant(name, cls),
func(theme, name, cls): return theme.has_constant(name, cls),
func(v): return int(v) if (v is int or v is float or (v is String and v.is_valid_int())) else null)
func set_font_size(params: Dictionary) -> Dictionary:
return _set_scalar(params, "font_size", func(theme, name, cls): return theme.get_font_size(name, cls),
func(theme, name, cls, val): theme.set_font_size(name, cls, int(val)),
func(theme, name, cls): theme.clear_font_size(name, cls),
func(theme, name, cls): return theme.has_font_size(name, cls),
func(v): return int(v) if (v is int or v is float or (v is String and v.is_valid_int())) else null)
# Shared implementation for scalar Theme slots (color, constant, font_size).
# Captures old value, applies new value, saves to disk, registers undo that
# restores the old value and saves again.
func _set_scalar(
params: Dictionary,
kind: String,
getter: Callable,
setter: Callable,
clearer: Callable,
has_fn: Callable,
parser: Callable,
) -> Dictionary:
var load_result := _load_theme_from_params(params)
if load_result.has("error"):
return load_result
var theme: Theme = load_result.theme
var theme_path: String = load_result.path
var class_name_param: String = params.get("class_name", "")
if class_name_param.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: class_name")
var name: String = params.get("name", "")
if name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name")
if not "value" in params:
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: value")
var raw_value = params.get("value")
if raw_value == null:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid %s value: null (pass a concrete value; use the appropriate clear command to remove a slot)" % kind
)
var parsed = parser.call(raw_value)
if parsed == null:
## color slots want a color hint; constant/font_size are integer slots.
var hint := _COLOR_HINT if kind == "color" else "expected an integer"
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid %s value: %s (%s)" % [kind, raw_value, hint])
var had_before: bool = has_fn.call(theme, name, class_name_param)
var before_value = getter.call(theme, name, class_name_param) if had_before else null
_undo_redo.create_action("MCP: Theme set %s %s/%s" % [kind, class_name_param, name])
_undo_redo.add_do_method(self, "_apply_scalar", theme_path, setter, name, class_name_param, parsed)
if had_before:
_undo_redo.add_undo_method(self, "_apply_scalar", theme_path, setter, name, class_name_param, before_value)
else:
_undo_redo.add_undo_method(self, "_clear_scalar", theme_path, clearer, name, class_name_param)
_undo_redo.commit_action()
return {
"data": {
"path": theme_path,
"kind": kind,
"class_name": class_name_param,
"name": name,
"value": _serialize_value(parsed),
"previous_value": _serialize_value(before_value) if had_before else null,
"undoable": true,
}
}
func _apply_scalar(theme_path: String, setter: Callable, name: String, class_name_param: String, value: Variant) -> void:
var theme: Theme = ResourceLoader.load(theme_path)
if theme == null:
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
return
setter.call(theme, name, class_name_param, value)
McpResourceIO.guarded_save(theme, theme_path, _connection)
func _clear_scalar(theme_path: String, clearer: Callable, name: String, class_name_param: String) -> void:
var theme: Theme = ResourceLoader.load(theme_path)
if theme == null:
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
return
clearer.call(theme, name, class_name_param)
McpResourceIO.guarded_save(theme, theme_path, _connection)
# ============================================================================
# theme_set_stylebox_flat
# ============================================================================
## Compose a StyleBoxFlat and assign it to a theme slot.
##
## Parameters (beyond theme_path / class_name / name):
## bg_color (Color, "#rrggbb", "#rrggbbaa", or {r,g,b,a})
## border_color (Color)
## border {all|top|bottom|left|right: int} — side keys override `all`
## corners {all|top_left|top_right|bottom_left|bottom_right: int}
## margins {all|top|bottom|left|right: float}
## shadow {color, size: int, offset_x: float, offset_y: float}
## anti_aliasing (bool)
##
## Unknown keys inside any nested dict are rejected with INVALID_PARAMS so
## typos fail loudly instead of silently being ignored.
func set_stylebox_flat(params: Dictionary) -> Dictionary:
var load_result := _load_theme_from_params(params)
if load_result.has("error"):
return load_result
var theme: Theme = load_result.theme
var theme_path: String = load_result.path
var class_name_param: String = params.get("class_name", "")
if class_name_param.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: class_name")
var name: String = params.get("name", "")
if name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name")
var sb := StyleBoxFlat.new()
if params.has("bg_color"):
var bg := _parse_color(params.bg_color)
if bg == null:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid bg_color: %s (%s)" % [str(params.bg_color), _COLOR_HINT])
sb.bg_color = bg
if params.has("border_color"):
var bc := _parse_color(params.border_color)
if bc == null:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid border_color: %s (%s)" % [str(params.border_color), _COLOR_HINT])
sb.border_color = bc
# border: {all, top, bottom, left, right} — int widths
if params.has("border"):
var err := _apply_sides(sb, params.border, "border",
["top", "bottom", "left", "right"],
"border_width_",
TYPE_INT)
if err != "":
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, err)
# corners: {all, top_left, top_right, bottom_left, bottom_right} — int radii
if params.has("corners"):
var err2 := _apply_sides(sb, params.corners, "corners",
["top_left", "top_right", "bottom_left", "bottom_right"],
"corner_radius_",
TYPE_INT)
if err2 != "":
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, err2)
# margins: {all, top, bottom, left, right} — float padding
if params.has("margins"):
var err3 := _apply_sides(sb, params.margins, "margins",
["top", "bottom", "left", "right"],
"content_margin_",
TYPE_FLOAT)
if err3 != "":
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, err3)
# shadow: {color, size, offset_x, offset_y}
if params.has("shadow"):
if typeof(params.shadow) != TYPE_DICTIONARY:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "'shadow' must be a dict with color/size/offset_x/offset_y")
var shadow: Dictionary = params.shadow
var allowed_shadow_keys := {"color": true, "size": true, "offset_x": true, "offset_y": true}
for k in shadow.keys():
if not allowed_shadow_keys.has(k):
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Unknown key in 'shadow': %s (valid: color, size, offset_x, offset_y)" % k)
if shadow.has("color"):
var sc := _parse_color(shadow.color)
if sc == null:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Invalid shadow.color: %s (%s)" % [str(shadow.color), _COLOR_HINT])
sb.shadow_color = sc
if shadow.has("size"):
sb.shadow_size = int(shadow.size)
if shadow.has("offset_x") or shadow.has("offset_y"):
sb.shadow_offset = Vector2(
float(shadow.get("offset_x", 0)),
float(shadow.get("offset_y", 0)),
)
if params.has("anti_aliasing"):
sb.anti_aliasing = bool(params.anti_aliasing)
var had_before := theme.has_stylebox(name, class_name_param)
var before_sb: StyleBox = theme.get_stylebox(name, class_name_param) if had_before else null
_undo_redo.create_action("MCP: Theme set stylebox %s/%s" % [class_name_param, name])
_undo_redo.add_do_method(self, "_apply_stylebox", theme_path, name, class_name_param, sb)
if had_before:
_undo_redo.add_undo_method(self, "_apply_stylebox", theme_path, name, class_name_param, before_sb)
else:
_undo_redo.add_undo_method(self, "_clear_stylebox", theme_path, name, class_name_param)
_undo_redo.commit_action()
return {
"data": {
"path": theme_path,
"class_name": class_name_param,
"name": name,
"stylebox_class": "StyleBoxFlat",
"bg_color": _serialize_value(sb.bg_color),
"border": {
"top": sb.border_width_top,
"bottom": sb.border_width_bottom,
"left": sb.border_width_left,
"right": sb.border_width_right,
},
"corners": {
"top_left": sb.corner_radius_top_left,
"top_right": sb.corner_radius_top_right,
"bottom_left": sb.corner_radius_bottom_left,
"bottom_right": sb.corner_radius_bottom_right,
},
"margins": {
"top": sb.content_margin_top,
"bottom": sb.content_margin_bottom,
"left": sb.content_margin_left,
"right": sb.content_margin_right,
},
"undoable": true,
}
}
## Parse a {all, <side1>, <side2>, ...} dict and apply it to StyleBoxFlat via
## its set_<prop_prefix><side> properties. Returns "" on success, an error
## message on failure. Validates that only known keys are present.
func _apply_sides(sb: StyleBoxFlat, sides_dict: Variant, dict_name: String,
side_names: Array, prop_prefix: String, value_type: int) -> String:
if typeof(sides_dict) != TYPE_DICTIONARY:
return "'%s' must be a dict with 'all' and/or side-specific keys" % dict_name
var valid_keys := {"all": true}
for s in side_names:
valid_keys[s] = true
for k in sides_dict.keys():
if not valid_keys.has(k):
return "Unknown key in '%s': %s (valid: all, %s)" % [
dict_name, k, ", ".join(side_names)
]
# Apply `all` first, then override with side-specific keys.
if sides_dict.has("all"):
var all_val: Variant = sides_dict.all
for s in side_names:
var v: Variant = int(all_val) if value_type == TYPE_INT else float(all_val)
sb.set(prop_prefix + s, v)
for s in side_names:
if sides_dict.has(s):
var v2: Variant = int(sides_dict[s]) if value_type == TYPE_INT else float(sides_dict[s])
sb.set(prop_prefix + s, v2)
return ""
func _apply_stylebox(theme_path: String, name: String, class_name_param: String, sb: StyleBox) -> void:
var theme: Theme = ResourceLoader.load(theme_path)
if theme == null:
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
return
theme.set_stylebox(name, class_name_param, sb)
McpResourceIO.guarded_save(theme, theme_path, _connection)
func _clear_stylebox(theme_path: String, name: String, class_name_param: String) -> void:
var theme: Theme = ResourceLoader.load(theme_path)
if theme == null:
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
return
theme.clear_stylebox(name, class_name_param)
McpResourceIO.guarded_save(theme, theme_path, _connection)
# ============================================================================
# theme_apply — assign a theme to a Control
# ============================================================================
func apply_theme(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
if node_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: node_path")
var theme_path: String = params.get("theme_path", "")
var theme: Theme = null
if not theme_path.is_empty():
var path_err := _validate_res_path(theme_path, ".tres")
if path_err != null:
return path_err
if not ResourceLoader.exists(theme_path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Theme not found: %s" % theme_path)
theme = ResourceLoader.load(theme_path)
if theme == null or not theme is Theme:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Theme" % theme_path)
var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
if _resolved.has("error"):
return _resolved
var node: Node = _resolved.node
var _scene_root: Node = _resolved.scene_root
if not node is Control and not node is Window:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Node %s is not a Control or Window (got %s)" % [node_path, node.get_class()]
)
var before_theme: Theme = node.theme
_undo_redo.create_action("MCP: Apply theme to %s" % node.name)
_undo_redo.add_do_property(node, "theme", theme)
_undo_redo.add_undo_property(node, "theme", before_theme)
_undo_redo.commit_action()
return {
"data": {
"node_path": node_path,
"theme_path": theme_path if theme != null else "",
"cleared": theme == null,
"undoable": true,
}
}
# ============================================================================
# Helpers
# ============================================================================
func _load_theme_from_params(params: Dictionary) -> Dictionary:
var theme_path: String = params.get("theme_path", "")
var err := _validate_res_path(theme_path, ".tres", "theme_path", true)
if err != null:
return err
if not ResourceLoader.exists(theme_path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Theme not found: %s" % theme_path)
var theme: Theme = ResourceLoader.load(theme_path)
if theme == null or not theme is Theme:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Theme" % theme_path)
return {"theme": theme, "path": theme_path}
static func _validate_res_path(path: String, required_suffix: String, param_name: String = "theme_path", for_write: bool = false) -> Variant:
if path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % param_name)
var path_err := McpPathValidator.validate_resource_path(path, for_write)
if not path_err.is_empty():
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "%s: %s" % [param_name, path_err])
if not path.ends_with(required_suffix):
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"%s must end with %s (got %s)" % [param_name, required_suffix, path]
)
return null
## Parse a color from Color, "#rrggbb", "#rrggbbaa", named (red/blue/...) or dict.
## Returns null if the input cannot be parsed.
## Delegates to the canonical parser (#714) — gains [r,g,b(,a)] array
## support and strict key/component checking, same shapes as every other
## color-accepting handler.
static func _parse_color(value: Variant) -> Variant:
return McpJsonValues.parse_color(value)
static func _serialize_value(value: Variant) -> Variant:
if value == null:
return null
if value is Color:
return {"r": value.r, "g": value.g, "b": value.b, "a": value.a}
if value is Vector2:
return {"x": value.x, "y": value.y}
return value
@@ -0,0 +1 @@
uid://gjyldaddj7mu
+163
View File
@@ -0,0 +1,163 @@
@tool
extends RefCounted
## TileMap / TileMapLayer authoring — set, fill, clear, and read tile cells
## directly in the editor scene with full undo/redo support.
##
## All ops target TileMapLayer nodes in the currently edited scene by
## scene-relative path (e.g. "/LavaLake20x20/Ground").
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const MAX_RECT_FILL_CELLS := 4096
var _undo_redo: EditorUndoRedoManager
func _init(undo_redo: EditorUndoRedoManager) -> void:
_undo_redo = undo_redo
## Set a single tile cell.
## params: {path, source_id, atlas_col, atlas_row, map_x, map_y}
## Returns: {map_x, map_y, source_id, atlas_col, atlas_row}
func set_cell(params: Dictionary) -> Dictionary:
var layer := _resolve_layer(params)
if layer.has("error"): return layer
var node: TileMapLayer = layer.node
var pos := Vector2i(params.get("map_x", 0), params.get("map_y", 0))
var src := int(params.get("source_id", 0))
var atlas := Vector2i(params.get("atlas_col", 0), params.get("atlas_row", 0))
var prev := _capture_cell_state(node, pos)
_undo_redo.create_action("MCP: TileMap set_cell")
_undo_redo.add_do_method(node, "set_cell", pos, src, atlas)
_undo_redo.add_undo_method(self, "_restore_cell_state", node, pos, prev)
_undo_redo.commit_action()
return {"data": {"map_x": pos.x, "map_y": pos.y, "source_id": src,
"atlas_col": atlas.x, "atlas_row": atlas.y, "undoable": true}}
## Fill a rectangular region with one tile type in a single undo action.
## params: {path, source_id, atlas_col, atlas_row, rect_x, rect_y, rect_w, rect_h}
## Returns: {cells_filled, rect: {x, y, w, h}}
func set_cells_rect(params: Dictionary) -> Dictionary:
var layer := _resolve_layer(params)
if layer.has("error"): return layer
var node: TileMapLayer = layer.node
var src := int(params.get("source_id", 0))
var atlas := Vector2i(params.get("atlas_col", 0), params.get("atlas_row", 0))
var rx := int(params.get("rect_x", 0)); var ry := int(params.get("rect_y", 0))
var rw := int(params.get("rect_w", 1)); var rh := int(params.get("rect_h", 1))
if rw <= 0 or rh <= 0:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"rect_w and rect_h must be > 0 (got %d x %d)" % [rw, rh]
)
var cell_count := rw * rh
if cell_count > MAX_RECT_FILL_CELLS:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Rect too large: %d cells exceeds max %d" % [cell_count, MAX_RECT_FILL_CELLS]
)
var cells: Array[Vector2i] = []
var snapshot: Array[Dictionary] = []
for x in range(rx, rx + rw):
for y in range(ry, ry + rh):
var pos := Vector2i(x, y)
cells.append(pos)
snapshot.append({"pos": pos, "state": _capture_cell_state(node, pos)})
_undo_redo.create_action("MCP: TileMap set_cells_rect %dx%d" % [rw, rh])
for pos in cells:
_undo_redo.add_do_method(node, "set_cell", pos, src, atlas)
_undo_redo.add_undo_method(self, "_restore_rect_snapshot", node, snapshot)
_undo_redo.commit_action()
return {"data": {"cells_filled": cells.size(),
"rect": {"x": rx, "y": ry, "w": rw, "h": rh}, "undoable": true}}
## Remove all tiles from a TileMapLayer.
## params: {path}
## Returns: {cleared: true}
func clear_layer(params: Dictionary) -> Dictionary:
var layer := _resolve_layer(params)
if layer.has("error"): return layer
var node: TileMapLayer = layer.node
var snapshot := _capture_used_cells_snapshot(node)
_undo_redo.create_action("MCP: TileMap clear")
_undo_redo.add_do_method(node, "clear")
_undo_redo.add_undo_method(self, "_restore_cells_snapshot", node, snapshot)
_undo_redo.commit_action()
return {"data": {"cleared": true, "undoable": true}}
## Return all used cell coordinates.
## params: {path}
## Returns: {cells: [{x, y}, ...], count: int}
func get_used_cells(params: Dictionary) -> Dictionary:
var layer := _resolve_layer(params)
if layer.has("error"): return layer
var node: TileMapLayer = layer.node
var cells := node.get_used_cells()
var result: Array = []
for c in cells:
result.append({"x": c.x, "y": c.y})
return {"data": {"cells": result, "count": result.size()}}
## Resolve a TileMapLayer node from params["path"] in the currently edited
## scene. Returns {"node": TileMapLayer} on success, or an error dict.
func _resolve_layer(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var scene_file: String = params.get("scene_file", "")
var resolved := McpNodeValidator.resolve_or_error(path, "path", scene_file)
if resolved.has("error"):
return resolved
var node: Node = resolved.node
if not node is TileMapLayer:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"Node is not a TileMapLayer: %s" % path)
return {"node": node}
func _capture_cell_state(node: TileMapLayer, pos: Vector2i) -> Dictionary:
var source_id := node.get_cell_source_id(pos)
if source_id == -1:
return {"has_tile": false}
var atlas: Vector2i = node.get_cell_atlas_coords(pos)
var alternative := node.get_cell_alternative_tile(pos)
return {
"has_tile": true,
"source_id": source_id,
"atlas_col": atlas.x,
"atlas_row": atlas.y,
"alternative": alternative,
}
func _capture_used_cells_snapshot(node: TileMapLayer) -> Array[Dictionary]:
var snapshot: Array[Dictionary] = []
for pos in node.get_used_cells():
snapshot.append({"pos": pos, "state": _capture_cell_state(node, pos)})
return snapshot
func _restore_cells_snapshot(node: TileMapLayer, snapshot: Array[Dictionary]) -> void:
node.clear()
for entry in snapshot:
_restore_cell_state(node, entry.pos, entry.state)
func _restore_rect_snapshot(node: TileMapLayer, snapshot: Array[Dictionary]) -> void:
for entry in snapshot:
_restore_cell_state(node, entry.pos, entry.state)
func _restore_cell_state(node: TileMapLayer, pos: Vector2i, state: Dictionary) -> void:
if not state.get("has_tile", false):
node.erase_cell(pos)
return
node.set_cell(
pos,
int(state.get("source_id", -1)),
Vector2i(int(state.get("atlas_col", -1)), int(state.get("atlas_row", -1))),
int(state.get("alternative", 0))
)
@@ -0,0 +1 @@
uid://cm8s7a0ey2q6k
+178
View File
@@ -0,0 +1,178 @@
@tool
extends RefCounted
## TileSet management — atlas inspection helpers.
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
func _init() -> void:
pass
## Query all occupied atlas tile positions for a single source.
##
## params:
## tileset_path — res:// path to the TileSet resource (required, non-empty)
## source_id — raw TileSet source id (required)
##
## Returns:
## {"data": {"tiles": [{"col": int, "row": int}, ...], "count": int}}
## on success (including empty sources, where tiles=[] and count=0)
## ErrorCodes.make(code, message) on any validation or load failure
##
## Error codes:
## MISSING_REQUIRED_PARAM — tileset_path absent/empty, or source_id absent
## RESOURCE_NOT_FOUND — ResourceLoader.exists(tileset_path) is false
## WRONG_TYPE — loaded resource is not a TileSet, or source is
## not a TileSetAtlasSource
## VALUE_OUT_OF_RANGE — source_id not present in TileSet
##
## This method is read-only: it never calls ResourceSaver or modifies any resource.
func get_atlas_tiles(params: Dictionary) -> Dictionary:
var resolved := _resolve_atlas_source(params)
if resolved.has("error"):
return resolved
var src: TileSetAtlasSource = resolved.src
var tiles: Array = []
for i in range(src.get_tiles_count()):
var v: Vector2i = src.get_tile_id(i)
tiles.append({"col": v.x, "row": v.y})
return {"data": {"tiles": tiles, "count": tiles.size()}}
## Return the atlas texture of a TileSetAtlasSource as a Base64-encoded PNG.
##
## params:
## tileset_path — res:// path to the TileSet resource (required, non-empty)
## source_id — raw TileSet source id (required)
## max_size — optional int; if > 0, the image is scaled so its longest
## edge is at most max_size pixels (default 0 = full res)
##
## Returns:
## {"data": {"image_base64": String, "width": int, "height": int,
## "original_width": int, "original_height": int, "format": "png"}}
## on success
## ErrorCodes.make(code, message) on any validation or load failure
##
## Error codes:
## MISSING_REQUIRED_PARAM — tileset_path absent/empty, or source_id absent
## RESOURCE_NOT_FOUND — ResourceLoader.exists(tileset_path) is false
## WRONG_TYPE — loaded resource is not a TileSet, or source is
## not a TileSetAtlasSource, or texture is null
## VALUE_OUT_OF_RANGE — source_id not present in TileSet
##
## This method is read-only: it never calls ResourceSaver or modifies anything.
func get_atlas_image(params: Dictionary) -> Dictionary:
var resolved := _resolve_atlas_source(params)
if resolved.has("error"):
return resolved
var source_id: int = resolved.source_id
var src: TileSetAtlasSource = resolved.src
var tex: Texture2D = src.texture
if tex == null:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Source %d has no texture assigned" % source_id
)
var img: Image = tex.get_image()
if img == null:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Could not retrieve image data from texture of source %d" % source_id
)
if img.is_compressed():
var decompress_err := img.decompress()
if decompress_err != OK:
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Could not decompress texture of source %d: %s" % [source_id, error_string(decompress_err)]
)
var original_width: int = img.get_width()
var original_height: int = img.get_height()
var max_size: int = params.get("max_size", 0)
if max_size > 0:
var longest_edge: int = max(original_width, original_height)
if longest_edge > max_size:
var scale: float = float(max_size) / float(longest_edge)
var new_w: int = max(1, int(original_width * scale))
var new_h: int = max(1, int(original_height * scale))
img.resize(new_w, new_h, Image.INTERPOLATE_LANCZOS)
var png_bytes: PackedByteArray = img.save_png_to_buffer()
if png_bytes.is_empty():
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"PNG encoding produced empty output for source %d" % source_id
)
var b64: String = Marshalls.raw_to_base64(png_bytes)
return {
"data": {
"image_base64": b64,
"width": img.get_width(),
"height": img.get_height(),
"original_width": original_width,
"original_height": original_height,
"format": "png",
}
}
func _resolve_atlas_source(params: Dictionary) -> Dictionary:
var tileset_path: String = params.get("tileset_path", "")
if tileset_path.is_empty():
return ErrorCodes.make(
ErrorCodes.MISSING_REQUIRED_PARAM,
"'tileset_path' parameter is required and must not be empty"
)
if not params.has("source_id"):
return ErrorCodes.make(
ErrorCodes.MISSING_REQUIRED_PARAM,
"'source_id' parameter is required"
)
var tileset_path_err = McpPathValidator.loadable_error(tileset_path, "tileset_path")
if tileset_path_err != null:
return tileset_path_err
if not ResourceLoader.exists(tileset_path):
return ErrorCodes.make(
ErrorCodes.RESOURCE_NOT_FOUND,
"TileSet resource not found: %s" % tileset_path
)
var ts = load(tileset_path)
if not ts is TileSet:
var loaded_type := "null" if ts == null else ts.get_class()
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Resource at '%s' is not a TileSet (got %s)" % [tileset_path, loaded_type]
)
var source_id: int = int(params.get("source_id", -999))
if source_id < 0 or not ts.has_source(source_id):
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"source_id %d does not exist in TileSet" % source_id
)
var src = ts.get_source(source_id)
if not src is TileSetAtlasSource:
var source_type: String = "null" if src == null else src.get_class()
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Source %d is not a TileSetAtlasSource (got %s)" % [source_id, source_type]
)
return {
"source_id": source_id,
"src": src,
}
@@ -0,0 +1 @@
uid://de3v4m1pnk7tr
+525
View File
@@ -0,0 +1,525 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles UI-specific (Control) layout helpers: anchor presets, etc.
##
## Anchors/offsets are the worst part of Control layout to set one-property-at-a-time.
## This handler wraps Godot's built-in presets (FULL_RECT, CENTER, TOP_LEFT, ...) so
## callers can set a whole layout with one command, with proper undo.
var _undo_redo: EditorUndoRedoManager
const _PRESETS := {
"top_left": Control.PRESET_TOP_LEFT,
"top_right": Control.PRESET_TOP_RIGHT,
"bottom_left": Control.PRESET_BOTTOM_LEFT,
"bottom_right": Control.PRESET_BOTTOM_RIGHT,
"center_left": Control.PRESET_CENTER_LEFT,
"center_top": Control.PRESET_CENTER_TOP,
"center_right": Control.PRESET_CENTER_RIGHT,
"center_bottom": Control.PRESET_CENTER_BOTTOM,
"center": Control.PRESET_CENTER,
"left_wide": Control.PRESET_LEFT_WIDE,
"top_wide": Control.PRESET_TOP_WIDE,
"right_wide": Control.PRESET_RIGHT_WIDE,
"bottom_wide": Control.PRESET_BOTTOM_WIDE,
"vcenter_wide": Control.PRESET_VCENTER_WIDE,
"hcenter_wide": Control.PRESET_HCENTER_WIDE,
"full_rect": Control.PRESET_FULL_RECT,
}
const _RESIZE_MODES := {
"minsize": Control.PRESET_MODE_MINSIZE,
"keep_width": Control.PRESET_MODE_KEEP_WIDTH,
"keep_height": Control.PRESET_MODE_KEEP_HEIGHT,
"keep_size": Control.PRESET_MODE_KEEP_SIZE,
}
const _ANCHOR_OFFSET_PROPS := [
"anchor_left", "anchor_top", "anchor_right", "anchor_bottom",
"offset_left", "offset_top", "offset_right", "offset_bottom",
]
func _init(undo_redo: EditorUndoRedoManager) -> void:
_undo_redo = undo_redo
## Apply a Control layout preset (anchors + offsets) to a UI node.
##
## Params:
## path - scene path to a Control node (required)
## preset - preset name: full_rect, center, top_left, ... (required)
## resize_mode - minsize | keep_width | keep_height | keep_size (default: minsize)
## margin - integer margin in pixels from the anchor edges (default: 0)
func set_anchor_preset(params: Dictionary) -> Dictionary:
var node_path: String = params.get("path", "")
if node_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
var preset_name: String = str(params.get("preset", "")).to_lower()
if preset_name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: preset")
if not _PRESETS.has(preset_name):
var names := _PRESETS.keys()
names.sort()
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Unknown preset '%s'. Valid: %s" % [preset_name, ", ".join(names)]
)
var resize_mode_name: String = str(params.get("resize_mode", "minsize")).to_lower()
if not _RESIZE_MODES.has(resize_mode_name):
var names := _RESIZE_MODES.keys()
names.sort()
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Unknown resize_mode '%s'. Valid: %s" % [resize_mode_name, ", ".join(names)]
)
var margin: int = int(params.get("margin", 0))
var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
if _resolved.has("error"):
return _resolved
var node: Node = _resolved.node
var scene_root: Node = _resolved.scene_root
if not node is Control:
var got_class: String = node.get_class()
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Node %s is not a Control (got %s)%s" % [
node_path, got_class, _canvas_layer_overlay_hint(got_class)
]
)
var control := node as Control
var preset_value: int = _PRESETS[preset_name]
var resize_mode_value: int = _RESIZE_MODES[resize_mode_name]
# Snapshot before so we can undo every property the preset may have touched.
var before: Dictionary = {}
for prop in _ANCHOR_OFFSET_PROPS:
before[prop] = control.get(prop)
_undo_redo.create_action("MCP: Set %s anchor preset %s" % [control.name, preset_name])
_undo_redo.add_do_method(
control, "set_anchors_and_offsets_preset", preset_value, resize_mode_value, margin
)
for prop in _ANCHOR_OFFSET_PROPS:
_undo_redo.add_undo_property(control, prop, before[prop])
_undo_redo.commit_action()
var after: Dictionary = {}
for prop in _ANCHOR_OFFSET_PROPS:
after[prop] = control.get(prop)
return {
"data": {
"path": node_path,
"preset": preset_name,
"resize_mode": resize_mode_name,
"margin": margin,
"anchors": {
"left": after.anchor_left,
"top": after.anchor_top,
"right": after.anchor_right,
"bottom": after.anchor_bottom,
},
"offsets": {
"left": after.offset_left,
"top": after.offset_top,
"right": after.offset_right,
"bottom": after.offset_bottom,
},
"undoable": true,
}
}
## Set the visible `text` property on a UI Control (Label, Button + subclasses,
## LineEdit, TextEdit, RichTextLabel, LinkButton). Undoable.
func set_text(params: Dictionary) -> Dictionary:
var node_path: String = params.get("path", "")
if node_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
if not params.has("text"):
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: text")
var text_value: Variant = params["text"]
if typeof(text_value) != TYPE_STRING:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "text must be a string")
var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
if _resolved.has("error"):
return _resolved
var node: Node = _resolved.node
var scene_root: Node = _resolved.scene_root
var node_type := node.get_class()
if not node is Control:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Node %s is not a Control (got %s)" % [node_path, node_type]
)
# Scan get_property_list() (matches set_property / _apply_property in this
# repo) so we can both confirm `text` exists and that it's actually a String
# — guards against a custom Control whose `text` happens to be some other
# type, where set()-ing a String would silently mis-coerce.
var text_prop_type := TYPE_NIL
var has_text := false
for prop in node.get_property_list():
if prop.get("name", "") == "text":
has_text = true
text_prop_type = prop.get("type", TYPE_NIL)
break
if not has_text:
return ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Control %s has no 'text' property (got %s)" % [node_path, node_type]
)
if text_prop_type != TYPE_STRING:
return ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Control %s has a non-string 'text' property (got %s)" % [node_path, node_type]
)
var old_value: String = node.get("text")
_undo_redo.create_action("MCP: Set %s text" % node.name)
_undo_redo.add_do_property(node, "text", text_value)
_undo_redo.add_undo_property(node, "text", old_value)
_undo_redo.commit_action()
return {
"data": {
"path": node_path,
"text": text_value,
"old_text": old_value,
"node_type": node_type,
"undoable": true,
}
}
# ============================================================================
# build_layout — declarative nested-dict → Control tree in one undo action
# ============================================================================
## Build a tree of Control nodes atomically.
##
## Params:
## tree - Dictionary describing the root node. Required fields: "type".
## Optional: "name", "properties" (dict), "anchor_preset",
## "anchor_margin", "theme" (res://, uid:// or user:// path), "children" (array).
## parent_path - Parent scene path. Empty or "/" = scene root.
##
## Validation is done before any scene mutation: class names, property
## existence, and res:// paths are all checked up-front. If anything is
## invalid, no node is created.
func build_layout(params: Dictionary) -> Dictionary:
var tree = params.get("tree")
if not params.has("tree"):
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: tree")
if typeof(tree) != TYPE_DICTIONARY:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "tree must be a dictionary")
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 parent_path: String = params.get("parent_path", "")
var parent: Node = scene_root
if not parent_path.is_empty() and parent_path != "/":
parent = McpScenePath.resolve(parent_path, scene_root)
if parent == null:
return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root))
# Validate + build in memory first; if anything fails, free and bail.
var built := _build_subtree(tree)
if built.has("error"):
return built
var root_node: Node = built.node
var created: Array[Node] = built.created
_undo_redo.create_action("MCP: Build UI layout (%d nodes)" % created.size())
_undo_redo.add_do_method(parent, "add_child", root_node, true)
_undo_redo.add_do_method(root_node, "set_owner", scene_root)
for n in created:
_undo_redo.add_do_method(n, "set_owner", scene_root)
_undo_redo.add_do_reference(n)
_undo_redo.add_undo_method(parent, "remove_child", root_node)
_undo_redo.commit_action()
return {
"data": {
"root_path": McpScenePath.from_node(root_node, scene_root),
"node_count": created.size(),
"undoable": true,
}
}
## Recursively instantiate + configure a node and its children in memory.
## Returns {"node": root, "created": [all descendants incl. root]} or {"error": ...}.
func _build_subtree(spec: Dictionary) -> Dictionary:
var node_type: String = spec.get("type", "")
if node_type.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Every layout node requires a 'type'")
if not ClassDB.class_exists(node_type):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown type: %s" % node_type)
if not ClassDB.is_parent_class(node_type, "Node"):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Node type" % node_type)
var node: Node = ClassDB.instantiate(node_type)
if node == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % node_type)
var node_name: String = spec.get("name", "")
if not node_name.is_empty():
node.name = node_name
# Properties.
if spec.has("properties"):
var props = spec.get("properties")
if typeof(props) != TYPE_DICTIONARY:
node.free()
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "properties must be a dictionary")
for key in props:
var value = props[key]
var apply_err := _apply_property(node, str(key), value)
if apply_err != null:
node.free()
return apply_err
# Theme (res:// / uid:// / user:// path -> Resource).
if spec.has("theme"):
var theme_path: String = str(spec.get("theme", ""))
if not theme_path.is_empty():
var theme_path_err = McpPathValidator.loadable_error(theme_path, "theme")
if theme_path_err != null:
node.free()
return theme_path_err
if not ResourceLoader.exists(theme_path):
node.free()
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Theme not found: %s" % theme_path)
var theme_res: Resource = ResourceLoader.load(theme_path)
if theme_res == null or not theme_res is Theme:
node.free()
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "theme path must point to a Theme resource: %s" % theme_path)
if not node is Control and not node is Window:
node.free()
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"theme can only be set on Control / Window (got %s)%s" % [
node_type, _canvas_layer_overlay_hint(node_type)
]
)
node.theme = theme_res as Theme
# Anchor preset — applied before children so children inherit sensible anchors.
if spec.has("anchor_preset"):
var preset_name: String = str(spec.get("anchor_preset", "")).to_lower()
if not _PRESETS.has(preset_name):
node.free()
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown anchor_preset: %s" % preset_name)
if not node is Control:
node.free()
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"anchor_preset requires a Control (got %s)%s" % [
node_type, _canvas_layer_overlay_hint(node_type)
]
)
var preset_value: int = _PRESETS[preset_name]
var margin: int = int(spec.get("anchor_margin", 0))
(node as Control).set_anchors_and_offsets_preset(preset_value, Control.PRESET_MODE_MINSIZE, margin)
var created: Array[Node] = [node]
if spec.has("children"):
var children = spec.get("children")
if typeof(children) != TYPE_ARRAY:
node.free()
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "children must be an array")
for child_spec in children:
if typeof(child_spec) != TYPE_DICTIONARY:
node.free()
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "each child must be a dictionary")
var child_result := _build_subtree(child_spec)
if child_result.has("error"):
node.free()
return child_result
var child_node: Node = child_result.node
node.add_child(child_node)
for n in child_result.created:
created.append(n)
return {"node": node, "created": created}
## Mapping from theme_override_* property prefixes to their add/remove methods.
const _THEME_OVERRIDE_MAP := {
"theme_override_colors/": {
"add": "add_theme_color_override",
"remove": "remove_theme_color_override",
"coerce_type": TYPE_COLOR,
},
"theme_override_constants/": {
"add": "add_theme_constant_override",
"remove": "remove_theme_constant_override",
"coerce_type": TYPE_INT,
},
"theme_override_font_sizes/": {
"add": "add_theme_font_size_override",
"remove": "remove_theme_font_size_override",
"coerce_type": TYPE_INT,
},
"theme_override_styles/": {
"add": "add_theme_stylebox_override",
"remove": "remove_theme_stylebox_override",
"coerce_type": TYPE_OBJECT,
},
}
## Apply a property to a newly-instantiated node. Handles Color/Vector2/NodePath
## coercion from JSON-friendly forms. Returns null on success, error dict on failure.
func _apply_property(node: Node, prop: String, value: Variant) -> Variant:
# Handle theme_override_* pseudo-properties before the regular property scan.
for prefix in _THEME_OVERRIDE_MAP:
if prop.begins_with(prefix):
if not node is Control:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"theme_override_* requires a Control node (got %s)" % node.get_class()
)
var override_name := prop.substr(prefix.length())
var info: Dictionary = _THEME_OVERRIDE_MAP[prefix]
var coerce_type: int = info.coerce_type
# For stylebox overrides, load from a res:// / uid:// / user:// path.
if coerce_type == TYPE_OBJECT:
if value is String and (value.begins_with("res://") or value.begins_with("uid://") or value.begins_with("user://")):
var style_path_err = McpPathValidator.loadable_error(value, "stylebox")
if style_path_err != null:
return style_path_err
var res := ResourceLoader.load(value)
if res == null or not res is StyleBox:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Style resource not found or not a StyleBox: %s" % value
)
node.call(info.add, override_name, res)
else:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"theme_override_styles/ expects a res:// / uid:// / user:// path to a StyleBox"
)
else:
var coercion := _coerce_for_type(value, coerce_type)
if not coercion.ok:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Cannot coerce '%s' for %s" % [value, prop]
)
node.call(info.add, override_name, coercion.value)
return null
var found := false
var prop_type := TYPE_NIL
for p in node.get_property_list():
if p.name == prop:
found = true
prop_type = p.get("type", TYPE_NIL)
break
if not found:
return ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
McpPropertyErrors.build_message(node, prop)
)
var coercion := _coerce_for_type(value, prop_type)
if not coercion.ok:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Property '%s' on %s expects type %s (cannot coerce %s)" % [
prop, node.get_class(), type_string(prop_type), value
]
)
node.set(prop, coercion.value)
return null
## Coerce a JSON-friendly value to the target Godot type. Returns
## {"ok": true, "value": coerced} on success, {"ok": false} on failure.
## For types we don't explicitly coerce, the value is returned as-is
## (Godot will typecheck at set() time and fail loudly if it disagrees).
static func _coerce_for_type(value: Variant, prop_type: int) -> Dictionary:
match prop_type:
TYPE_COLOR:
## Canonical parser (#714): adds [r,g,b(,a)] array support and
## strict key/component checking, same shapes everywhere.
var parsed_color = McpJsonValues.parse_color(value)
if parsed_color != null:
return {"ok": true, "value": parsed_color}
return {"ok": false}
TYPE_VECTOR2:
## Same canonical parser as TYPE_COLOR (CodeRabbit review):
## keeping the inline copy here would re-introduce exactly the
## permissive-vs-strict drift this PR removes elsewhere.
var parsed_v2 = McpJsonValues.parse_vector2(value)
if parsed_v2 != null:
return {"ok": true, "value": parsed_v2}
return {"ok": false}
TYPE_VECTOR2I:
if value is Vector2i:
return {"ok": true, "value": value}
if value is Dictionary and value.has("x") and value.has("y"):
return {"ok": true, "value": Vector2i(int(value.x), int(value.y))}
if value is Array and value.size() == 2:
return {"ok": true, "value": Vector2i(int(value[0]), int(value[1]))}
return {"ok": false}
TYPE_RECT2:
if value is Rect2:
return {"ok": true, "value": value}
if value is Array and value.size() == 4:
return {
"ok": true,
"value":
Rect2(float(value[0]), float(value[1]), float(value[2]), float(value[3])),
}
if value is Dictionary:
if value.has("x") and value.has("y") and value.has("w") and value.has("h"):
return {
"ok": true,
"value":
Rect2(float(value.x), float(value.y), float(value.w), float(value.h)),
}
if value.has("position") and value.has("size"):
var pos := _coerce_for_type(value.position, TYPE_VECTOR2)
var sz := _coerce_for_type(value.size, TYPE_VECTOR2)
if pos.ok and sz.ok:
return {"ok": true, "value": Rect2(pos.value, sz.value)}
return {"ok": false}
TYPE_NODE_PATH:
if value is NodePath:
return {"ok": true, "value": value}
if value is String:
return {"ok": true, "value": NodePath(value)}
return {"ok": false}
return {"ok": true, "value": value}
# CanvasLayer is the canonical HUD parent but isn't a Control, so applying
# Control-only properties (theme, anchor_preset) to it is a common mistake.
# The recovery shape is always the same: nest a Control child under the layer.
static func _canvas_layer_overlay_hint(node_class: String) -> String:
if node_class != "CanvasLayer":
return ""
return (
". CanvasLayer is not a Control — add a Control (e.g. Panel or Control "
+ "with anchor_preset=full_rect) as its child and apply theme / "
+ "anchor_preset to that overlay."
)
@@ -0,0 +1 @@
uid://ckm6f1objpgvw