Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions docs/implementation-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,9 @@ Historical bootstrap material, architecture detail, packaging mechanics, go/no-g
### High-Leverage Authoring

- [x] `batch.execute` with stop-on-first-error semantics and optional grouped undo
- [ ] `node.rename` with UID/reference awareness where feasible
- [ ] complex `node.set_property` (`Resource`, `NodePath`, `Array`, `Dictionary`)
- [ ] `script.patch` shipped or explicitly ruled out after a focused spike
- [x] `node.rename` with sibling-collision validation and char-safety checks (NodePath/script references in OTHER nodes are not auto-updated — documented in the tool)
- [x] complex `node.set_property` (`Resource` via res:// path, `NodePath`, `Array`, `Dictionary`, `StringName`)
- [x] `script.patch` shipped — anchor-based `old_text` → `new_text` replace with ambiguity detection and optional `replace_all`

**Why this matters:** These are workflow multipliers. They matter more for real project iteration than adding another narrow read tool.

Expand All @@ -77,8 +77,8 @@ Historical bootstrap material, architecture detail, packaging mechanics, go/no-g
- [x] run/stop cycle is reliable
- [x] batch execution is shipped with a clear contract
- [ ] multi-instance routing works in practice
- [ ] `script.patch` decision is made
- [x] test coverage and smoke coverage increase where the new runtime loop needs it (277 Python + 191 GDScript = 468 total)
- [x] `script.patch` decision is made (shipped: anchor-based replace)
- [x] test coverage and smoke coverage increase where the new runtime loop needs it (282 Python + 216 GDScript = 498 total)

---

Expand Down
101 changes: 99 additions & 2 deletions plugin/addons/godot_ai/handlers/node_handler.gd
Original file line number Diff line number Diff line change
Expand Up @@ -157,17 +157,31 @@ func set_property(params: Dictionary) -> Dictionary:

var value = params.get("value")

# Verify property exists on the node
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 McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Property '%s' not found on %s" % [property, node.get_class()])

var old_value = node.get(property)
value = _coerce_value(value, typeof(old_value))
# Prefer declared property type; fall back to runtime type for dynamic props
# (scripted @export vars can report TYPE_NIL in the property list).
var target_type: int = prop_type if prop_type != TYPE_NIL else typeof(old_value)

if target_type == TYPE_OBJECT and value is String:
if value == "":
value = null
else:
var loaded := ResourceLoader.load(value)
if loaded == null:
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Resource not found: %s" % value)
value = loaded
else:
value = _coerce_value(value, target_type)

_undo_redo.create_action("MCP: Set %s.%s" % [node.name, property])
_undo_redo.add_do_property(node, property, value)
Expand All @@ -185,6 +199,58 @@ func set_property(params: Dictionary) -> Dictionary:
}


func rename_node(params: Dictionary) -> Dictionary:
var resolved := _resolve_node(params)
if resolved.has("error"):
return resolved
var node: Node = resolved.node
var node_path: String = resolved.path
var scene_root: Node = resolved.scene_root

var new_name: String = params.get("new_name", "")
if new_name.is_empty():
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Missing required param: new_name")

if new_name.validate_node_name() != new_name:
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Invalid characters in name: %s" % new_name)

if node == scene_root:
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Cannot rename the scene root")

var old_name := String(node.name)
if old_name == new_name:
return {
"data": {
"path": node_path,
"name": new_name,
"old_name": old_name,
"unchanged": true,
"undoable": false,
"reason": "Name unchanged",
}
}

var parent := node.get_parent()
for sibling in parent.get_children():
if sibling != node and String(sibling.name) == new_name:
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "A sibling already has the name '%s'" % new_name)

_undo_redo.create_action("MCP: Rename %s to %s" % [old_name, new_name])
_undo_redo.add_do_property(node, "name", new_name)
_undo_redo.add_undo_property(node, "name", old_name)
_undo_redo.commit_action()

return {
"data": {
"path": ScenePath.from_node(node, scene_root),
"old_path": node_path,
"name": String(node.name),
"old_name": old_name,
"undoable": true,
}
}


func duplicate_node(params: Dictionary) -> Dictionary:
var resolved := _resolve_node(params)
if resolved.has("error"):
Expand Down Expand Up @@ -380,6 +446,25 @@ static func _coerce_value(value: Variant, target_type: int) -> Variant:
TYPE_FLOAT:
if value is int:
return float(value)
TYPE_STRING_NAME:
if value is String:
return StringName(value)
TYPE_NODE_PATH:
if value is String:
return NodePath(value)
if value == null:
return NodePath()
TYPE_OBJECT:
# Resource loading is handled in set_property so we can return a
# typed error; here we only pass through cleared values.
if value == null:
return null
TYPE_ARRAY:
if value is Array:
return value
TYPE_DICTIONARY:
if value is Dictionary:
return value
return value


Expand Down Expand Up @@ -484,6 +569,8 @@ static func _serialize_value(value: Variant) -> Variant:
match typeof(value):
TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING:
return value
TYPE_STRING_NAME:
return str(value)
TYPE_VECTOR2:
return {"x": value.x, "y": value.y}
TYPE_VECTOR3:
Expand All @@ -496,6 +583,16 @@ static func _serialize_value(value: Variant) -> Variant:
return str(value)
TYPE_NODE_PATH:
return str(value)
TYPE_ARRAY:
var arr: Array = []
for item in value:
arr.append(_serialize_value(item))
return arr
TYPE_DICTIONARY:
var out := {}
for k in value:
out[str(k)] = _serialize_value(value[k])
return out
TYPE_OBJECT:
if value is Resource and value.resource_path:
return value.resource_path
Expand Down
60 changes: 60 additions & 0 deletions plugin/addons/godot_ai/handlers/script_handler.gd
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,66 @@ func read_script(params: Dictionary) -> Dictionary:
}


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)

if path.is_empty():
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Missing required param: path")
if not path.begins_with("res://"):
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Path must start with res://")
if not "new_text" in params:
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Missing required param: new_text")

Copilot AI Apr 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

patch_script checks for missing new_text but not missing old_text. If the caller omits old_text, the handler returns old_text must not be empty rather than a consistent Missing required param: old_text, and it also prevents explicitly passing an empty string (even if that might be useful for no-op validation). Consider adding an explicit "old_text" in params check (mirroring new_text) and keep the non-empty constraint as a separate validation.

Suggested change
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Missing required param: new_text")
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Missing required param: new_text")
if not "old_text" in params:
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Missing required param: old_text")

Copilot uses AI. Check for mistakes.
if old_text.is_empty():
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "old_text must not be empty")

Comment on lines +89 to +101

Copilot AI Apr 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

patch_script allows patching any res:// file type, but the tool/docstring frames this as a GDScript (.gd) operation. Unlike create_script (which enforces .gd), this can accidentally rewrite non-text/binary assets (e.g., .tscn/.tres) as UTF-8 text and corrupt them. Add the same .gd extension validation (or otherwise restrict to safe text/script extensions) before reading/writing.

Copilot uses AI. Check for mistakes.
var read := FileAccess.open(path, FileAccess.READ)
if read == null:
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "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 McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "old_text not found in %s" % path)
if match_count > 1 and not replace_all:
return McpErrorCodes.make(
McpErrorCodes.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

var write := FileAccess.open(path, FileAccess.WRITE)
if write == null:
return McpErrorCodes.make(McpErrorCodes.INTERNAL_ERROR, "Failed to open file for writing: %s" % path)
write.store_string(new_content)
write.close()

EditorInterface.get_resource_filesystem().scan()

return {
"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",
}
}


func attach_script(params: Dictionary) -> Dictionary:
var node_path: String = params.get("path", "")
var script_path: String = params.get("script_path", "")
Expand Down
2 changes: 2 additions & 0 deletions plugin/addons/godot_ai/plugin.gd
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ func _enter_tree() -> void:
_dispatcher.register("delete_node", node_handler.delete_node)
_dispatcher.register("reparent_node", node_handler.reparent_node)
_dispatcher.register("set_property", node_handler.set_property)
_dispatcher.register("rename_node", node_handler.rename_node)
_dispatcher.register("duplicate_node", node_handler.duplicate_node)
_dispatcher.register("move_node", node_handler.move_node)
_dispatcher.register("add_to_group", node_handler.add_to_group)
Expand All @@ -68,6 +69,7 @@ func _enter_tree() -> void:
_dispatcher.register("configure_client", client_handler.configure_client)
_dispatcher.register("check_client_status", client_handler.check_client_status)
_dispatcher.register("create_script", script_handler.create_script)
_dispatcher.register("patch_script", script_handler.patch_script)
_dispatcher.register("read_script", script_handler.read_script)
_dispatcher.register("attach_script", script_handler.attach_script)
_dispatcher.register("detach_script", script_handler.detach_script)
Expand Down
8 changes: 8 additions & 0 deletions src/godot_ai/handlers/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ async def node_set_property(runtime: Runtime, path: str, property: str, value) -
)


async def node_rename(runtime: Runtime, path: str, new_name: str) -> dict:
require_writable(runtime)
return await runtime.send_command(
"rename_node",
{"path": path, "new_name": new_name},
)


async def node_duplicate(runtime: Runtime, path: str, name: str = "") -> dict:
require_writable(runtime)
return await runtime.send_command(
Expand Down
19 changes: 19 additions & 0 deletions src/godot_ai/handlers/script.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,25 @@ async def script_create(runtime: Runtime, path: str, content: str = "") -> dict:
)


async def script_patch(
runtime: Runtime,
path: str,
old_text: str,
new_text: str,
replace_all: bool = False,
) -> dict:
require_writable(runtime)
return await runtime.send_command(
"patch_script",
{
"path": path,
"old_text": old_text,
"new_text": new_text,
"replace_all": replace_all,
},
)


async def script_read(runtime: Runtime, path: str) -> dict:
return await runtime.send_command("read_script", {"path": path})

Expand Down
42 changes: 36 additions & 6 deletions src/godot_ai/tools/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,24 +141,54 @@ async def node_set_property(
ctx: Context,
path: str,
property: str,
value: str | int | float | bool | dict | list,
value: str | int | float | bool | dict | list | None,
) -> dict:
"""Set a property on a node.

Sets a simple property value. For Vector2/Vector3, pass a dict
with x/y/z keys. For Color, pass a dict with r/g/b/a keys or
a hex string like "#ff0000".
Coerces `value` to match the property's declared type:

- Vector2/Vector3: dict with x/y/z keys
- Color: dict with r/g/b/a keys, or hex string ("#ff0000")
- NodePath: string ("../Other/Node")
- Resource: res:// path string (loads and assigns), or null to clear
- StringName: plain string
- Array/Dictionary: pass a JSON list/object
- bool/int/float: JSON primitives

Args:
path: Scene path of the node (e.g. "/Main/Camera3D").
property: Property name (e.g. "fov", "position", "visible").
value: New value for the property.
property: Property name (e.g. "fov", "position", "visible", "mesh", "remote_path").
value: New value for the property. Pass null to clear a Resource/NodePath.

Copilot AI Apr 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The node_set_property docstring says Resources can be cleared with null only, but the Godot-side implementation also treats an empty string ("") as a signal to clear Resource properties. Either document the empty-string behavior here (since clients may rely on it) or remove that behavior for a single, consistent clearing mechanism.

Copilot uses AI. Check for mistakes.
"""
runtime = DirectRuntime.from_context(ctx)
return await node_handlers.node_set_property(
runtime, path=path, property=property, value=value,
)

@mcp.tool(meta=DEFER_META)
async def node_rename(
ctx: Context,
path: str,
new_name: str,
) -> dict:
"""Rename a node in the scene tree.

Changes the node's `name`. Fails if a sibling already has that name,
or if the name contains `/`, `:`, or `@`. Cannot rename the scene root.

Note: `NodePath` properties on OTHER nodes that pointed at this node
(e.g. a camera's `remote_path`) will not be auto-updated. Scripts that
reference this node by name (`$OldName`, `get_node("OldName")`) also
need manual fixes. Children of the renamed node keep working because
their paths are relative.

Args:
path: Scene path of the node to rename (e.g. "/Main/Player").
new_name: New name for the node (e.g. "Hero").
"""
runtime = DirectRuntime.from_context(ctx)
return await node_handlers.node_rename(runtime, path=path, new_name=new_name)

@mcp.tool(meta=DEFER_META)
async def node_duplicate(
ctx: Context,
Expand Down
37 changes: 37 additions & 0 deletions src/godot_ai/tools/script.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,43 @@ async def script_create(
runtime = DirectRuntime.from_context(ctx)
return await script_handlers.script_create(runtime, path=path, content=content)

@mcp.tool(meta=DEFER_META)
async def script_patch(
ctx: Context,
path: str,
old_text: str,
new_text: str,
replace_all: bool = False,
) -> dict:
"""Patch (partial edit / string-replace) a GDScript file in place.

Anchor-based edit: finds an exact occurrence of `old_text` in the file
and replaces it with `new_text`. Use this instead of `script_create`
when you only need to change a function, add a signal, or fix a line —
it avoids rewriting (and possibly losing) the rest of the file.

If `old_text` matches multiple places, the call fails unless
`replace_all=true` is passed. If it matches zero places, the call
fails. Exact byte match — whitespace is significant.

Triggers a filesystem scan so the editor picks up the edit. Not
undoable via Ctrl+Z (filesystem edits bypass editor undo).

Args:
path: File path starting with res:// (e.g. "res://scripts/player.gd").
old_text: Exact substring to find. Must be unique unless replace_all=true.
new_text: Replacement text. Can be empty to delete.
replace_all: If true, replace every occurrence. Default false.
"""
runtime = DirectRuntime.from_context(ctx)
return await script_handlers.script_patch(
runtime,
path=path,
old_text=old_text,
new_text=new_text,
replace_all=replace_all,
)

@mcp.tool(meta=DEFER_META)
async def script_read(ctx: Context, path: str) -> dict:
"""Read the contents of a GDScript file.
Expand Down
Loading
Loading