新增godot mcp github copilot

This commit is contained in:
2026-05-28 21:00:39 +08:00
parent a550a2675e
commit 18e24d40f0
242 changed files with 33640 additions and 45 deletions

View File

@@ -0,0 +1,518 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
func get_commands() -> Dictionary:
return {
"find_unused_resources": _find_unused_resources,
"analyze_signal_flow": _analyze_signal_flow,
"analyze_scene_complexity": _analyze_scene_complexity,
"find_script_references": _find_script_references,
"detect_circular_dependencies": _detect_circular_dependencies,
"get_project_statistics": _get_project_statistics,
}
# =============================================================================
# find_unused_resources
# =============================================================================
## Scan project for resources not referenced by any .tscn, .gd, or .tres file.
func _find_unused_resources(params: Dictionary) -> Dictionary:
var path: String = optional_string(params, "path", "res://")
var include_addons: bool = optional_bool(params, "include_addons", false)
# Step 1: Collect all resource files
var resource_extensions: Array = ["tres", "tscn", "png", "jpg", "jpeg", "svg",
"wav", "ogg", "mp3", "ttf", "otf", "gdshader", "material",
"theme", "stylebox", "font", "anim"]
var all_resources: Array = []
_collect_files_by_ext(path, resource_extensions, all_resources, include_addons)
# Step 2: Collect all referencing files (.tscn, .gd, .tres)
var ref_extensions: Array = ["tscn", "gd", "tres", "cfg", "godot"]
var ref_files: Array = []
_collect_files_by_ext(path, ref_extensions, ref_files, include_addons)
# Step 3: Build a set of all referenced paths
var referenced: Dictionary = {} # path -> true
for ref_file in ref_files:
var content := _read_file_text(ref_file as String)
if content.is_empty():
continue
# Find res:// paths in file content
var idx := 0
while idx < content.length():
var found := content.find("res://", idx)
if found == -1:
break
# Extract the path (up to quote, space, or end of line)
var end := found + 6
while end < content.length():
var c := content[end]
if c == '"' or c == "'" or c == ' ' or c == '\n' or c == '\r' or c == ')' or c == ']' or c == '}':
break
end += 1
var ref_path := content.substr(found, end - found)
referenced[ref_path] = true
idx = end
# Step 4: Find unreferenced resources
var unused: Array = []
for res_path in all_resources:
var p: String = res_path
if not referenced.has(p):
# Also check without uid:// prefix variants — some references use uid
unused.append(p)
return success({
"unused_resources": unused,
"unused_count": unused.size(),
"total_resources_scanned": all_resources.size(),
"total_files_checked": ref_files.size(),
})
# =============================================================================
# analyze_signal_flow
# =============================================================================
## Map all signal connections in the currently edited scene.
func _analyze_signal_flow(params: Dictionary) -> Dictionary:
var root := get_edited_root()
if root == null:
return error_no_scene()
var nodes_data: Array = []
_collect_signal_data(root, root, nodes_data)
return success({
"scene": root.scene_file_path,
"nodes": nodes_data,
"total_nodes": nodes_data.size(),
})
func _collect_signal_data(node: Node, root: Node, out: Array) -> void:
var node_path := str(root.get_path_to(node))
var signals_emitted: Array = []
var signals_connected_to: Array = []
# Get all signals this node defines
for sig in node.get_signal_list():
var sig_name: String = sig["name"]
var connections := node.get_signal_connection_list(sig_name)
if connections.size() > 0:
var targets: Array = []
for conn in connections:
var callable: Callable = conn["callable"]
var target_node: Node = callable.get_object() as Node
var target_path := ""
if target_node != null:
target_path = str(root.get_path_to(target_node))
targets.append({
"target_node": target_path,
"method": callable.get_method(),
})
# Also record on the target side
signals_connected_to.append({
"from_node": node_path,
"signal": sig_name,
"method": callable.get_method(),
})
signals_emitted.append({
"signal": sig_name,
"targets": targets,
})
# Only include nodes that have signal activity
if signals_emitted.size() > 0 or signals_connected_to.size() > 0:
out.append({
"name": node.name,
"path": node_path,
"type": node.get_class(),
"signals_emitted": signals_emitted,
"signals_connected_to": signals_connected_to,
})
for child in node.get_children():
_collect_signal_data(child, root, out)
# =============================================================================
# analyze_scene_complexity
# =============================================================================
## Analyze a scene's complexity: node count, depth, types, scripts, potential issues.
func _analyze_scene_complexity(params: Dictionary) -> Dictionary:
var scene_path: String = optional_string(params, "path", "")
var root: Node = null
if scene_path.is_empty():
root = get_edited_root()
if root == null:
return error_no_scene()
scene_path = root.scene_file_path
else:
if not ResourceLoader.exists(scene_path):
return error_not_found("Scene '%s'" % scene_path)
var packed := ResourceLoader.load(scene_path) as PackedScene
if packed == null:
return error_internal("Failed to load scene: %s" % scene_path)
root = packed.instantiate()
var total_nodes := 0
var max_depth := 0
var types: Dictionary = {} # class_name -> count
var scripts_attached: Array = []
var resources_used: Dictionary = {} # resource path -> count
var issues: Array = []
_analyze_node(root, root, 0, total_nodes, max_depth, types, scripts_attached, resources_used)
# Count totals from recursive walk
total_nodes = _count_nodes_recursive(root)
max_depth = _get_max_depth(root, 0)
# Detect potential issues
if total_nodes > 1000:
issues.append({"severity": "warning", "message": "Scene has %d nodes (>1000). Consider splitting into sub-scenes." % total_nodes})
elif total_nodes > 500:
issues.append({"severity": "info", "message": "Scene has %d nodes (>500). Monitor performance." % total_nodes})
if max_depth > 15:
issues.append({"severity": "warning", "message": "Max nesting depth is %d (>15). Deep hierarchies can be hard to maintain." % max_depth})
elif max_depth > 10:
issues.append({"severity": "info", "message": "Max nesting depth is %d (>10)." % max_depth})
# If we instantiated the scene ourselves, free it
if not scene_path.is_empty() and root != get_edited_root():
root.queue_free()
return success({
"scene_path": scene_path,
"total_nodes": total_nodes,
"max_depth": max_depth,
"nodes_by_type": types,
"scripts_attached": scripts_attached,
"unique_resource_count": resources_used.size(),
"issues": issues,
})
func _analyze_node(node: Node, root: Node, depth: int,
total_nodes: int, max_depth: int,
types: Dictionary, scripts: Array, resources: Dictionary) -> void:
var type_name := node.get_class()
types[type_name] = types.get(type_name, 0) + 1
if node.get_script() != null:
var script: Script = node.get_script()
var script_path := script.resource_path
if not script_path.is_empty():
scripts.append({
"node": str(root.get_path_to(node)),
"script": script_path,
})
for child in node.get_children():
_analyze_node(child, root, depth + 1, total_nodes, max_depth, types, scripts, resources)
func _count_nodes_recursive(node: Node) -> int:
var count := 1
for child in node.get_children():
count += _count_nodes_recursive(child)
return count
func _get_max_depth(node: Node, current_depth: int) -> int:
var max_d := current_depth
for child in node.get_children():
var child_depth := _get_max_depth(child, current_depth + 1)
if child_depth > max_d:
max_d = child_depth
return max_d
# =============================================================================
# find_script_references
# =============================================================================
## Find all places where a given script, class_name, or resource path is used.
func _find_script_references(params: Dictionary) -> Dictionary:
var result := require_string(params, "query")
if result[1] != null:
return result[1]
var query: String = result[0]
var path: String = optional_string(params, "path", "res://")
var include_addons: bool = optional_bool(params, "include_addons", false)
var search_extensions: Array = ["tscn", "gd", "tres", "cfg", "godot"]
var search_files: Array = []
_collect_files_by_ext(path, search_extensions, search_files, include_addons)
var references: Array = []
for file_path in search_files:
var fp: String = file_path
var content := _read_file_text(fp)
if content.is_empty():
continue
var lines := content.split("\n")
var line_num := 0
for line in lines:
line_num += 1
var l: String = line
if l.contains(query):
references.append({
"file": fp,
"line": line_num,
"content": l.strip_edges(),
})
return success({
"query": query,
"references": references,
"reference_count": references.size(),
"files_searched": search_files.size(),
})
# =============================================================================
# detect_circular_dependencies
# =============================================================================
## Check for circular scene dependencies (.tscn files referencing each other).
func _detect_circular_dependencies(params: Dictionary) -> Dictionary:
var path: String = optional_string(params, "path", "res://")
var include_addons: bool = optional_bool(params, "include_addons", false)
# Step 1: Collect all .tscn files
var tscn_files: Array = []
_collect_files_by_ext(path, ["tscn"], tscn_files, include_addons)
# Step 2: Build dependency graph: scene_path -> [referenced_scene_paths]
var dep_graph: Dictionary = {} # String -> Array[String]
for tscn_path in tscn_files:
var tp: String = tscn_path
var content := _read_file_text(tp)
if content.is_empty():
continue
var deps: Array = []
for line in content.split("\n"):
var l: String = line
# Match [ext_resource ... path="res://..." ...] lines that reference .tscn
if l.begins_with("[ext_resource") and ".tscn" in l:
var path_start := l.find('path="')
if path_start == -1:
continue
path_start += 6 # len('path="')
var path_end := l.find('"', path_start)
if path_end == -1:
continue
var ref_path := l.substr(path_start, path_end - path_start)
if ref_path.ends_with(".tscn"):
deps.append(ref_path)
dep_graph[tp] = deps
# Step 3: Detect cycles using DFS
var cycles: Array = []
var visited: Dictionary = {} # path -> "unvisited" | "visiting" | "visited"
for scene in dep_graph:
visited[scene] = "unvisited"
for scene in dep_graph:
if visited[scene] == "unvisited":
var path_stack: Array = []
_dfs_detect_cycle(scene as String, dep_graph, visited, path_stack, cycles)
return success({
"scenes_checked": tscn_files.size(),
"circular_dependencies": cycles,
"has_circular": cycles.size() > 0,
"dependency_graph": dep_graph,
})
func _dfs_detect_cycle(node: String, graph: Dictionary, visited: Dictionary,
path_stack: Array, cycles: Array) -> void:
visited[node] = "visiting"
path_stack.append(node)
if graph.has(node):
var deps: Array = graph[node]
for dep in deps:
var d: String = dep
if not visited.has(d):
# Scene referenced but not in our graph (might not exist or outside scope)
continue
if visited[d] == "visiting":
# Found a cycle — extract it from the stack
var cycle_start := path_stack.find(d)
var cycle: Array = path_stack.slice(cycle_start)
cycle.append(d) # Close the cycle
cycles.append(cycle)
elif visited[d] == "unvisited":
_dfs_detect_cycle(d, graph, visited, path_stack, cycles)
path_stack.pop_back()
visited[node] = "visited"
# =============================================================================
# get_project_statistics
# =============================================================================
## Overall project stats: file counts, script lines, scenes, resources, autoloads, plugins.
func _get_project_statistics(params: Dictionary) -> Dictionary:
var path: String = optional_string(params, "path", "res://")
var include_addons: bool = optional_bool(params, "include_addons", false)
var file_counts: Dictionary = {} # extension -> count
var total_script_lines := 0
var scene_count := 0
var resource_count := 0
var total_files := 0
_collect_statistics(path, include_addons, file_counts)
# Extract internal counters and remove them from the visible dict
total_script_lines = int(file_counts.get("_total_script_lines", 0))
scene_count = int(file_counts.get("_scene_count", 0))
resource_count = int(file_counts.get("_resource_count", 0))
total_files = int(file_counts.get("_total_files", 0))
file_counts.erase("_total_script_lines")
file_counts.erase("_scene_count")
file_counts.erase("_resource_count")
file_counts.erase("_total_files")
# Autoloads
var autoloads: Dictionary = {}
for prop in ProjectSettings.get_property_list():
var prop_name: String = prop["name"]
if prop_name.begins_with("autoload/"):
autoloads[prop_name.substr(9)] = str(ProjectSettings.get_setting(prop_name))
# Enabled plugins
var plugins: Array = []
var plugin_cfg_path := "res://addons"
var enabled_plugins: PackedStringArray = ProjectSettings.get_setting(
"editor_plugins/enabled", PackedStringArray()
)
var plugin_dir := DirAccess.open(plugin_cfg_path)
if plugin_dir != null:
plugin_dir.list_dir_begin()
var dir_name := plugin_dir.get_next()
while not dir_name.is_empty():
if plugin_dir.current_is_dir() and not dir_name.begins_with("."):
var cfg_path := plugin_cfg_path.path_join(dir_name).path_join("plugin.cfg")
if FileAccess.file_exists(cfg_path):
var plugin_path := "res://addons/%s/plugin.cfg" % dir_name
plugins.append({
"name": dir_name,
"enabled": plugin_path in enabled_plugins,
})
dir_name = plugin_dir.get_next()
plugin_dir.list_dir_end()
return success({
"file_counts_by_extension": file_counts,
"total_files": total_files,
"total_script_lines": total_script_lines,
"scene_count": scene_count,
"resource_count": resource_count,
"autoloads": autoloads,
"plugins": plugins,
})
func _collect_statistics(path: String, include_addons: bool,
file_counts: Dictionary) -> void:
var dir := DirAccess.open(path)
if dir == null:
return
dir.list_dir_begin()
var file_name := dir.get_next()
while not file_name.is_empty():
if file_name.begins_with("."):
file_name = dir.get_next()
continue
var full_path := path.path_join(file_name)
if dir.current_is_dir():
if file_name == "addons" and not include_addons:
file_name = dir.get_next()
continue
_collect_statistics(full_path, include_addons, file_counts)
else:
var ext := file_name.get_extension().to_lower()
file_counts[ext] = file_counts.get(ext, 0) + 1
if ext == "gd":
var content := _read_file_text(full_path)
var line_count := content.count("\n") + 1 if not content.is_empty() else 0
# We can't modify int params, so we store in the dict
file_counts["_total_script_lines"] = file_counts.get("_total_script_lines", 0) + line_count
if ext == "tscn":
file_counts["_scene_count"] = file_counts.get("_scene_count", 0) + 1
if ext in ["tres", "material", "theme", "stylebox", "font"]:
file_counts["_resource_count"] = file_counts.get("_resource_count", 0) + 1
file_counts["_total_files"] = file_counts.get("_total_files", 0) + 1
file_name = dir.get_next()
dir.list_dir_end()
# =============================================================================
# Shared helpers
# =============================================================================
## Recursively collect files matching given extensions.
func _collect_files_by_ext(path: String, extensions: Array, out: Array, include_addons: bool) -> void:
var dir := DirAccess.open(path)
if dir == null:
return
dir.list_dir_begin()
var file_name := dir.get_next()
while not file_name.is_empty():
if file_name.begins_with("."):
file_name = dir.get_next()
continue
var full_path := path.path_join(file_name)
if dir.current_is_dir():
if file_name == "addons" and not include_addons:
file_name = dir.get_next()
continue
_collect_files_by_ext(full_path, extensions, out, include_addons)
else:
var ext := file_name.get_extension().to_lower()
if ext in extensions:
out.append(full_path)
file_name = dir.get_next()
dir.list_dir_end()
## Read a file's text content. Returns empty string on failure.
func _read_file_text(file_path: String) -> String:
var file := FileAccess.open(file_path, FileAccess.READ)
if file == null:
return ""
var content := file.get_as_text()
file.close()
return content

View File

@@ -0,0 +1,192 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
func get_commands() -> Dictionary:
return {
"list_android_devices": _list_android_devices,
"get_android_preset_info": _get_android_preset_info,
"deploy_to_android": _deploy_to_android,
}
## Resolve adb path from editor settings or PATH fallback.
func _resolve_adb_path() -> String:
var editor_settings := get_editor().get_editor_settings()
# Godot exposes this under export/android/adb (may be stored as an absolute path).
var configured: String = ""
if editor_settings.has_setting("export/android/adb"):
configured = str(editor_settings.get_setting("export/android/adb"))
if not configured.is_empty() and FileAccess.file_exists(configured):
return configured
# Fallback: assume adb is on PATH. OS.execute will resolve it at call time.
return "adb"
func _run(cmd: String, args: PackedStringArray) -> Dictionary:
var output: Array = []
var exit_code := OS.execute(cmd, args, output, true)
var stdout := ""
if not output.is_empty():
stdout = str(output[0])
return {"exit_code": exit_code, "stdout": stdout}
## List devices visible to adb.
func _list_android_devices(_params: Dictionary) -> Dictionary:
var adb := _resolve_adb_path()
var result := _run(adb, PackedStringArray(["devices", "-l"]))
if result["exit_code"] != 0:
return error(-32000, "adb failed (exit %d). Install Android platform-tools or set Editor Settings > Export > Android > Adb." % result["exit_code"], {"adb_path": adb, "output": result["stdout"]})
# Parse `adb devices -l` output:
# List of devices attached
# R58M12345 device usb:3-1 product:foo model:Pixel_5 device:redfin
var devices: Array = []
var lines: PackedStringArray = str(result["stdout"]).split("\n")
for raw_line in lines:
var line: String = raw_line.strip_edges()
if line.is_empty() or line.begins_with("List of devices") or line.begins_with("* daemon"):
continue
var parts: PackedStringArray = line.split(" ", false)
if parts.size() < 2:
continue
var dev: Dictionary = {"serial": parts[0], "state": parts[1]}
for i in range(2, parts.size()):
var kv: String = parts[i]
var eq: int = kv.find(":")
if eq > 0:
dev[kv.substr(0, eq)] = kv.substr(eq + 1)
devices.append(dev)
return success({"devices": devices, "count": devices.size(), "adb_path": adb})
## Find an Android preset in export_presets.cfg. Returns the preset dict or null.
func _find_android_preset(preset_name: String, preset_index: int) -> Dictionary:
var presets_path := "res://export_presets.cfg"
if not FileAccess.file_exists(presets_path):
return {}
var cfg := ConfigFile.new()
if cfg.load(presets_path) != OK:
return {}
var idx := 0
while cfg.has_section("preset.%d" % idx):
var section := "preset.%d" % idx
var platform := str(cfg.get_value(section, "platform", ""))
var name := str(cfg.get_value(section, "name", ""))
var matches := false
if not preset_name.is_empty():
matches = (name == preset_name)
elif preset_index >= 0:
matches = (idx == preset_index)
else:
# No filter: pick the first Android preset.
matches = (platform == "Android")
if matches:
var options_section := "preset.%d.options" % idx
var package_name := ""
if cfg.has_section(options_section):
package_name = str(cfg.get_value(options_section, "package/unique_name", ""))
return {
"index": idx,
"name": name,
"platform": platform,
"runnable": bool(cfg.get_value(section, "runnable", false)),
"export_path": str(cfg.get_value(section, "export_path", "")),
"package_name": package_name,
}
idx += 1
return {}
## Read Android preset metadata (package name, export path, etc.)
func _get_android_preset_info(params: Dictionary) -> Dictionary:
var preset_name: String = optional_string(params, "preset_name", "")
var preset_index: int = optional_int(params, "preset_index", -1)
var preset := _find_android_preset(preset_name, preset_index)
if preset.is_empty():
return error_not_found("Android export preset", "Configure an Android preset in Project > Export first.")
if preset["platform"] != "Android":
return error(-32000, "Preset '%s' is not an Android preset (platform=%s)" % [preset["name"], preset["platform"]])
return success(preset)
## Export APK, install it on a device, then optionally launch the main activity.
func _deploy_to_android(params: Dictionary) -> Dictionary:
var preset_name: String = optional_string(params, "preset_name", "")
var preset_index: int = optional_int(params, "preset_index", -1)
var device_serial: String = optional_string(params, "device_serial", "")
var debug: bool = optional_bool(params, "debug", true)
var launch: bool = optional_bool(params, "launch", true)
var skip_export: bool = optional_bool(params, "skip_export", false)
var preset := _find_android_preset(preset_name, preset_index)
if preset.is_empty():
return error_not_found("Android export preset", "Configure an Android preset in Project > Export first.")
if preset["platform"] != "Android":
return error(-32000, "Preset '%s' is not an Android preset" % preset["name"])
var export_path_res: String = preset["export_path"]
if export_path_res.is_empty():
return error(-32000, "Export path not configured for preset '%s'" % preset["name"])
var export_path_abs: String = ProjectSettings.globalize_path(export_path_res) if export_path_res.begins_with("res://") else export_path_res
var steps: Array = []
# Step 1: Export APK via Godot CLI (unless caller already has an APK).
if not skip_export:
var godot_bin := OS.get_executable_path()
var project_dir := ProjectSettings.globalize_path("res://")
var export_flag := "--export-debug" if debug else "--export-release"
var export_args := PackedStringArray(["--headless", "--path", project_dir, export_flag, preset["name"], export_path_abs])
var export_result := _run(godot_bin, export_args)
steps.append({"step": "export", "command": godot_bin, "args": export_args, "exit_code": export_result["exit_code"]})
if export_result["exit_code"] != 0:
return error(-32000, "Godot export failed (exit %d). See stdout." % export_result["exit_code"], {"steps": steps, "stdout": export_result["stdout"]})
if not FileAccess.file_exists(export_path_abs):
return error(-32000, "APK not found at %s after export" % export_path_abs, {"steps": steps})
# Step 2: adb install -r
var adb := _resolve_adb_path()
var install_args := PackedStringArray()
if not device_serial.is_empty():
install_args.append("-s")
install_args.append(device_serial)
install_args.append("install")
install_args.append("-r")
install_args.append(export_path_abs)
var install_result := _run(adb, install_args)
steps.append({"step": "install", "command": adb, "args": install_args, "exit_code": install_result["exit_code"], "stdout": install_result["stdout"]})
if install_result["exit_code"] != 0:
return error(-32000, "adb install failed (exit %d)" % install_result["exit_code"], {"steps": steps})
# Step 3: adb shell am start (optional)
if launch:
var package_name: String = preset["package_name"]
if package_name.is_empty():
steps.append({"step": "launch", "skipped": true, "reason": "package_name not found in preset"})
else:
var launch_args := PackedStringArray()
if not device_serial.is_empty():
launch_args.append("-s")
launch_args.append(device_serial)
launch_args.append("shell")
launch_args.append("monkey")
launch_args.append("-p")
launch_args.append(package_name)
launch_args.append("-c")
launch_args.append("android.intent.category.LAUNCHER")
launch_args.append("1")
var launch_result := _run(adb, launch_args)
steps.append({"step": "launch", "command": adb, "args": launch_args, "exit_code": launch_result["exit_code"], "stdout": launch_result["stdout"]})
return success({
"preset": preset["name"],
"apk_path": export_path_abs,
"device": device_serial if not device_serial.is_empty() else "(default)",
"package_name": preset["package_name"],
"steps": steps,
})

View File

@@ -0,0 +1,298 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
func get_commands() -> Dictionary:
return {
"list_animations": _list_animations,
"create_animation": _create_animation,
"add_animation_track": _add_animation_track,
"set_animation_keyframe": _set_animation_keyframe,
"get_animation_info": _get_animation_info,
"remove_animation": _remove_animation,
}
func _find_animation_player(node_path: String) -> AnimationPlayer:
var node := find_node_by_path(node_path)
if node is AnimationPlayer:
return node as AnimationPlayer
return null
func _list_animations(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var player := _find_animation_player(node_path)
if player == null:
return error_not_found("AnimationPlayer at '%s'" % node_path)
var animations: Array = []
for anim_name in player.get_animation_list():
var anim := player.get_animation(anim_name)
animations.append({
"name": anim_name,
"length": anim.length,
"loop_mode": anim.loop_mode,
"track_count": anim.get_track_count(),
})
return success({"node_path": node_path, "animations": animations, "count": animations.size()})
func _create_animation(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "name")
if result2[1] != null:
return result2[1]
var anim_name: String = result2[0]
var player := _find_animation_player(node_path)
if player == null:
return error_not_found("AnimationPlayer at '%s'" % node_path)
var length: float = float(params.get("length", 1.0))
var loop_mode: int = int(params.get("loop_mode", 0)) # 0=none, 1=linear, 2=pingpong
var anim := Animation.new()
anim.length = length
anim.loop_mode = loop_mode as Animation.LoopMode
var lib := player.get_animation_library("")
var created_library := false
if lib == null:
lib = AnimationLibrary.new()
created_library = true
if lib.has_animation(anim_name):
return error_invalid_params("Animation '%s' already exists" % anim_name)
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Create animation %s" % anim_name)
if created_library:
undo_redo.add_do_method(player, "add_animation_library", "", lib)
undo_redo.add_do_reference(lib)
undo_redo.add_undo_method(player, "remove_animation_library", "")
undo_redo.add_do_method(lib, "add_animation", anim_name, anim)
undo_redo.add_do_reference(anim)
undo_redo.add_undo_method(lib, "remove_animation", anim_name)
undo_redo.commit_action()
return success({"name": anim_name, "length": length, "created": true})
func _add_animation_track(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "animation")
if result2[1] != null:
return result2[1]
var anim_name: String = result2[0]
var result3 := require_string(params, "track_path")
if result3[1] != null:
return result3[1]
var track_path: String = result3[0]
var player := _find_animation_player(node_path)
if player == null:
return error_not_found("AnimationPlayer at '%s'" % node_path)
var anim := player.get_animation(anim_name)
if anim == null:
return error_not_found("Animation '%s'" % anim_name)
var track_type_str: String = optional_string(params, "track_type", "value")
var track_type: int
match track_type_str:
"value": track_type = Animation.TYPE_VALUE
"position_2d": track_type = Animation.TYPE_POSITION_3D # Godot uses 3D type for 2D too
"rotation_2d": track_type = Animation.TYPE_ROTATION_3D
"scale_2d": track_type = Animation.TYPE_SCALE_3D
"method": track_type = Animation.TYPE_METHOD
"bezier": track_type = Animation.TYPE_BEZIER
"blend_shape": track_type = Animation.TYPE_BLEND_SHAPE
_: track_type = Animation.TYPE_VALUE
var track_idx := anim.get_track_count()
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Add animation track")
undo_redo.add_do_method(anim, "add_track", track_type, track_idx)
undo_redo.add_do_method(anim, "track_set_path", track_idx, NodePath(track_path))
var update_mode_str: String = optional_string(params, "update_mode", "")
if not update_mode_str.is_empty() and track_type == Animation.TYPE_VALUE:
match update_mode_str:
"continuous": undo_redo.add_do_method(anim, "value_track_set_update_mode", track_idx, Animation.UPDATE_CONTINUOUS)
"discrete": undo_redo.add_do_method(anim, "value_track_set_update_mode", track_idx, Animation.UPDATE_DISCRETE)
"capture": undo_redo.add_do_method(anim, "value_track_set_update_mode", track_idx, Animation.UPDATE_CAPTURE)
undo_redo.add_undo_method(anim, "remove_track", track_idx)
undo_redo.commit_action()
return success({"track_index": track_idx, "track_path": track_path, "track_type": track_type_str})
func _set_animation_keyframe(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "animation")
if result2[1] != null:
return result2[1]
var anim_name: String = result2[0]
var player := _find_animation_player(node_path)
if player == null:
return error_not_found("AnimationPlayer at '%s'" % node_path)
var anim := player.get_animation(anim_name)
if anim == null:
return error_not_found("Animation '%s'" % anim_name)
var track_index: int = int(params.get("track_index", 0))
if track_index < 0 or track_index >= anim.get_track_count():
return error_invalid_params("Invalid track_index: %d" % track_index)
var time: float = float(params.get("time", 0.0))
var value = params.get("value")
# Parse value string for common types
if value is String:
var s: String = value
var expr := Expression.new()
if expr.parse(s) == OK:
var parsed = expr.execute()
if parsed != null:
value = parsed
var easing: float = float(params.get("easing", 1.0))
var old_key_idx := _find_animation_key_at_time(anim, track_index, time)
var had_old_key := old_key_idx >= 0
var old_value: Variant = anim.track_get_key_value(track_index, old_key_idx) if had_old_key else null
var old_easing: float = anim.track_get_key_transition(track_index, old_key_idx) if had_old_key else 1.0
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Set animation keyframe")
undo_redo.add_do_method(self, "_upsert_animation_key", anim, track_index, time, value, easing)
undo_redo.add_undo_method(self, "_restore_animation_key", anim, track_index, time, had_old_key, old_value, old_easing)
undo_redo.commit_action()
var key_idx := _find_animation_key_at_time(anim, track_index, time)
return success({"track_index": track_index, "time": time, "key_index": key_idx, "easing": anim.track_get_key_transition(track_index, key_idx)})
func _get_animation_info(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "animation")
if result2[1] != null:
return result2[1]
var anim_name: String = result2[0]
var player := _find_animation_player(node_path)
if player == null:
return error_not_found("AnimationPlayer at '%s'" % node_path)
var anim := player.get_animation(anim_name)
if anim == null:
return error_not_found("Animation '%s'" % anim_name)
var tracks: Array = []
for i in anim.get_track_count():
var track_info := {
"index": i,
"path": str(anim.track_get_path(i)),
"type": anim.track_get_type(i),
"key_count": anim.track_get_key_count(i),
}
var keys: Array = []
for k in anim.track_get_key_count(i):
keys.append({
"time": anim.track_get_key_time(i, k),
"value": str(anim.track_get_key_value(i, k)),
"easing": anim.track_get_key_transition(i, k),
})
track_info["keys"] = keys
tracks.append(track_info)
return success({
"name": anim_name,
"length": anim.length,
"loop_mode": anim.loop_mode,
"step": anim.step,
"tracks": tracks,
})
func _remove_animation(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "name")
if result2[1] != null:
return result2[1]
var anim_name: String = result2[0]
var player := _find_animation_player(node_path)
if player == null:
return error_not_found("AnimationPlayer at '%s'" % node_path)
var lib := player.get_animation_library("")
if lib == null or not lib.has_animation(anim_name):
return error_not_found("Animation '%s'" % anim_name)
var anim := lib.get_animation(anim_name)
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Remove animation %s" % anim_name)
undo_redo.add_do_method(lib, "remove_animation", anim_name)
undo_redo.add_undo_method(lib, "add_animation", anim_name, anim)
undo_redo.add_undo_reference(anim)
undo_redo.commit_action()
return success({"name": anim_name, "removed": true})
func _find_animation_key_at_time(anim: Animation, track_index: int, time: float) -> int:
for key_index: int in anim.track_get_key_count(track_index):
if is_equal_approx(anim.track_get_key_time(track_index, key_index), time):
return key_index
return -1
func _upsert_animation_key(anim: Animation, track_index: int, time: float, value: Variant, easing: float) -> void:
var key_idx := _find_animation_key_at_time(anim, track_index, time)
if key_idx < 0:
key_idx = anim.track_insert_key(track_index, time, value)
else:
anim.track_set_key_value(track_index, key_idx, value)
if easing != 1.0:
anim.track_set_key_transition(track_index, key_idx, easing)
func _restore_animation_key(anim: Animation, track_index: int, time: float, had_old_key: bool, old_value: Variant, old_easing: float) -> void:
var key_idx := _find_animation_key_at_time(anim, track_index, time)
if had_old_key:
if key_idx < 0:
key_idx = anim.track_insert_key(track_index, time, old_value)
else:
anim.track_set_key_value(track_index, key_idx, old_value)
anim.track_set_key_transition(track_index, key_idx, old_easing)
elif key_idx >= 0:
anim.track_remove_key(track_index, key_idx)

View File

@@ -0,0 +1,601 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
func get_commands() -> Dictionary:
return {
"create_animation_tree": _create_animation_tree,
"get_animation_tree_structure": _get_animation_tree_structure,
"add_state_machine_state": _add_state_machine_state,
"remove_state_machine_state": _remove_state_machine_state,
"add_state_machine_transition": _add_state_machine_transition,
"remove_state_machine_transition": _remove_state_machine_transition,
"set_blend_tree_node": _set_blend_tree_node,
"set_tree_parameter": _set_tree_parameter,
}
## Find AnimationTree on a node or return null
func _find_animation_tree(node_path: String) -> AnimationTree:
var node := find_node_by_path(node_path)
if node is AnimationTree:
return node as AnimationTree
return null
## Navigate to a nested state machine by slash-separated path (e.g. "Run/SubState")
## Returns [state_machine, error_or_null]
func _resolve_state_machine(tree: AnimationTree, sm_path: String) -> Array:
var root := tree.tree_root
if not root is AnimationNodeStateMachine:
return [null, error_invalid_params("AnimationTree root is not an AnimationNodeStateMachine")]
if sm_path.is_empty() or sm_path == ".":
return [root as AnimationNodeStateMachine, null]
var current: AnimationNodeStateMachine = root as AnimationNodeStateMachine
var parts := sm_path.split("/")
for part in parts:
if not current.has_node(StringName(part)):
return [null, error_not_found("State machine node '%s' in path '%s'" % [part, sm_path])]
var child := current.get_node(StringName(part))
if not child is AnimationNodeStateMachine:
return [null, error_invalid_params("Node '%s' is not a StateMachine" % part)]
current = child as AnimationNodeStateMachine
return [current, null]
## Resolve a BlendTree inside the tree. bt_path can be a state name inside a state machine,
## or a slash-separated path. The last segment is the BlendTree node name.
## Returns [blend_tree, error_or_null]
func _resolve_blend_tree(tree: AnimationTree, sm_path: String, bt_name: String) -> Array:
var result := _resolve_state_machine(tree, sm_path)
if result[1] != null:
return result
var sm: AnimationNodeStateMachine = result[0]
if not sm.has_node(StringName(bt_name)):
return [null, error_not_found("BlendTree node '%s'" % bt_name)]
var node := sm.get_node(StringName(bt_name))
if not node is AnimationNodeBlendTree:
return [null, error_invalid_params("Node '%s' is not an AnimationNodeBlendTree" % bt_name)]
return [node as AnimationNodeBlendTree, null]
func _create_animation_tree(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var parent := find_node_by_path(node_path)
if parent == null:
return error_not_found("Node at '%s'" % node_path)
var anim_player_path: String = optional_string(params, "anim_player", "")
var tree_name: String = optional_string(params, "name", "AnimationTree")
# Create the AnimationTree
var tree := AnimationTree.new()
tree.name = tree_name
# Set root to AnimationNodeStateMachine
var state_machine := AnimationNodeStateMachine.new()
tree.tree_root = state_machine
# Link to AnimationPlayer if provided
if not anim_player_path.is_empty():
tree.anim_player = NodePath(anim_player_path)
add_child_with_undo(parent, tree, root, "MCP: Create AnimationTree")
return success({
"name": tree.name,
"node_path": str(root.get_path_to(tree)),
"root_type": "AnimationNodeStateMachine",
"anim_player": anim_player_path,
"created": true,
})
func _get_animation_tree_structure(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var tree := _find_animation_tree(node_path)
if tree == null:
return error_not_found("AnimationTree at '%s'" % node_path)
var root := tree.tree_root
if root == null:
return success({"node_path": node_path, "root": null})
var structure := _read_node_structure(root)
structure["active"] = tree.active
structure["anim_player"] = str(tree.anim_player)
structure["node_path"] = node_path
return success(structure)
func _read_node_structure(node: AnimationNode) -> Dictionary:
if node is AnimationNodeStateMachine:
return _read_state_machine_structure(node as AnimationNodeStateMachine)
elif node is AnimationNodeBlendTree:
return _read_blend_tree_structure(node as AnimationNodeBlendTree)
elif node is AnimationNodeAnimation:
var anim_node := node as AnimationNodeAnimation
return {"type": "AnimationNodeAnimation", "animation": str(anim_node.animation)}
else:
return {"type": node.get_class()}
func _read_state_machine_structure(sm: AnimationNodeStateMachine) -> Dictionary:
var states: Array = []
# Iterate through graph nodes via get_node_name
# AnimationNodeStateMachine doesn't have get_node_list in 4.x, iterate using _get_child_nodes
var node_list := _get_sm_node_names(sm)
for state_name in node_list:
var child := sm.get_node(StringName(state_name))
var state_info := {
"name": state_name,
"position": {"x": sm.get_node_position(StringName(state_name)).x, "y": sm.get_node_position(StringName(state_name)).y},
}
state_info.merge(_read_node_structure(child))
states.append(state_info)
var transitions: Array = []
for i in sm.get_transition_count():
var from_node := sm.get_transition_from(i)
var to_node := sm.get_transition_to(i)
var trans := sm.get_transition(i)
var trans_info := {
"from": str(from_node),
"to": str(to_node),
"switch_mode": trans.switch_mode,
"advance_mode": trans.advance_mode,
}
if not trans.advance_expression.is_empty():
trans_info["advance_expression"] = trans.advance_expression
if trans.advance_mode == AnimationNodeStateMachineTransition.ADVANCE_MODE_AUTO:
trans_info["auto"] = true
transitions.append(trans_info)
return {
"type": "AnimationNodeStateMachine",
"states": states,
"transitions": transitions,
}
func _get_sm_node_names(sm: AnimationNodeStateMachine) -> Array:
# Use the internal _get_child_nodes or iterate known patterns
# AnimationNodeStateMachine doesn't expose a simple list method,
# but we can use get_graph_offset and iterate via has_node with common checks.
# Actually in Godot 4.x we can get the node list by checking property list
# or using the script resource approach. The most reliable is iterating through
# the resource properties.
var names: Array = []
var prop_list := sm.get_property_list()
for prop in prop_list:
var pname: String = prop["name"]
# State machine stores nodes as "states/<name>/node"
if pname.begins_with("states/") and pname.ends_with("/node"):
var state_name := pname.get_slice("/", 1)
if state_name != "Start" and state_name != "End":
names.append(state_name)
return names
func _read_blend_tree_structure(bt: AnimationNodeBlendTree) -> Dictionary:
var nodes_info: Array = []
var prop_list := bt.get_property_list()
var node_names: Array = []
for prop in prop_list:
var pname: String = prop["name"]
if pname.begins_with("nodes/") and pname.ends_with("/node"):
var n := pname.get_slice("/", 1)
if n != "output":
node_names.append(n)
for n_name in node_names:
var child: AnimationNode = bt.get_node(StringName(n_name))
var node_info := {
"name": n_name,
"type": child.get_class(),
"position": {"x": bt.get_node_position(StringName(n_name)).x, "y": bt.get_node_position(StringName(n_name)).y},
}
if child is AnimationNodeAnimation:
node_info["animation"] = str((child as AnimationNodeAnimation).animation)
nodes_info.append(node_info)
# Read connections
# BlendTree connections are stored as "node_connections" in properties
# We can read them from the resource property list
for prop in prop_list:
var pname: String = prop["name"]
if pname.begins_with("nodes/") and pname.ends_with("/node"):
continue
if pname.begins_with("nodes/") and pname.ends_with("/position"):
continue
# Connection format: "node_connection/<idx>/<input_node>/<input_port>"
# Actually connections are stored differently - let's skip for now
return {
"type": "AnimationNodeBlendTree",
"nodes": nodes_info,
}
func _add_state_machine_state(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "state_name")
if result2[1] != null:
return result2[1]
var state_name: String = result2[0]
var tree := _find_animation_tree(node_path)
if tree == null:
return error_not_found("AnimationTree at '%s'" % node_path)
var sm_path: String = optional_string(params, "state_machine_path", "")
var sm_result := _resolve_state_machine(tree, sm_path)
if sm_result[1] != null:
return sm_result[1]
var sm: AnimationNodeStateMachine = sm_result[0]
if sm.has_node(StringName(state_name)):
return error_invalid_params("State '%s' already exists" % state_name)
var state_type: String = optional_string(params, "state_type", "animation")
var position_x: float = float(params.get("position_x", 0.0))
var position_y: float = float(params.get("position_y", 0.0))
var position := Vector2(position_x, position_y)
var node: AnimationNode
match state_type:
"animation":
var anim_node := AnimationNodeAnimation.new()
var anim_name: String = optional_string(params, "animation", "")
if not anim_name.is_empty():
anim_node.animation = StringName(anim_name)
node = anim_node
"blend_tree":
node = AnimationNodeBlendTree.new()
"state_machine":
node = AnimationNodeStateMachine.new()
_:
return error_invalid_params("Unknown state_type: '%s'. Use 'animation', 'blend_tree', or 'state_machine'" % state_type)
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Add state machine state")
undo_redo.add_do_method(sm, "add_node", StringName(state_name), node, position)
undo_redo.add_do_reference(node)
undo_redo.add_undo_method(sm, "remove_node", StringName(state_name))
undo_redo.commit_action()
return success({
"state_name": state_name,
"state_type": state_type,
"position": {"x": position_x, "y": position_y},
"added": true,
})
func _remove_state_machine_state(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "state_name")
if result2[1] != null:
return result2[1]
var state_name: String = result2[0]
var tree := _find_animation_tree(node_path)
if tree == null:
return error_not_found("AnimationTree at '%s'" % node_path)
var sm_path: String = optional_string(params, "state_machine_path", "")
var sm_result := _resolve_state_machine(tree, sm_path)
if sm_result[1] != null:
return sm_result[1]
var sm: AnimationNodeStateMachine = sm_result[0]
if not sm.has_node(StringName(state_name)):
return error_not_found("State '%s'" % state_name)
var old_node := sm.get_node(StringName(state_name))
var old_position := sm.get_node_position(StringName(state_name))
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Remove state machine state")
undo_redo.add_do_method(sm, "remove_node", StringName(state_name))
undo_redo.add_undo_method(sm, "add_node", StringName(state_name), old_node, old_position)
undo_redo.add_undo_reference(old_node)
undo_redo.commit_action()
return success({"state_name": state_name, "removed": true})
func _add_state_machine_transition(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "from_state")
if result2[1] != null:
return result2[1]
var from_state: String = result2[0]
var result3 := require_string(params, "to_state")
if result3[1] != null:
return result3[1]
var to_state: String = result3[0]
var tree := _find_animation_tree(node_path)
if tree == null:
return error_not_found("AnimationTree at '%s'" % node_path)
var sm_path: String = optional_string(params, "state_machine_path", "")
var sm_result := _resolve_state_machine(tree, sm_path)
if sm_result[1] != null:
return sm_result[1]
var sm: AnimationNodeStateMachine = sm_result[0]
# Validate states exist (Start and End are special built-in nodes)
if from_state != "Start" and from_state != "End" and not sm.has_node(StringName(from_state)):
return error_not_found("State '%s'" % from_state)
if to_state != "Start" and to_state != "End" and not sm.has_node(StringName(to_state)):
return error_not_found("State '%s'" % to_state)
var transition := AnimationNodeStateMachineTransition.new()
# switch_mode: AT_END=0, IMMEDIATE=1, SYNC=2
var switch_mode_str: String = optional_string(params, "switch_mode", "immediate")
match switch_mode_str:
"at_end": transition.switch_mode = AnimationNodeStateMachineTransition.SWITCH_MODE_AT_END
"immediate": transition.switch_mode = AnimationNodeStateMachineTransition.SWITCH_MODE_IMMEDIATE
"sync": transition.switch_mode = AnimationNodeStateMachineTransition.SWITCH_MODE_AT_END # SYNC maps similarly
_: transition.switch_mode = AnimationNodeStateMachineTransition.SWITCH_MODE_IMMEDIATE
# advance_mode: DISABLED=0, ENABLED=1, AUTO=2
var advance_mode_str: String = optional_string(params, "advance_mode", "enabled")
match advance_mode_str:
"disabled": transition.advance_mode = AnimationNodeStateMachineTransition.ADVANCE_MODE_DISABLED
"enabled": transition.advance_mode = AnimationNodeStateMachineTransition.ADVANCE_MODE_ENABLED
"auto": transition.advance_mode = AnimationNodeStateMachineTransition.ADVANCE_MODE_AUTO
_: transition.advance_mode = AnimationNodeStateMachineTransition.ADVANCE_MODE_ENABLED
# advance_expression
var expression: String = optional_string(params, "advance_expression", "")
if not expression.is_empty():
transition.advance_expression = expression
# xfade_time
if params.has("xfade_time"):
transition.xfade_time = float(params["xfade_time"])
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Add state machine transition")
undo_redo.add_do_method(sm, "add_transition", StringName(from_state), StringName(to_state), transition)
undo_redo.add_do_reference(transition)
undo_redo.add_undo_method(sm, "remove_transition", StringName(from_state), StringName(to_state))
undo_redo.commit_action()
return success({
"from": from_state,
"to": to_state,
"switch_mode": switch_mode_str,
"advance_mode": advance_mode_str,
"advance_expression": expression,
"added": true,
})
func _remove_state_machine_transition(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "from_state")
if result2[1] != null:
return result2[1]
var from_state: String = result2[0]
var result3 := require_string(params, "to_state")
if result3[1] != null:
return result3[1]
var to_state: String = result3[0]
var tree := _find_animation_tree(node_path)
if tree == null:
return error_not_found("AnimationTree at '%s'" % node_path)
var sm_path: String = optional_string(params, "state_machine_path", "")
var sm_result := _resolve_state_machine(tree, sm_path)
if sm_result[1] != null:
return sm_result[1]
var sm: AnimationNodeStateMachine = sm_result[0]
# Check if transition exists
var found := false
for i in sm.get_transition_count():
if str(sm.get_transition_from(i)) == from_state and str(sm.get_transition_to(i)) == to_state:
found = true
break
if not found:
return error_not_found("Transition from '%s' to '%s'" % [from_state, to_state])
var transition: AnimationNodeStateMachineTransition = null
for i in sm.get_transition_count():
if str(sm.get_transition_from(i)) == from_state and str(sm.get_transition_to(i)) == to_state:
transition = sm.get_transition(i)
break
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Remove state machine transition")
undo_redo.add_do_method(sm, "remove_transition", StringName(from_state), StringName(to_state))
undo_redo.add_undo_method(sm, "add_transition", StringName(from_state), StringName(to_state), transition)
undo_redo.add_undo_reference(transition)
undo_redo.commit_action()
return success({"from": from_state, "to": to_state, "removed": true})
func _set_blend_tree_node(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "blend_tree_state")
if result2[1] != null:
return result2[1]
var bt_state: String = result2[0]
var result3 := require_string(params, "bt_node_name")
if result3[1] != null:
return result3[1]
var bt_node_name: String = result3[0]
var result4 := require_string(params, "bt_node_type")
if result4[1] != null:
return result4[1]
var bt_node_type: String = result4[0]
var tree := _find_animation_tree(node_path)
if tree == null:
return error_not_found("AnimationTree at '%s'" % node_path)
var sm_path: String = optional_string(params, "state_machine_path", "")
var bt_result := _resolve_blend_tree(tree, sm_path, bt_state)
if bt_result[1] != null:
return bt_result[1]
var bt: AnimationNodeBlendTree = bt_result[0]
var position_x: float = float(params.get("position_x", 0.0))
var position_y: float = float(params.get("position_y", 0.0))
var position := Vector2(position_x, position_y)
var had_old_node := bt.has_node(StringName(bt_node_name))
var old_node: AnimationNode = bt.get_node(StringName(bt_node_name)) if had_old_node else null
var old_position := bt.get_node_position(StringName(bt_node_name)) if had_old_node else Vector2.ZERO
var node: AnimationNode
match bt_node_type:
"Animation":
var anim_node := AnimationNodeAnimation.new()
var anim_name: String = optional_string(params, "animation", "")
if not anim_name.is_empty():
anim_node.animation = StringName(anim_name)
node = anim_node
"Add2":
node = AnimationNodeAdd2.new()
"Blend2":
node = AnimationNodeBlend2.new()
"Add3":
node = AnimationNodeAdd3.new()
"Blend3":
node = AnimationNodeBlend3.new()
"TimeScale":
node = AnimationNodeTimeScale.new()
"TimeSeek":
node = AnimationNodeTimeSeek.new()
"Transition":
node = AnimationNodeTransition.new()
"OneShot":
node = AnimationNodeOneShot.new()
"Sub2":
node = AnimationNodeSub2.new()
_:
return error_invalid_params("Unknown bt_node_type: '%s'. Use: Animation, Add2, Blend2, Add3, Blend3, TimeScale, TimeSeek, Transition, OneShot, Sub2" % bt_node_type)
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Set blend tree node")
if had_old_node:
undo_redo.add_do_method(bt, "remove_node", StringName(bt_node_name))
undo_redo.add_undo_method(bt, "add_node", StringName(bt_node_name), old_node, old_position)
undo_redo.add_undo_reference(old_node)
undo_redo.add_do_method(bt, "add_node", StringName(bt_node_name), node, position)
undo_redo.add_do_reference(node)
undo_redo.add_undo_method(bt, "remove_node", StringName(bt_node_name))
# Connect to another node if specified
var connect_to: String = optional_string(params, "connect_to", "")
var connect_port: int = optional_int(params, "connect_port", 0)
if not connect_to.is_empty():
undo_redo.add_do_method(bt, "connect_node", StringName(connect_to), connect_port, StringName(bt_node_name))
undo_redo.commit_action()
var connected_to_value: Variant = null
if not connect_to.is_empty():
connected_to_value = connect_to
return success({
"blend_tree_state": bt_state,
"bt_node_name": bt_node_name,
"bt_node_type": bt_node_type,
"position": {"x": position_x, "y": position_y},
"connected_to": connected_to_value,
"added": true,
})
func _set_tree_parameter(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "parameter")
if result2[1] != null:
return result2[1]
var parameter: String = result2[0]
var tree := _find_animation_tree(node_path)
if tree == null:
return error_not_found("AnimationTree at '%s'" % node_path)
if not params.has("value"):
return error_invalid_params("Missing required parameter: value")
var value = params["value"]
# Prefix with "parameters/" if not already
if not parameter.begins_with("parameters/"):
parameter = "parameters/" + parameter
# Parse string values for common types
if value is String:
var s: String = value
var expr := Expression.new()
if expr.parse(s) == OK:
var parsed = expr.execute()
if parsed != null:
value = parsed
set_property_with_undo(tree, parameter, value, "MCP: Set AnimationTree parameter")
# Read back to confirm
var actual = tree.get(parameter)
return success({
"parameter": parameter,
"value": str(actual),
"set": true,
})

View File

@@ -0,0 +1,431 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
func get_commands() -> Dictionary:
return {
"get_audio_bus_layout": _get_audio_bus_layout,
"add_audio_bus": _add_audio_bus,
"set_audio_bus": _set_audio_bus,
"add_audio_bus_effect": _add_audio_bus_effect,
"add_audio_player": _add_audio_player,
"get_audio_info": _get_audio_info,
}
func _get_audio_bus_layout(_params: Dictionary) -> Dictionary:
var buses: Array[Dictionary] = []
for i in range(AudioServer.bus_count):
var bus_data := {
"index": i,
"name": AudioServer.get_bus_name(i),
"volume_db": AudioServer.get_bus_volume_db(i),
"solo": AudioServer.is_bus_solo(i),
"mute": AudioServer.is_bus_mute(i),
"bypass_effects": AudioServer.is_bus_bypassing_effects(i),
"send": AudioServer.get_bus_send(i),
"effects": [],
}
var effects: Array[Dictionary] = []
for j in range(AudioServer.get_bus_effect_count(i)):
var effect := AudioServer.get_bus_effect(i, j)
var effect_data := {
"index": j,
"type": effect.get_class(),
"enabled": AudioServer.is_bus_effect_enabled(i, j),
}
# Include effect-specific parameters
effect_data["params"] = _get_effect_params(effect)
effects.append(effect_data)
bus_data["effects"] = effects
buses.append(bus_data)
return success({"bus_count": AudioServer.bus_count, "buses": buses})
func _get_effect_params(effect: AudioEffect) -> Dictionary:
var params := {}
if effect is AudioEffectReverb:
var rev := effect as AudioEffectReverb
params = {"room_size": rev.room_size, "damping": rev.damping, "wet": rev.wet, "dry": rev.dry, "spread": rev.spread}
elif effect is AudioEffectDelay:
var d := effect as AudioEffectDelay
params = {"tap1_active": d.tap1_active, "tap1_delay_ms": d.tap1_delay_ms, "tap1_level_db": d.tap1_level_db, "tap2_active": d.tap2_active, "tap2_delay_ms": d.tap2_delay_ms, "tap2_level_db": d.tap2_level_db}
elif effect is AudioEffectCompressor:
var c := effect as AudioEffectCompressor
params = {"threshold": c.threshold, "ratio": c.ratio, "attack_us": c.attack_us, "release_ms": c.release_ms, "gain": c.gain, "mix": c.mix, "sidechain": c.sidechain}
elif effect is AudioEffectLimiter:
var l := effect as AudioEffectLimiter
params = {"ceiling_db": l.ceiling_db, "threshold_db": l.threshold_db, "soft_clip_db": l.soft_clip_db, "soft_clip_ratio": l.soft_clip_ratio}
elif effect is AudioEffectDistortion:
var dist := effect as AudioEffectDistortion
params = {"mode": dist.mode, "pre_gain": dist.pre_gain, "post_gain": dist.post_gain, "keep_hf_hz": dist.keep_hf_hz, "drive": dist.drive}
elif effect is AudioEffectChorus:
var ch := effect as AudioEffectChorus
params = {"voice_count": ch.voice_count, "dry": ch.dry, "wet": ch.wet}
elif effect is AudioEffectPhaser:
var ph := effect as AudioEffectPhaser
params = {"range_min_hz": ph.range_min_hz, "range_max_hz": ph.range_max_hz, "rate_hz": ph.rate_hz, "feedback": ph.feedback, "depth": ph.depth}
elif effect is AudioEffectFilter:
# Covers LowPassFilter, HighPassFilter, BandPassFilter, etc.
var f := effect as AudioEffectFilter
params = {"cutoff_hz": f.cutoff_hz, "resonance": f.resonance, "gain": f.gain, "db": f.db}
elif effect is AudioEffectAmplify:
var a := effect as AudioEffectAmplify
params = {"volume_db": a.volume_db}
return params
func _add_audio_bus(params: Dictionary) -> Dictionary:
var result := require_string(params, "name")
if result[1] != null:
return result[1]
var bus_name: String = result[0]
# Check if bus name already exists
for i in range(AudioServer.bus_count):
if AudioServer.get_bus_name(i) == bus_name:
return error_invalid_params("Audio bus '%s' already exists at index %d" % [bus_name, i])
var at_position: int = optional_int(params, "at_position", -1)
AudioServer.add_bus(at_position)
var idx: int = AudioServer.bus_count - 1 if at_position < 0 else at_position
AudioServer.set_bus_name(idx, bus_name)
if params.has("volume_db"):
AudioServer.set_bus_volume_db(idx, float(params["volume_db"]))
var send: String = optional_string(params, "send", "")
if not send.is_empty():
AudioServer.set_bus_send(idx, send)
if params.has("solo"):
AudioServer.set_bus_solo(idx, bool(params["solo"]))
if params.has("mute"):
AudioServer.set_bus_mute(idx, bool(params["mute"]))
return success({"name": bus_name, "index": idx, "bus_count": AudioServer.bus_count})
func _set_audio_bus(params: Dictionary) -> Dictionary:
var result := require_string(params, "name")
if result[1] != null:
return result[1]
var bus_name: String = result[0]
var idx := AudioServer.get_bus_index(bus_name)
if idx < 0:
return error_not_found("Audio bus '%s'" % bus_name)
var changes := 0
if params.has("volume_db"):
AudioServer.set_bus_volume_db(idx, float(params["volume_db"]))
changes += 1
if params.has("solo"):
AudioServer.set_bus_solo(idx, bool(params["solo"]))
changes += 1
if params.has("mute"):
AudioServer.set_bus_mute(idx, bool(params["mute"]))
changes += 1
if params.has("bypass_effects"):
AudioServer.set_bus_bypass_effects(idx, bool(params["bypass_effects"]))
changes += 1
var send: String = optional_string(params, "send", "")
if not send.is_empty():
AudioServer.set_bus_send(idx, send)
changes += 1
if params.has("rename"):
var new_name: String = str(params["rename"])
AudioServer.set_bus_name(idx, new_name)
bus_name = new_name
changes += 1
return success({"name": bus_name, "index": idx, "changes": changes})
func _add_audio_bus_effect(params: Dictionary) -> Dictionary:
var result := require_string(params, "bus")
if result[1] != null:
return result[1]
var bus_name: String = result[0]
var result2 := require_string(params, "effect_type")
if result2[1] != null:
return result2[1]
var effect_type: String = result2[0]
var bus_idx := AudioServer.get_bus_index(bus_name)
if bus_idx < 0:
return error_not_found("Audio bus '%s'" % bus_name)
var effect: AudioEffect = null
var effect_params: Dictionary = params.get("params", {}) if params.has("params") else {}
match effect_type.to_lower():
"reverb":
var e := AudioEffectReverb.new()
if effect_params.has("room_size"):
e.room_size = float(effect_params["room_size"])
if effect_params.has("damping"):
e.damping = float(effect_params["damping"])
if effect_params.has("wet"):
e.wet = float(effect_params["wet"])
if effect_params.has("dry"):
e.dry = float(effect_params["dry"])
if effect_params.has("spread"):
e.spread = float(effect_params["spread"])
effect = e
"chorus":
var e := AudioEffectChorus.new()
if effect_params.has("voice_count"):
e.voice_count = int(effect_params["voice_count"])
if effect_params.has("dry"):
e.dry = float(effect_params["dry"])
if effect_params.has("wet"):
e.wet = float(effect_params["wet"])
effect = e
"delay":
var e := AudioEffectDelay.new()
if effect_params.has("tap1_active"):
e.tap1_active = bool(effect_params["tap1_active"])
if effect_params.has("tap1_delay_ms"):
e.tap1_delay_ms = float(effect_params["tap1_delay_ms"])
if effect_params.has("tap1_level_db"):
e.tap1_level_db = float(effect_params["tap1_level_db"])
if effect_params.has("tap2_active"):
e.tap2_active = bool(effect_params["tap2_active"])
if effect_params.has("tap2_delay_ms"):
e.tap2_delay_ms = float(effect_params["tap2_delay_ms"])
if effect_params.has("tap2_level_db"):
e.tap2_level_db = float(effect_params["tap2_level_db"])
effect = e
"compressor":
var e := AudioEffectCompressor.new()
if effect_params.has("threshold"):
e.threshold = float(effect_params["threshold"])
if effect_params.has("ratio"):
e.ratio = float(effect_params["ratio"])
if effect_params.has("attack_us"):
e.attack_us = float(effect_params["attack_us"])
if effect_params.has("release_ms"):
e.release_ms = float(effect_params["release_ms"])
if effect_params.has("gain"):
e.gain = float(effect_params["gain"])
if effect_params.has("mix"):
e.mix = float(effect_params["mix"])
effect = e
"limiter":
var e := AudioEffectLimiter.new()
if effect_params.has("ceiling_db"):
e.ceiling_db = float(effect_params["ceiling_db"])
if effect_params.has("threshold_db"):
e.threshold_db = float(effect_params["threshold_db"])
if effect_params.has("soft_clip_db"):
e.soft_clip_db = float(effect_params["soft_clip_db"])
if effect_params.has("soft_clip_ratio"):
e.soft_clip_ratio = float(effect_params["soft_clip_ratio"])
effect = e
"phaser":
var e := AudioEffectPhaser.new()
if effect_params.has("range_min_hz"):
e.range_min_hz = float(effect_params["range_min_hz"])
if effect_params.has("range_max_hz"):
e.range_max_hz = float(effect_params["range_max_hz"])
if effect_params.has("rate_hz"):
e.rate_hz = float(effect_params["rate_hz"])
if effect_params.has("feedback"):
e.feedback = float(effect_params["feedback"])
if effect_params.has("depth"):
e.depth = float(effect_params["depth"])
effect = e
"distortion":
var e := AudioEffectDistortion.new()
if effect_params.has("mode"):
e.mode = int(effect_params["mode"]) as AudioEffectDistortion.Mode
if effect_params.has("pre_gain"):
e.pre_gain = float(effect_params["pre_gain"])
if effect_params.has("post_gain"):
e.post_gain = float(effect_params["post_gain"])
if effect_params.has("keep_hf_hz"):
e.keep_hf_hz = float(effect_params["keep_hf_hz"])
if effect_params.has("drive"):
e.drive = float(effect_params["drive"])
effect = e
"lowpassfilter", "lowpass":
var e := AudioEffectLowPassFilter.new()
if effect_params.has("cutoff_hz"):
e.cutoff_hz = float(effect_params["cutoff_hz"])
if effect_params.has("resonance"):
e.resonance = float(effect_params["resonance"])
effect = e
"highpassfilter", "highpass":
var e := AudioEffectHighPassFilter.new()
if effect_params.has("cutoff_hz"):
e.cutoff_hz = float(effect_params["cutoff_hz"])
if effect_params.has("resonance"):
e.resonance = float(effect_params["resonance"])
effect = e
"bandpassfilter", "bandpass":
var e := AudioEffectBandPassFilter.new()
if effect_params.has("cutoff_hz"):
e.cutoff_hz = float(effect_params["cutoff_hz"])
if effect_params.has("resonance"):
e.resonance = float(effect_params["resonance"])
effect = e
"amplify":
var e := AudioEffectAmplify.new()
if effect_params.has("volume_db"):
e.volume_db = float(effect_params["volume_db"])
effect = e
"eq":
var e := AudioEffectEQ.new()
effect = e
_:
return error_invalid_params("Unknown effect type: '%s'. Valid types: reverb, chorus, delay, compressor, limiter, phaser, distortion, lowpassfilter, highpassfilter, bandpassfilter, amplify, eq" % effect_type)
var at_position: int = optional_int(params, "at_position", -1)
AudioServer.add_bus_effect(bus_idx, effect, at_position)
var effect_idx: int = AudioServer.get_bus_effect_count(bus_idx) - 1 if at_position < 0 else at_position
return success({"bus": bus_name, "bus_index": bus_idx, "effect_type": effect.get_class(), "effect_index": effect_idx})
func _add_audio_player(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "name")
if result2[1] != null:
return result2[1]
var player_name: String = result2[0]
var player_type: String = optional_string(params, "type", "AudioStreamPlayer")
var valid_types := ["AudioStreamPlayer", "AudioStreamPlayer2D", "AudioStreamPlayer3D"]
if player_type not in valid_types:
return error_invalid_params("Invalid player type '%s'. Valid: %s" % [player_type, ", ".join(valid_types)])
var parent := find_node_by_path(node_path)
if parent == null:
return error_not_found("Node at '%s'" % node_path)
var root := get_edited_root()
if root == null:
return error_no_scene()
var player: Node = null
match player_type:
"AudioStreamPlayer":
player = AudioStreamPlayer.new()
"AudioStreamPlayer2D":
player = AudioStreamPlayer2D.new()
"AudioStreamPlayer3D":
player = AudioStreamPlayer3D.new()
player.name = player_name
# Set stream if provided
var stream_path: String = optional_string(params, "stream", "")
if not stream_path.is_empty():
if ResourceLoader.exists(stream_path):
var stream = ResourceLoader.load(stream_path)
if stream is AudioStream:
player.set("stream", stream)
else:
player.queue_free()
return error_invalid_params("Resource at '%s' is not an AudioStream" % stream_path)
else:
player.queue_free()
return error_not_found("Audio stream at '%s'" % stream_path)
# Common properties
if params.has("volume_db"):
player.set("volume_db", float(params["volume_db"]))
var bus: String = optional_string(params, "bus", "")
if not bus.is_empty():
player.set("bus", bus)
if params.has("autoplay"):
player.set("autoplay", bool(params["autoplay"]))
# 2D-specific properties
if player is AudioStreamPlayer2D:
if params.has("max_distance"):
(player as AudioStreamPlayer2D).max_distance = float(params["max_distance"])
if params.has("attenuation"):
(player as AudioStreamPlayer2D).attenuation = float(params["attenuation"])
# 3D-specific properties
if player is AudioStreamPlayer3D:
if params.has("max_distance"):
(player as AudioStreamPlayer3D).max_distance = float(params["max_distance"])
if params.has("attenuation_model"):
(player as AudioStreamPlayer3D).attenuation_model = int(params["attenuation_model"]) as AudioStreamPlayer3D.AttenuationModel
if params.has("unit_size"):
(player as AudioStreamPlayer3D).unit_size = float(params["unit_size"])
add_child_with_undo(parent, player, root, "MCP: Add audio player")
return success({
"name": player_name,
"type": player_type,
"parent": node_path,
"stream": stream_path,
"bus": player.get("bus"),
"volume_db": player.get("volume_db"),
"autoplay": player.get("autoplay"),
})
func _get_audio_info(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node at '%s'" % node_path)
var players: Array[Dictionary] = []
_collect_audio_players(node, players)
return success({"node_path": node_path, "audio_player_count": players.size(), "players": players})
func _collect_audio_players(node: Node, result: Array[Dictionary]) -> void:
if node is AudioStreamPlayer or node is AudioStreamPlayer2D or node is AudioStreamPlayer3D:
var info := {
"name": node.name,
"path": str(get_edited_root().get_path_to(node)),
"type": node.get_class(),
"volume_db": node.get("volume_db"),
"bus": node.get("bus"),
"autoplay": node.get("autoplay"),
"playing": node.get("playing"),
"stream": "",
}
var stream = node.get("stream")
if stream != null and stream is AudioStream:
info["stream"] = stream.resource_path
if node is AudioStreamPlayer2D:
info["max_distance"] = (node as AudioStreamPlayer2D).max_distance
info["attenuation"] = (node as AudioStreamPlayer2D).attenuation
elif node is AudioStreamPlayer3D:
info["max_distance"] = (node as AudioStreamPlayer3D).max_distance
info["attenuation_model"] = (node as AudioStreamPlayer3D).attenuation_model
info["unit_size"] = (node as AudioStreamPlayer3D).unit_size
result.append(info)
for child in node.get_children():
_collect_audio_players(child, result)

View File

@@ -0,0 +1,255 @@
@tool
extends Node
var editor_plugin: EditorPlugin
## Override in subclasses: return {"method_name": Callable}
func get_commands() -> Dictionary:
return {}
## Helper: return a success result
func success(data: Dictionary = {}) -> Dictionary:
return {"result": data}
## Helper: return an error
func error(code: int, message: String, data: Dictionary = {}) -> Dictionary:
var err := {"code": code, "message": message}
if not data.is_empty():
err["data"] = data
return {"error": err}
## Error codes
func error_not_found(what: String, suggestion: String = "") -> Dictionary:
var data := {}
if suggestion:
data["suggestion"] = suggestion
return error(-32001, "%s not found" % what, data)
func error_invalid_params(message: String) -> Dictionary:
return error(-32602, message)
func error_no_scene() -> Dictionary:
return error(-32000, "No scene is currently open", {"suggestion": "Use open_scene to open a scene first"})
func error_internal(message: String) -> Dictionary:
return error(-32603, "Internal error: %s" % message)
func error_conflict(message: String, data: Dictionary = {}) -> Dictionary:
return error(-32009, message, data)
## Get required string param
func require_string(params: Dictionary, key: String) -> Array:
if not params.has(key) or not params[key] is String or (params[key] as String).is_empty():
return [null, error_invalid_params("Missing required parameter: %s" % key)]
return [params[key] as String, null]
## Get optional string param with default
func optional_string(params: Dictionary, key: String, default: String = "") -> String:
if params.has(key) and params[key] is String:
return params[key] as String
return default
## Get optional bool param with default
func optional_bool(params: Dictionary, key: String, default: bool = false) -> bool:
if params.has(key) and params[key] is bool:
return params[key] as bool
return default
## Get optional int param with default
func optional_int(params: Dictionary, key: String, default: int = 0) -> int:
if params.has(key):
return int(params[key])
return default
## Get the game process's user data directory.
## OS.get_user_data_dir() is cached at editor startup and won't reflect
## project name changes made to project.godot while the editor is running.
## The game process reads the name from disk, so we must do the same.
func get_game_user_dir() -> String:
var cached_dir := OS.get_user_data_dir()
var cfg := ConfigFile.new()
var err := cfg.load(ProjectSettings.globalize_path("res://project.godot"))
if err != OK:
return cached_dir
# When use_custom_user_dir=true, editor and game share the same dir
# (OS.get_user_data_dir() already resolves to the custom path).
if cfg.get_value("application", "config/use_custom_user_dir", false):
return cached_dir
var disk_name = cfg.get_value("application", "config/name", "")
if typeof(disk_name) != TYPE_STRING or (disk_name as String).is_empty():
return cached_dir
# Sanitize exactly like Godot does when computing the default user dir
# (core/config/project_settings.cpp ProjectSettings::_init).
var sanitized := (disk_name as String).xml_unescape().validate_filename().replace(".", "_")
if sanitized.is_empty():
return cached_dir
var base_dir := cached_dir.get_base_dir()
var game_dir := base_dir.path_join(sanitized)
# Ensure the directory exists (game may not have created it yet)
if not DirAccess.dir_exists_absolute(game_dir):
DirAccess.make_dir_recursive_absolute(game_dir)
return game_dir
## Get EditorInterface
func get_editor() -> EditorInterface:
return editor_plugin.get_editor_interface()
## Get the edited scene root
func get_edited_root() -> Node:
return EditorInterface.get_edited_scene_root()
## Get UndoRedo
func get_undo_redo() -> EditorUndoRedoManager:
return editor_plugin.get_undo_redo()
func normalize_project_path(path: String) -> String:
if path.is_empty():
return ""
if path.begins_with("res://") or path.begins_with("user://"):
return path.simplify_path()
return ProjectSettings.localize_path(path).simplify_path()
func is_scene_resource_path(path: String) -> bool:
var ext := path.get_extension().to_lower()
return ext == "tscn" or ext == "scn"
func get_open_scene_paths() -> Array[String]:
var paths: Array[String] = []
var open_scenes: PackedStringArray = EditorInterface.get_open_scenes()
for scene_path: String in open_scenes:
var normalized := normalize_project_path(scene_path)
if not normalized.is_empty() and normalized not in paths:
paths.append(normalized)
var root := get_edited_root()
if root != null and not root.scene_file_path.is_empty():
var active_path := normalize_project_path(root.scene_file_path)
if active_path not in paths:
paths.append(active_path)
return paths
func is_scene_path_open(path: String) -> bool:
var normalized := normalize_project_path(path)
if normalized.is_empty():
return false
return normalized in get_open_scene_paths()
func is_active_scene_path(path: String) -> bool:
var root := get_edited_root()
if root == null:
return false
return normalize_project_path(root.scene_file_path) == normalize_project_path(path)
func guard_offline_scene_save(path: String) -> Dictionary:
if is_scene_resource_path(path) and is_scene_path_open(path):
return error_conflict(
"Refusing to save open scene '%s' outside the Godot editor state" % normalize_project_path(path),
{
"path": normalize_project_path(path),
"open_scenes": get_open_scene_paths(),
"suggestion": "Use live editor changes plus save_scene, or close the scene before offline edits.",
}
)
return {}
func is_shader_resource_path(path: String) -> bool:
var ext := path.get_extension().to_lower()
return ext == "gdshader" or ext == "gdshaderinc" or ext == "shader"
func is_text_resource_open_in_script_editor(path: String) -> bool:
var target := normalize_project_path(path)
if target.is_empty():
return false
if is_shader_resource_path(target) and ResourceLoader.has_cached(target):
return true
var script_editor := EditorInterface.get_script_editor()
if script_editor == null:
return false
for open_resource in script_editor.get_open_scripts():
if open_resource is Resource:
var resource_path := normalize_project_path((open_resource as Resource).resource_path)
if resource_path == target:
return true
return false
func guard_text_resource_write(path: String, force: bool) -> Dictionary:
if not force and is_text_resource_open_in_script_editor(path):
return error_conflict(
"Refusing to write open text resource '%s' outside the script editor state" % normalize_project_path(path),
{
"path": normalize_project_path(path),
"suggestion": "Close the file in Godot's script editor or pass force=true to overwrite it deliberately.",
}
)
return {}
func mark_current_scene_unsaved() -> void:
if EditorInterface.has_method("mark_scene_as_unsaved"):
EditorInterface.mark_scene_as_unsaved()
func add_child_with_undo(parent: Node, child: Node, root: Node, action_name: String) -> void:
var undo_redo := get_undo_redo()
undo_redo.create_action(action_name)
undo_redo.add_do_method(parent, "add_child", child)
undo_redo.add_do_method(child, "set_owner", root)
undo_redo.add_do_reference(child)
undo_redo.add_undo_method(parent, "remove_child", child)
undo_redo.commit_action()
func set_property_with_undo(target: Object, property: String, new_value: Variant, action_name: String) -> void:
var old_value: Variant = target.get(property)
var undo_redo := get_undo_redo()
undo_redo.create_action(action_name)
undo_redo.add_do_property(target, property, new_value)
if new_value is Resource:
undo_redo.add_do_reference(new_value)
undo_redo.add_undo_property(target, property, old_value)
if old_value is Resource:
undo_redo.add_undo_reference(old_value)
undo_redo.commit_action()
## Find node by path in edited scene
func find_node_by_path(node_path: String) -> Node:
var root := get_edited_root()
if root == null:
return null
if node_path == "." or node_path == root.name:
return root
# Try relative from root
if root.has_node(node_path):
return root.get_node(node_path)
# Try with root name prefix stripped
if node_path.begins_with(root.name + "/"):
var rel := node_path.substr(root.name.length() + 1)
if root.has_node(rel):
return root.get_node(rel)
return null

View File

@@ -0,0 +1,436 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
const PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd")
func get_commands() -> Dictionary:
return {
"find_nodes_by_type": _find_nodes_by_type,
"find_signal_connections": _find_signal_connections,
"batch_set_property": _batch_set_property,
"batch_add_nodes": _batch_add_nodes,
"find_node_references": _find_node_references,
"get_scene_dependencies": _get_scene_dependencies,
"cross_scene_set_property": _cross_scene_set_property,
}
func _find_nodes_by_type(params: Dictionary) -> Dictionary:
var result := require_string(params, "type")
if result[1] != null:
return result[1]
var type_name: String = result[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var recursive: bool = optional_bool(params, "recursive", true)
var matches: Array = []
_search_by_type(root, type_name, recursive, matches)
return success({"type": type_name, "matches": matches, "count": matches.size()})
func _search_by_type(node: Node, type_name: String, recursive: bool, matches: Array) -> void:
if node.is_class(type_name) or node.get_class() == type_name:
var root := get_edited_root()
matches.append({
"name": node.name,
"path": str(root.get_path_to(node)),
"type": node.get_class(),
})
if recursive:
for child in node.get_children():
_search_by_type(child, type_name, recursive, matches)
func _find_signal_connections(params: Dictionary) -> Dictionary:
var root := get_edited_root()
if root == null:
return error_no_scene()
var signal_filter: String = optional_string(params, "signal_name", "")
var node_filter: String = optional_string(params, "node_path", "")
var connections: Array = []
_collect_signals(root, root, signal_filter, node_filter, connections)
return success({"connections": connections, "count": connections.size()})
func _collect_signals(node: Node, root: Node, signal_filter: String, node_filter: String, connections: Array) -> void:
var node_path := str(root.get_path_to(node))
if node_filter.is_empty() or node_path.contains(node_filter):
for sig_info in node.get_signal_list():
var sig_name: String = sig_info["name"]
if not signal_filter.is_empty() and not sig_name.contains(signal_filter):
continue
for conn in node.get_signal_connection_list(sig_name):
connections.append({
"source": node_path,
"signal": sig_name,
"target": str(root.get_path_to(conn["callable"].get_object())),
"method": conn["callable"].get_method(),
})
for child in node.get_children():
_collect_signals(child, root, signal_filter, node_filter, connections)
func _batch_set_property(params: Dictionary) -> Dictionary:
var result := require_string(params, "type")
if result[1] != null:
return result[1]
var type_name: String = result[0]
var result2 := require_string(params, "property")
if result2[1] != null:
return result2[1]
var property: String = result2[0]
if not params.has("value"):
return error_invalid_params("Missing required parameter: value")
var value = params["value"]
# Parse value string
if value is String:
var s: String = value
var expr := Expression.new()
if expr.parse(s) == OK:
var parsed = expr.execute()
if parsed != null:
value = parsed
var root := get_edited_root()
if root == null:
return error_no_scene()
var affected: Array = []
var changes: Array = []
_batch_collect_property_changes(root, root, type_name, property, value, affected, changes)
if not changes.is_empty():
_apply_property_changes_with_undo(changes, property, "MCP: Batch set %s" % property)
return success({"property": property, "affected": affected, "count": affected.size()})
func _batch_collect_property_changes(node: Node, root: Node, type_name: String, property: String, value: Variant, affected: Array, changes: Array) -> void:
if node.is_class(type_name) or node.get_class() == type_name:
if property in node:
affected.append(str(root.get_path_to(node)))
changes.append({
"node": node,
"old_value": node.get(property),
"new_value": value,
})
for child in node.get_children():
_batch_collect_property_changes(child, root, type_name, property, value, affected, changes)
func _batch_add_nodes(params: Dictionary) -> Dictionary:
if not params.has("nodes") or not params["nodes"] is Array:
return error_invalid_params("Missing required parameter: nodes (Array)")
var nodes_data: Array = params["nodes"]
if nodes_data.is_empty():
return error_invalid_params("nodes array is empty")
var root := get_edited_root()
if root == null:
return error_no_scene()
var created: Array = []
var errors: Array = []
for i: int in nodes_data.size():
var entry: Dictionary = nodes_data[i]
if not entry.has("type") or not entry["type"] is String:
errors.append({"index": i, "error": "Missing or invalid 'type'"})
continue
var type: String = entry["type"]
if not ClassDB.class_exists(type):
errors.append({"index": i, "error": "Unknown node type: %s" % type})
continue
var parent_path: String = entry.get("parent_path", ".") if entry.has("parent_path") and entry["parent_path"] is String else "."
var node_name: String = entry.get("name", "") if entry.has("name") and entry["name"] is String else ""
var properties: Dictionary = entry.get("properties", {}) if entry.has("properties") and entry["properties"] is Dictionary else {}
var parent := find_node_by_path(parent_path)
if parent == null:
errors.append({"index": i, "error": "Parent node '%s' not found" % parent_path})
continue
var node: Node = ClassDB.instantiate(type)
if not node_name.is_empty():
node.name = node_name
for prop_name: String in properties:
var prop_exists := false
for prop in node.get_property_list():
if prop["name"] == prop_name:
prop_exists = true
break
if prop_exists:
var current: Variant = node.get(prop_name)
var target_type := typeof(current)
node.set(prop_name, PropertyParser.parse_value(properties[prop_name], target_type))
add_child_with_undo(parent, node, root, "MCP: Batch add %s" % type)
created.append({
"index": i,
"type": type,
"name": str(node.name),
"parent": parent_path,
"node_path": str(root.get_path_to(node)),
})
var result := {"created": created, "count": created.size()}
if not errors.is_empty():
result["errors"] = errors
return success(result)
func _find_node_references(params: Dictionary) -> Dictionary:
var result := require_string(params, "pattern")
if result[1] != null:
return result[1]
var pattern: String = result[0]
# Search through all .tscn and .gd files for references
var matches: Array = []
_search_files_for_pattern("res://", pattern, matches, 100)
return success({"pattern": pattern, "matches": matches, "count": matches.size()})
func _search_files_for_pattern(path: String, pattern: String, matches: Array, max_results: int) -> void:
if matches.size() >= max_results:
return
var dir := DirAccess.open(path)
if dir == null:
return
dir.list_dir_begin()
var file_name := dir.get_next()
while not file_name.is_empty() and matches.size() < max_results:
if file_name.begins_with("."):
file_name = dir.get_next()
continue
var full_path := path.path_join(file_name)
if dir.current_is_dir():
_search_files_for_pattern(full_path, pattern, matches, max_results)
elif file_name.get_extension() in ["tscn", "gd", "tres", "gdshader"]:
var file := FileAccess.open(full_path, FileAccess.READ)
if file:
var content := file.get_as_text()
file.close()
if content.contains(pattern):
# Find line numbers
var lines := content.split("\n")
var line_matches: Array = []
for i in lines.size():
if lines[i].contains(pattern):
line_matches.append(i + 1)
if line_matches.size() >= 5:
break
matches.append({
"file": full_path,
"lines": line_matches,
})
file_name = dir.get_next()
dir.list_dir_end()
func _cross_scene_set_property(params: Dictionary) -> Dictionary:
var result := require_string(params, "type")
if result[1] != null:
return result[1]
var type_name: String = result[0]
var result2 := require_string(params, "property")
if result2[1] != null:
return result2[1]
var property: String = result2[0]
if not params.has("value"):
return error_invalid_params("Missing required parameter: value")
var value = params["value"]
# Parse value string
if value is String:
var expr := Expression.new()
if expr.parse(value) == OK:
var parsed = expr.execute()
if parsed != null:
value = parsed
var path_filter: String = optional_string(params, "path_filter", "res://")
var exclude_addons: bool = optional_bool(params, "exclude_addons", true)
var force: bool = optional_bool(params, "force", false)
var dry_run: bool = optional_bool(params, "dry_run", not force)
if not dry_run and not force:
return error_invalid_params("cross_scene_set_property requires force=true when dry_run=false")
var scenes_affected: Array = []
var skipped_open_scenes: Array = []
var total_nodes: int = 0
var scene_files: Array = []
_collect_scene_files(path_filter, scene_files, exclude_addons)
for scene_path: String in scene_files:
var normalized_scene_path := normalize_project_path(scene_path)
if is_scene_path_open(normalized_scene_path):
if is_active_scene_path(normalized_scene_path) and force and not dry_run:
var root := get_edited_root()
var live_changes: Array = []
var live_affected_nodes: Array = []
_cross_scene_collect_changes(root, root, type_name, property, value, live_affected_nodes, live_changes)
if not live_changes.is_empty():
_apply_property_changes_with_undo(live_changes, property, "MCP: Cross-scene set %s" % property)
scenes_affected.append({
"scene": normalized_scene_path,
"nodes": live_affected_nodes,
"count": live_affected_nodes.size(),
"mode": "live_open_scene",
})
total_nodes += live_affected_nodes.size()
else:
var reason := "open scene skipped during dry_run" if dry_run else "open scene is not the active editor scene"
skipped_open_scenes.append({"scene": normalized_scene_path, "reason": reason})
continue
var packed: PackedScene = ResourceLoader.load(scene_path) as PackedScene
if packed == null:
continue
var instance: Node = packed.instantiate()
if instance == null:
continue
var affected_nodes: Array = []
var changes: Array = []
_cross_scene_collect_changes(instance, instance, type_name, property, value, affected_nodes, changes)
if not changes.is_empty():
if not dry_run:
var guard := guard_offline_scene_save(normalized_scene_path)
if not guard.is_empty():
instance.free()
return guard
for change: Dictionary in changes:
(change["node"] as Node).set(property, value)
# Pack and save
var new_packed := PackedScene.new()
var pack_err := new_packed.pack(instance)
if pack_err != OK:
instance.free()
return error_internal("Failed to pack scene '%s': %s" % [normalized_scene_path, error_string(pack_err)])
var save_err := ResourceSaver.save(new_packed, normalized_scene_path)
if save_err != OK:
instance.free()
return error_internal("Failed to save scene '%s': %s" % [normalized_scene_path, error_string(save_err)])
scenes_affected.append({
"scene": normalized_scene_path,
"nodes": affected_nodes,
"count": affected_nodes.size(),
"mode": "dry_run" if dry_run else "offline_saved",
})
total_nodes += affected_nodes.size()
instance.free()
# Rescan filesystem so editor picks up changes
if not scenes_affected.is_empty():
EditorInterface.get_resource_filesystem().scan()
return success({
"type": type_name,
"property": property,
"dry_run": dry_run,
"force": force,
"scenes_affected": scenes_affected,
"skipped_open_scenes": skipped_open_scenes,
"total_scenes": scenes_affected.size(),
"total_nodes": total_nodes,
"message": "Dry run only. Re-run with force=true and dry_run=false to write closed scenes and live-edit the active open scene." if dry_run else "Changes applied.",
})
func _collect_scene_files(path: String, files: Array, exclude_addons: bool) -> void:
var dir := DirAccess.open(path)
if dir == null:
return
dir.list_dir_begin()
var file_name := dir.get_next()
while not file_name.is_empty():
if file_name.begins_with("."):
file_name = dir.get_next()
continue
var full_path := path.path_join(file_name)
if dir.current_is_dir():
if exclude_addons and file_name == "addons":
file_name = dir.get_next()
continue
_collect_scene_files(full_path, files, exclude_addons)
elif file_name.get_extension() == "tscn":
files.append(full_path)
file_name = dir.get_next()
dir.list_dir_end()
func _cross_scene_collect_changes(node: Node, root: Node, type_name: String, property: String, value: Variant, affected: Array, changes: Array) -> void:
if node.is_class(type_name) or node.get_class() == type_name:
if property in node:
affected.append(str(root.get_path_to(node)))
changes.append({
"node": node,
"old_value": node.get(property),
"new_value": value,
})
for child in node.get_children():
_cross_scene_collect_changes(child, root, type_name, property, value, affected, changes)
func _apply_property_changes_with_undo(changes: Array, property: String, action_name: String) -> void:
var undo_redo := get_undo_redo()
undo_redo.create_action(action_name)
for change: Dictionary in changes:
var node: Node = change["node"]
undo_redo.add_do_property(node, property, change["new_value"])
undo_redo.add_undo_property(node, property, change["old_value"])
undo_redo.commit_action()
func _get_scene_dependencies(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
if not FileAccess.file_exists(path):
return error_not_found("File '%s'" % path)
var deps := ResourceLoader.get_dependencies(path)
var dependencies: Array = []
for dep: String in deps:
# Format: "path::type"
var parts := dep.split("::")
dependencies.append({
"path": parts[0] if parts.size() > 0 else dep,
"type": parts[2] if parts.size() > 2 else "",
})
return success({"path": path, "dependencies": dependencies, "count": dependencies.size()})

View File

@@ -0,0 +1,682 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
func get_commands() -> Dictionary:
return {
"get_editor_errors": _get_editor_errors,
"get_output_log": _get_output_log,
"get_editor_screenshot": _get_editor_screenshot,
"get_game_screenshot": _get_game_screenshot,
"execute_editor_script": _execute_editor_script,
"clear_output": _clear_output,
"reload_plugin": _reload_plugin,
"reload_project": _reload_project,
"get_signals": _get_signals,
"compare_screenshots": _compare_screenshots,
"set_auto_dismiss": _set_auto_dismiss,
"get_editor_camera": _get_editor_camera,
"set_editor_camera": _set_editor_camera,
}
func _get_editor_errors(params: Dictionary) -> Dictionary:
var errors: Array = []
var max_lines: int = optional_int(params, "max_lines", 50)
var base: Control = get_editor().get_base_control()
# 1. Read from the editor's Output panel (EditorLog RichTextLabel)
# This captures runtime errors, warnings, and print output
var editor_log: Node = base.find_child("Output", true, false)
if editor_log:
var rtl: RichTextLabel = _find_rtl(editor_log)
if rtl:
var content: String = rtl.get_parsed_text()
var lines: PackedStringArray = content.split("\n")
var start: int = maxi(0, lines.size() - max_lines)
for i in range(start, lines.size()):
var line: String = lines[i]
if line.contains("ERROR") or line.contains("SCRIPT ERROR") or line.contains("Parse Error") or line.contains("WARNING"):
errors.append(line.strip_edges())
# 2. Check the script editor for compile errors (red background lines)
# These don't appear in the Output panel
var script_errors: Array = []
var script_editor: ScriptEditor = EditorInterface.get_script_editor()
if script_editor:
var current_script: Script = script_editor.get_current_script()
var ce: CodeEdit = _find_code_edit(script_editor)
if ce and current_script:
var script_path: String = current_script.resource_path
for i in range(ce.get_line_count()):
var bg: Color = ce.get_line_background_color(i)
if bg.r > 0.8 and bg.a > 0: # Red-ish background = error
var line_text: String = ce.get_line(i).strip_edges()
script_errors.append("COMPILE ERROR: %s:%d - %s" % [script_path, i + 1, line_text])
# 3. Read from script editor error/warning panels (GDScript analyzer messages)
# Each open script editor has a VSplitContainer with two RichTextLabels:
# child[1] = warnings panel, child[2] = errors panel
var analyzer_errors: Array = []
if script_editor:
var open_editors: Array = script_editor.get_open_script_editors()
var open_scripts: Array = script_editor.get_open_scripts()
for ei in range(open_editors.size()):
var editor_node: Node = open_editors[ei]
var script_path: String = ""
if ei < open_scripts.size() and open_scripts[ei] != null:
script_path = (open_scripts[ei] as Resource).resource_path
var vsplit: VSplitContainer = null
for c in editor_node.get_children():
if c is VSplitContainer:
vsplit = c as VSplitContainer
break
if vsplit == null:
continue
var children: Array = vsplit.get_children()
# child[1] = warnings panel (RichTextLabel)
if children.size() > 1 and children[1] is RichTextLabel:
var text: String = (children[1] as RichTextLabel).get_parsed_text().strip_edges()
if not text.is_empty():
for line in text.split("\n"):
var stripped: String = line.strip_edges()
if stripped.is_empty() or stripped == "[Ignore]":
continue
# Remove leading "[Ignore]" prefix from warning lines
stripped = stripped.trim_prefix("[Ignore]")
var prefix: String = "WARNING: %s:" % script_path if not script_path.is_empty() else "WARNING: "
analyzer_errors.append(prefix + stripped)
# child[2] = errors panel (RichTextLabel)
if children.size() > 2 and children[2] is RichTextLabel:
var text: String = (children[2] as RichTextLabel).get_parsed_text().strip_edges()
if not text.is_empty():
for line in text.split("\n"):
var stripped: String = line.strip_edges()
if stripped.is_empty():
continue
var prefix: String = "SCRIPT ERROR: %s:" % script_path if not script_path.is_empty() else "SCRIPT ERROR: "
analyzer_errors.append(prefix + stripped)
# 4. Read from the debugger Errors tab (runtime errors/warnings)
# Path: ScriptEditorDebugger > TabContainer > "Errors" VBoxContainer > Tree
var debugger_errors: Array = []
var base2: Control = get_editor().get_base_control()
if base2:
var queue: Array[Node] = [base2]
while not queue.is_empty():
var node := queue.pop_front()
if node.get_class() == "ScriptEditorDebugger":
# Find TabContainer inside the debugger
for child in node.get_children():
if child is TabContainer:
var tab_container := child as TabContainer
for tab_idx in range(tab_container.get_tab_count()):
var tab_control: Control = tab_container.get_tab_control(tab_idx)
if tab_control is VBoxContainer and tab_control.name.begins_with("Errors"):
# Find Tree inside the Errors tab
for vchild in tab_control.get_children():
if vchild is Tree:
var tree := vchild as Tree
var root_item: TreeItem = tree.get_root()
if root_item:
var item: TreeItem = root_item.get_first_child()
while item:
var col0: String = item.get_text(0).strip_edges()
var col1: String = item.get_text(1).strip_edges()
if not col0.is_empty() or not col1.is_empty():
var msg: String = col0
if not col1.is_empty():
msg += " " + col1 if not msg.is_empty() else col1
debugger_errors.append("DEBUGGER: " + msg)
# Also check child items (expanded error details)
var sub: TreeItem = item.get_first_child()
while sub:
var sub0: String = sub.get_text(0).strip_edges()
var sub1: String = sub.get_text(1).strip_edges()
if not sub0.is_empty() or not sub1.is_empty():
var sub_msg: String = sub0
if not sub1.is_empty():
sub_msg += " " + sub1 if not sub_msg.is_empty() else sub1
debugger_errors.append("DEBUGGER: " + sub_msg)
sub = sub.get_next()
item = item.get_next()
break # Found Errors tab, stop searching tabs
break # Found TabContainer, stop searching debugger children
break # Found ScriptEditorDebugger, stop BFS
for child in node.get_children():
queue.append(child)
# Fallback: read from log file if Output panel not accessible
if errors.size() == 0 and script_errors.size() == 0 and analyzer_errors.size() == 0 and debugger_errors.size() == 0:
var log_path := "user://logs/godot.log"
if FileAccess.file_exists(log_path):
var file := FileAccess.open(log_path, FileAccess.READ)
if file:
var content := file.get_as_text()
file.close()
var lines := content.split("\n")
var start: int = maxi(0, lines.size() - max_lines)
for i in range(start, lines.size()):
var line: String = lines[i]
if line.contains("ERROR") or line.contains("SCRIPT ERROR"):
errors.append(line.strip_edges())
errors.append_array(script_errors)
errors.append_array(analyzer_errors)
errors.append_array(debugger_errors)
return success({"errors": errors, "count": errors.size()})
func _get_output_log(params: Dictionary) -> Dictionary:
var max_lines: int = optional_int(params, "max_lines", 100)
var filter: String = optional_string(params, "filter", "")
var base: Control = get_editor().get_base_control()
var editor_log: Node = base.find_child("Output", true, false)
if editor_log == null:
# Fallback: read from log file
var log_path := "user://logs/godot.log"
if not FileAccess.file_exists(log_path):
return error_internal("Output panel not found and no log file available")
var file := FileAccess.open(log_path, FileAccess.READ)
if file == null:
return error_internal("Cannot read log file")
var content := file.get_as_text()
file.close()
var lines := content.split("\n")
var start: int = maxi(0, lines.size() - max_lines)
var output_lines: Array = []
for i in range(start, lines.size()):
var line: String = lines[i]
if filter.is_empty() or line.contains(filter):
output_lines.append(line)
return success({"lines": output_lines, "count": output_lines.size(), "source": "log_file"})
var rtl: RichTextLabel = _find_rtl(editor_log)
if rtl == null:
return error_internal("Could not find RichTextLabel in Output panel")
var content: String = rtl.get_parsed_text()
var all_lines: PackedStringArray = content.split("\n")
var start: int = maxi(0, all_lines.size() - max_lines)
var output_lines: Array = []
for i in range(start, all_lines.size()):
var line: String = all_lines[i]
if filter.is_empty() or line.contains(filter):
output_lines.append(line)
return success({"lines": output_lines, "count": output_lines.size(), "source": "output_panel"})
func _find_code_edit(node: Node, depth: int = 0) -> CodeEdit:
if depth > 8:
return null
if node is CodeEdit:
return node as CodeEdit
for child in node.get_children():
var found: CodeEdit = _find_code_edit(child, depth + 1)
if found:
return found
return null
func _find_rtl(node: Node, depth: int = 0) -> RichTextLabel:
if depth > 6:
return null
if node is RichTextLabel:
return node as RichTextLabel
for child in node.get_children():
var found: RichTextLabel = _find_rtl(child, depth + 1)
if found:
return found
return null
func _get_editor_screenshot(params: Dictionary) -> Dictionary:
# Capture the editor's main viewport - no await to avoid timeout
var base_control: Control = get_editor().get_base_control()
if base_control == null:
return error_internal("Could not access editor base control")
var viewport: Viewport = base_control.get_viewport()
if viewport == null:
return error_internal("Could not access editor viewport")
var texture: ViewportTexture = viewport.get_texture()
if texture == null:
return error_internal("Could not get viewport texture")
var image: Image = texture.get_image()
if image == null:
return error_internal("Could not get image from viewport")
var save_path: String = params.get("save_path", "")
if save_path != "":
var abs_path := _resolve_save_path(save_path)
var err := image.save_png(abs_path)
if err != OK:
return error_internal("Failed to save screenshot: %s" % error_string(err))
return success({
"saved_path": save_path,
"width": image.get_width(),
"height": image.get_height(),
"format": "png",
})
var png_buffer := image.save_png_to_buffer()
var base64 := Marshalls.raw_to_base64(png_buffer)
return success({
"image_base64": base64,
"width": image.get_width(),
"height": image.get_height(),
"format": "png",
})
func _get_game_screenshot(params: Dictionary) -> Dictionary:
var ei := get_editor()
if not ei.is_playing_scene():
return error(-32000, "No scene is currently playing", {"suggestion": "Use play_scene first"})
# Communicate with the game process via file system
var user_dir := get_game_user_dir()
var request_path := user_dir + "/mcp_screenshot_request"
var screenshot_path := user_dir + "/mcp_screenshot.png"
# Clean up any stale screenshot file
if FileAccess.file_exists(screenshot_path):
DirAccess.remove_absolute(screenshot_path)
# Create the request file to signal the game process
var req := FileAccess.open(request_path, FileAccess.WRITE)
if req == null:
return error_internal("Could not create screenshot request file")
req.close()
# Poll for the screenshot file (max 3 seconds, 0.1s interval)
var attempts := 30
while attempts > 0:
await get_tree().create_timer(0.1).timeout
if FileAccess.file_exists(screenshot_path):
break
attempts -= 1
if not FileAccess.file_exists(screenshot_path):
# Clean up request file if it still exists
if FileAccess.file_exists(request_path):
DirAccess.remove_absolute(request_path)
return error(-32000, "Screenshot timed out", {
"suggestion": "Ensure the game is running and MCPScreenshot autoload is active",
})
# Load the PNG file
var image := Image.new()
var err := image.load(screenshot_path)
if err != OK:
DirAccess.remove_absolute(screenshot_path)
return error_internal("Failed to load screenshot: %s" % error_string(err))
# Clean up temp file
DirAccess.remove_absolute(screenshot_path)
var save_path_param: String = params.get("save_path", "")
if save_path_param != "":
var abs_path := _resolve_save_path(save_path_param)
var save_err := image.save_png(abs_path)
if save_err != OK:
return error_internal("Failed to save screenshot: %s" % error_string(save_err))
return success({
"saved_path": save_path_param,
"width": image.get_width(),
"height": image.get_height(),
"format": "png",
})
var png_buffer := image.save_png_to_buffer()
var base64 := Marshalls.raw_to_base64(png_buffer)
return success({
"image_base64": base64,
"width": image.get_width(),
"height": image.get_height(),
"format": "png",
})
func _resolve_save_path(path: String) -> String:
if path.begins_with("res://") or path.begins_with("user://"):
return ProjectSettings.globalize_path(path)
return path
func _execute_editor_script(params: Dictionary) -> Dictionary:
var result := require_string(params, "code")
if result[1] != null:
return result[1]
var code: String = result[0]
var allow_unsafe_editor_io: bool = optional_bool(params, "allow_unsafe_editor_io", false)
var unsafe_guard := _guard_editor_script_file_io(code, allow_unsafe_editor_io)
if not unsafe_guard.is_empty():
return unsafe_guard
# Wrap user code in a @tool script
var wrapped_code := """@tool
extends Node
var _mcp_output: Array = []
func _mcp_print(value: Variant) -> void:
_mcp_output.append(str(value))
func run() -> Variant:
# User code begins
%s
# User code ends
return _mcp_output
""" % _indent_code(code)
# Create a temporary script
var script := GDScript.new()
script.source_code = wrapped_code
var err := script.reload()
if err != OK:
return error(-32002, "Script compilation failed", {
"error": error_string(err),
"code": wrapped_code,
})
# Create temp node and execute
var temp_node := Node.new()
temp_node.set_script(script)
add_child(temp_node)
var output: Variant = null
# Execute with error handling
if temp_node.has_method("run"):
output = temp_node.run()
var mcp_output: Array = []
var raw_output: Variant = temp_node.get("_mcp_output")
if raw_output is Array:
mcp_output = raw_output
# Cleanup
temp_node.queue_free()
return success({
"output": mcp_output,
"return_value": str(output) if output != null else null,
})
func _guard_editor_script_file_io(code: String, allow_unsafe_editor_io: bool) -> Dictionary:
if allow_unsafe_editor_io:
return {}
var compact := code.replace(" ", "").replace("\t", "").replace("\n", "")
var unsafe_patterns: Array[String] = []
if compact.contains("ResourceSaver.save("):
unsafe_patterns.append("ResourceSaver.save")
if compact.contains("ProjectSettings.save("):
unsafe_patterns.append("ProjectSettings.save")
if compact.contains("ConfigFile.save("):
unsafe_patterns.append("ConfigFile.save")
if compact.contains("FileAccess.open(") and _contains_any(compact, ["FileAccess.WRITE", "FileAccess.READ_WRITE", "FileAccess.WRITE_READ"]):
unsafe_patterns.append("FileAccess.open WRITE")
if _contains_any(compact, ["DirAccess.remove_absolute(", "DirAccess.rename_absolute(", "DirAccess.copy_absolute(", "DirAccess.make_dir_absolute(", "DirAccess.make_dir_recursive_absolute("]):
unsafe_patterns.append("DirAccess filesystem mutation")
if unsafe_patterns.is_empty():
return {}
return error_conflict(
"Refusing to execute editor script with direct file/resource write APIs",
{
"unsafe_patterns": unsafe_patterns,
"open_scenes": get_open_scene_paths(),
"suggestion": "Use dedicated MCP commands and save_scene for editor-owned resources, or pass allow_unsafe_editor_io=true only when no open editor resource can be overwritten.",
}
)
func _contains_any(value: String, needles: Array[String]) -> bool:
for needle: String in needles:
if value.contains(needle):
return true
return false
func _indent_code(code: String) -> String:
var lines := code.split("\n")
var indented: PackedStringArray = []
for line in lines:
indented.append("\t" + line)
return "\n".join(indented)
func _clear_output(params: Dictionary) -> Dictionary:
print("\n".repeat(50))
return success({"cleared": true})
func _reload_plugin(params: Dictionary) -> Dictionary:
# Disable and re-enable this plugin to reload all scripts
var plugin_name := "godot_mcp"
var ei := get_editor()
# Send success BEFORE reloading (connection will briefly drop)
# Use call_deferred so the response is sent first
_deferred_reload_plugin.call_deferred(ei, plugin_name)
return success({"reloading": true, "message": "Plugin will reload momentarily. Connection will briefly drop and auto-reconnect."})
func _deferred_reload_plugin(ei: EditorInterface, plugin_name: String) -> void:
ei.set_plugin_enabled(plugin_name, false)
ei.set_plugin_enabled(plugin_name, true)
print("[MCP] Plugin reloaded")
func _reload_project(params: Dictionary) -> Dictionary:
# Rescan filesystem and reload changed scripts
var ei := get_editor()
ei.get_resource_filesystem().scan()
return success({"reloaded": true, "message": "Filesystem rescanned."})
func _get_signals(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path)
var signals: Array = []
for sig in node.get_signal_list():
var sig_info: Dictionary = {
"name": sig["name"],
"args": [],
}
for arg in sig["args"]:
sig_info["args"].append({"name": arg["name"], "type": arg["type"]})
# Get connections for this signal
var connections: Array = []
for conn in node.get_signal_connection_list(sig["name"]):
connections.append({
"target": str(root.get_path_to(conn["callable"].get_object())),
"method": conn["callable"].get_method(),
})
sig_info["connections"] = connections
signals.append(sig_info)
return success({
"node_path": str(root.get_path_to(node)),
"type": node.get_class(),
"signals": signals,
"count": signals.size(),
})
func _load_image_from_param(value: String, label: String) -> Array:
## Returns [Image, null] on success or [null, error_dict] on failure.
## Accepts a file path (res://, user://) or raw base64 PNG data.
var img := Image.new()
if value.begins_with("res://") or value.begins_with("user://"):
var err := img.load(value)
if err != OK:
return [null, error_invalid_params("Failed to load %s from path '%s': %s" % [label, value, error_string(err)])]
return [img, null]
# Treat as base64 PNG
var buf := Marshalls.base64_to_raw(value)
var err := img.load_png_from_buffer(buf)
if err != OK:
return [null, error_invalid_params("Failed to decode %s from base64: %s" % [label, error_string(err)])]
return [img, null]
func _compare_screenshots(params: Dictionary) -> Dictionary:
var result := require_string(params, "image_a")
if result[1] != null:
return result[1]
var image_a_value: String = result[0]
var result2 := require_string(params, "image_b")
if result2[1] != null:
return result2[1]
var image_b_value: String = result2[0]
var threshold: int = optional_int(params, "threshold", 10)
# Load images (from path or base64)
var load_a := _load_image_from_param(image_a_value, "image_a")
if load_a[1] != null:
return load_a[1]
var img_a: Image = load_a[0]
var load_b := _load_image_from_param(image_b_value, "image_b")
if load_b[1] != null:
return load_b[1]
var img_b: Image = load_b[0]
if img_a.get_size() != img_b.get_size():
return error_invalid_params("Image sizes differ: %s vs %s" % [str(img_a.get_size()), str(img_b.get_size())])
var width := img_a.get_width()
var height := img_a.get_height()
var diff_image := Image.create(width, height, false, Image.FORMAT_RGBA8)
var changed_pixels: int = 0
var total_pixels: int = width * height
for y in height:
for x in width:
var ca: Color = img_a.get_pixel(x, y)
var cb: Color = img_b.get_pixel(x, y)
var dr := absi(int(ca.r8) - int(cb.r8))
var dg := absi(int(ca.g8) - int(cb.g8))
var db := absi(int(ca.b8) - int(cb.b8))
var max_diff := maxi(dr, maxi(dg, db))
if max_diff > threshold:
changed_pixels += 1
# Red highlight for changed pixels
diff_image.set_pixel(x, y, Color(1, 0, 0, clampf(float(max_diff) / 255.0, 0.3, 1.0)))
else:
# Dim version of original
diff_image.set_pixel(x, y, Color(ca.r * 0.3, ca.g * 0.3, ca.b * 0.3, 1.0))
var diff_percentage: float = (float(changed_pixels) / float(total_pixels)) * 100.0
var identical: bool = changed_pixels == 0
# Encode diff image
var diff_png := diff_image.save_png_to_buffer()
var diff_base64 := Marshalls.raw_to_base64(diff_png)
return success({
"identical": identical,
"changed_pixels": changed_pixels,
"total_pixels": total_pixels,
"diff_percentage": snappedf(diff_percentage, 0.01),
"threshold": threshold,
"width": width,
"height": height,
"diff_image_base64": diff_base64,
})
func _get_editor_camera(_params: Dictionary) -> Dictionary:
var vp3d := EditorInterface.get_editor_viewport_3d()
var cam := vp3d.get_camera_3d() if vp3d else null
if not cam:
return error(-32000, "No 3D editor camera found", {
"suggestion": "Make sure a 3D scene is open in the editor",
})
var pos := cam.global_position
var rot := cam.rotation_degrees
return success({
"position": {"x": pos.x, "y": pos.y, "z": pos.z},
"rotation_degrees": {"x": rot.x, "y": rot.y, "z": rot.z},
"fov": cam.fov,
"near": cam.near,
"far": cam.far,
})
func _set_editor_camera(params: Dictionary) -> Dictionary:
var vp3d := EditorInterface.get_editor_viewport_3d()
var cam := vp3d.get_camera_3d() if vp3d else null
if not cam:
return error(-32000, "No 3D editor camera found", {
"suggestion": "Make sure a 3D scene is open in the editor",
})
# Set position
if params.has("position"):
var p: Dictionary = params["position"]
cam.global_position = Vector3(
float(p.get("x", cam.global_position.x)),
float(p.get("y", cam.global_position.y)),
float(p.get("z", cam.global_position.z)),
)
# Set rotation
if params.has("rotation_degrees"):
var r: Dictionary = params["rotation_degrees"]
cam.rotation_degrees = Vector3(
float(r.get("x", cam.rotation_degrees.x)),
float(r.get("y", cam.rotation_degrees.y)),
float(r.get("z", cam.rotation_degrees.z)),
)
# Look at target (overrides rotation if set)
if params.has("look_at"):
var t: Dictionary = params["look_at"]
cam.look_at(Vector3(float(t.get("x", 0)), float(t.get("y", 0)), float(t.get("z", 0))))
# Set FOV
if params.has("fov"):
cam.fov = float(params["fov"])
var pos := cam.global_position
var rot := cam.rotation_degrees
return success({
"position": {"x": pos.x, "y": pos.y, "z": pos.z},
"rotation_degrees": {"x": rot.x, "y": rot.y, "z": rot.z},
"fov": cam.fov,
})
func _set_auto_dismiss(params: Dictionary) -> Dictionary:
var enabled: bool = params.get("enabled", true)
editor_plugin.auto_dismiss_dialogs = enabled
return success({
"auto_dismiss": enabled,
"message": "Auto-dismiss dialogs %s" % ("enabled" if enabled else "disabled"),
})

View File

@@ -0,0 +1,117 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
func get_commands() -> Dictionary:
return {
"list_export_presets": _list_export_presets,
"export_project": _export_project,
"get_export_info": _get_export_info,
}
func _list_export_presets(params: Dictionary) -> Dictionary:
# Read export_presets.cfg
var presets_path := "res://export_presets.cfg"
if not FileAccess.file_exists(presets_path):
return success({"presets": [], "count": 0, "message": "No export_presets.cfg found"})
var cfg := ConfigFile.new()
var err := cfg.load(presets_path)
if err != OK:
return error_internal("Failed to read export_presets.cfg: %s" % error_string(err))
var presets: Array = []
var idx := 0
while cfg.has_section("preset.%d" % idx):
var section := "preset.%d" % idx
presets.append({
"index": idx,
"name": cfg.get_value(section, "name", ""),
"platform": cfg.get_value(section, "platform", ""),
"runnable": cfg.get_value(section, "runnable", false),
"export_path": cfg.get_value(section, "export_path", ""),
})
idx += 1
return success({"presets": presets, "count": presets.size()})
func _export_project(params: Dictionary) -> Dictionary:
var preset_index: int = optional_int(params, "preset_index", -1)
var preset_name: String = optional_string(params, "preset_name", "")
var debug: bool = optional_bool(params, "debug", true)
# Find preset
var presets_path := "res://export_presets.cfg"
if not FileAccess.file_exists(presets_path):
return error(-32000, "No export_presets.cfg found. Configure exports in Project > Export first.")
var cfg := ConfigFile.new()
var err := cfg.load(presets_path)
if err != OK:
return error_internal("Failed to read export_presets.cfg")
# Find by name or index
var target_section := ""
var target_name := ""
var target_path := ""
if not preset_name.is_empty():
var idx := 0
while cfg.has_section("preset.%d" % idx):
var section := "preset.%d" % idx
if cfg.get_value(section, "name", "") == preset_name:
target_section = section
target_name = preset_name
target_path = cfg.get_value(section, "export_path", "")
break
idx += 1
elif preset_index >= 0:
var section := "preset.%d" % preset_index
if cfg.has_section(section):
target_section = section
target_name = cfg.get_value(section, "name", "")
target_path = cfg.get_value(section, "export_path", "")
if target_section.is_empty():
return error_not_found("Export preset")
if target_path.is_empty():
return error(-32000, "Export path not configured for preset '%s'" % target_name)
# Use EditorExportPlatform via command line
# We can't directly call export from the plugin, so we return the command to run
var godot_path := OS.get_executable_path()
var project_path := ProjectSettings.globalize_path("res://")
var export_path := ProjectSettings.globalize_path(target_path) if target_path.begins_with("res://") else target_path
var flag := "--export-debug" if debug else "--export-release"
var command := '"%s" --headless --path "%s" %s "%s"' % [godot_path, project_path, flag, target_name]
return success({
"preset": target_name,
"export_path": export_path,
"debug": debug,
"command": command,
"message": "Run the command above to export. Direct export from editor plugin is not supported in Godot 4.",
})
func _get_export_info(params: Dictionary) -> Dictionary:
# General export-related project info
var info := {}
# Check if export_presets.cfg exists
info["has_export_presets"] = FileAccess.file_exists("res://export_presets.cfg")
# Get Godot executable path (useful for command-line exports)
info["godot_executable"] = OS.get_executable_path()
info["project_path"] = ProjectSettings.globalize_path("res://")
# Check for common export templates
var templates_path := OS.get_data_dir().path_join("export_templates")
info["templates_dir"] = templates_path
info["templates_installed"] = DirAccess.dir_exists_absolute(templates_path)
return success(info)

View File

@@ -0,0 +1,162 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
const COMMANDS_PATH := "user://mcp_input_commands"
func get_commands() -> Dictionary:
return {
"simulate_key": _simulate_key,
"simulate_mouse_click": _simulate_mouse_click,
"simulate_mouse_move": _simulate_mouse_move,
"simulate_action": _simulate_action,
"simulate_sequence": _simulate_sequence,
}
func _simulate_key(params: Dictionary) -> Dictionary:
var result := require_string(params, "keycode")
if result[1] != null:
return result[1]
var keycode: String = result[0]
var pressed: bool = optional_bool(params, "pressed", true)
var shift: bool = optional_bool(params, "shift", false)
var ctrl: bool = optional_bool(params, "ctrl", false)
var alt: bool = optional_bool(params, "alt", false)
var event := {
"type": "key",
"keycode": keycode,
"pressed": pressed,
"shift": shift,
"ctrl": ctrl,
"alt": alt,
}
_write_commands([event])
return success({"sent": true, "event": event})
func _simulate_mouse_click(params: Dictionary) -> Dictionary:
var button: int = optional_int(params, "button", 1) # MOUSE_BUTTON_LEFT
var pressed: bool = optional_bool(params, "pressed", true)
var double_click: bool = optional_bool(params, "double_click", false)
var auto_release: bool = optional_bool(params, "auto_release", true)
var x: float = float(params.get("x", 0))
var y: float = float(params.get("y", 0))
var press_event := {
"type": "mouse_button",
"button": button,
"pressed": pressed,
"double_click": double_click,
"position": {"x": x, "y": y},
}
# Auto-release: send press + release in sequence so UI buttons actually fire
if pressed and auto_release:
var release_event := press_event.duplicate()
release_event["pressed"] = false
var sequence_data := {
"sequence_events": [press_event, release_event],
"frame_delay": 1,
}
var json := JSON.stringify(sequence_data)
var file := FileAccess.open(COMMANDS_PATH, FileAccess.WRITE)
if file == null:
return error_internal("Failed to write commands: %s" % error_string(FileAccess.get_open_error()))
file.store_string(json)
file.close()
return success({"sent": true, "event": press_event, "auto_release": true})
_write_commands([press_event])
return success({"sent": true, "event": press_event})
func _simulate_mouse_move(params: Dictionary) -> Dictionary:
var x: float = float(params.get("x", 0))
var y: float = float(params.get("y", 0))
var rel_x: float = float(params.get("relative_x", 0))
var rel_y: float = float(params.get("relative_y", 0))
var button_mask: int = optional_int(params, "button_mask", 0)
var unhandled_explicit: bool = params.has("unhandled")
var unhandled: bool = optional_bool(params, "unhandled", false)
var event := {
"type": "mouse_motion",
"position": {"x": x, "y": y},
"relative": {"x": rel_x, "y": rel_y},
"button_mask": button_mask,
}
# Auto-enable unhandled for drag motions (camera-pan use case) ONLY when
# the caller did NOT explicitly pass an "unhandled" key. If they passed
# one — true or false — honor it. This lets UI drag-and-drop tests opt
# back into normal GUI dispatch by passing unhandled: false explicitly.
if unhandled_explicit:
event["unhandled"] = unhandled
elif button_mask > 0:
event["unhandled"] = true
_write_commands([event])
return success({"sent": true, "event": event})
func _simulate_action(params: Dictionary) -> Dictionary:
var result := require_string(params, "action")
if result[1] != null:
return result[1]
var action_name: String = result[0]
var pressed: bool = optional_bool(params, "pressed", true)
var strength: float = float(params.get("strength", 1.0))
var event := {
"type": "action",
"action": action_name,
"pressed": pressed,
"strength": strength,
}
_write_commands([event])
return success({"sent": true, "event": event})
func _simulate_sequence(params: Dictionary) -> Dictionary:
if not params.has("events") or not params["events"] is Array:
return error_invalid_params("Missing required parameter: events (Array)")
var events: Array = params["events"]
if events.is_empty():
return error_invalid_params("Events array is empty")
var frame_delay: int = optional_int(params, "frame_delay", 1)
for event_data: Dictionary in events:
if not event_data.has("type") or (event_data["type"] as String).is_empty():
return error_invalid_params("Invalid event in sequence: %s" % str(event_data))
if frame_delay <= 0:
# All events in one frame - write as plain array
_write_commands(events)
else:
# Sequence with frame delay - game side handles timing
var sequence_data := {
"sequence_events": events,
"frame_delay": frame_delay,
}
var json := JSON.stringify(sequence_data)
var file := FileAccess.open(COMMANDS_PATH, FileAccess.WRITE)
if file == null:
return error_internal("Failed to write commands: %s" % error_string(FileAccess.get_open_error()))
file.store_string(json)
file.close()
return success({"sent": true, "event_count": events.size(), "frame_delay": frame_delay})
func _write_commands(events: Array) -> void:
var json := JSON.stringify(events)
var file := FileAccess.open(COMMANDS_PATH, FileAccess.WRITE)
if file == null:
push_error("[MCP Input] Failed to write commands: %s" % error_string(FileAccess.get_open_error()))
return
file.store_string(json)
file.close()

View File

@@ -0,0 +1,151 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
func get_commands() -> Dictionary:
return {
"get_input_actions": _get_input_actions,
"set_input_action": _set_input_action,
}
func _get_input_actions(params: Dictionary) -> Dictionary:
var filter: String = optional_string(params, "filter", "")
var include_builtin: bool = optional_bool(params, "include_builtin", false)
var actions: Dictionary = {}
for action: StringName in InputMap.get_actions():
var action_str := str(action)
# Skip built-in UI actions unless requested
if not include_builtin and action_str.begins_with("ui_"):
continue
# Apply filter
if not filter.is_empty() and not action_str.contains(filter):
continue
var events: Array = []
for event: InputEvent in InputMap.action_get_events(action):
events.append(_serialize_event(event))
actions[action_str] = {
"deadzone": InputMap.action_get_deadzone(action),
"events": events,
}
return success({"actions": actions, "count": actions.size()})
func _set_input_action(params: Dictionary) -> Dictionary:
var result := require_string(params, "action")
if result[1] != null:
return result[1]
var action_name: String = result[0]
if not params.has("events") or not params["events"] is Array:
return error_invalid_params("'events' array is required")
var event_defs: Array = params["events"]
var deadzone: float = float(params.get("deadzone", 0.5))
# Build the events array
var events: Array[InputEvent] = []
for event_def in event_defs:
if not event_def is Dictionary:
continue
var event := _parse_event(event_def)
if event != null:
events.append(event)
# Save to ProjectSettings
var setting_value := {
"deadzone": deadzone,
"events": events,
}
ProjectSettings.set_setting("input/" + action_name, setting_value)
var err := ProjectSettings.save()
if err != OK:
return error_internal("Failed to save project settings: %s" % error_string(err))
# Also update the runtime InputMap
if not InputMap.has_action(action_name):
InputMap.add_action(action_name, deadzone)
else:
InputMap.action_set_deadzone(action_name, deadzone)
InputMap.action_erase_events(action_name)
for event in events:
InputMap.action_add_event(action_name, event)
return success({
"action": action_name,
"deadzone": deadzone,
"events_count": events.size(),
"saved": true,
})
func _serialize_event(event: InputEvent) -> Dictionary:
if event is InputEventKey:
var key_event: InputEventKey = event
var info := {
"type": "key",
"keycode": OS.get_keycode_string(key_event.keycode) if key_event.keycode != KEY_NONE else "",
"physical_keycode": OS.get_keycode_string(key_event.physical_keycode) if key_event.physical_keycode != KEY_NONE else "",
}
if key_event.ctrl_pressed: info["ctrl"] = true
if key_event.shift_pressed: info["shift"] = true
if key_event.alt_pressed: info["alt"] = true
if key_event.meta_pressed: info["meta"] = true
return info
elif event is InputEventMouseButton:
var mb_event: InputEventMouseButton = event
return {
"type": "mouse_button",
"button_index": mb_event.button_index,
}
elif event is InputEventJoypadButton:
var jb_event: InputEventJoypadButton = event
return {
"type": "joypad_button",
"button_index": jb_event.button_index,
}
elif event is InputEventJoypadMotion:
var jm_event: InputEventJoypadMotion = event
return {
"type": "joypad_motion",
"axis": jm_event.axis,
"axis_value": jm_event.axis_value,
}
return {"type": event.get_class()}
func _parse_event(def: Dictionary) -> InputEvent:
var type: String = def.get("type", "")
match type:
"key":
var event := InputEventKey.new()
var keycode_str: String = def.get("keycode", "")
if not keycode_str.is_empty():
event.keycode = OS.find_keycode_from_string(keycode_str)
var phys_str: String = def.get("physical_keycode", "")
if not phys_str.is_empty():
event.physical_keycode = OS.find_keycode_from_string(phys_str)
event.ctrl_pressed = def.get("ctrl", false)
event.shift_pressed = def.get("shift", false)
event.alt_pressed = def.get("alt", false)
event.meta_pressed = def.get("meta", false)
return event
"mouse_button":
var event := InputEventMouseButton.new()
event.button_index = int(def.get("button_index", 1))
return event
"joypad_button":
var event := InputEventJoypadButton.new()
event.button_index = int(def.get("button_index", 0))
return event
"joypad_motion":
var event := InputEventJoypadMotion.new()
event.axis = int(def.get("axis", 0))
event.axis_value = float(def.get("axis_value", 1.0))
return event
return null

View File

@@ -0,0 +1,472 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
func get_commands() -> Dictionary:
return {
"setup_navigation_region": _setup_navigation_region,
"bake_navigation_mesh": _bake_navigation_mesh,
"setup_navigation_agent": _setup_navigation_agent,
"set_navigation_layers": _set_navigation_layers,
"get_navigation_info": _get_navigation_info,
}
func _is_3d_context(node: Node) -> bool:
if node is Node3D:
return true
if node is Node2D:
return false
# Walk up to detect context
var parent := node.get_parent()
while parent != null:
if parent is Node3D:
return true
if parent is Node2D:
return false
parent = parent.get_parent()
return false
func _setup_navigation_region(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node at '%s'" % node_path)
var root := get_edited_root()
if root == null:
return error_no_scene()
var force_mode: String = optional_string(params, "mode", "auto")
var is_3d: bool
match force_mode:
"2d": is_3d = false
"3d": is_3d = true
_: is_3d = _is_3d_context(node)
if is_3d:
var region := NavigationRegion3D.new()
region.name = optional_string(params, "name", "NavigationRegion3D")
var nav_mesh := NavigationMesh.new()
nav_mesh.agent_radius = float(params.get("agent_radius", 0.5))
nav_mesh.agent_height = float(params.get("agent_height", 1.5))
nav_mesh.agent_max_climb = float(params.get("agent_max_climb", 0.25))
nav_mesh.agent_max_slope = float(params.get("agent_max_slope", 45.0))
nav_mesh.cell_size = float(params.get("cell_size", 0.25))
nav_mesh.cell_height = float(params.get("cell_height", 0.25))
region.navigation_mesh = nav_mesh
if params.has("navigation_layers"):
region.navigation_layers = int(params["navigation_layers"])
add_child_with_undo(node, region, root, "MCP: Add NavigationRegion3D")
return success({
"node_path": str(region.get_path()),
"type": "NavigationRegion3D",
"agent_radius": nav_mesh.agent_radius,
"agent_height": nav_mesh.agent_height,
"cell_size": nav_mesh.cell_size,
"created": true,
})
else:
var region := NavigationRegion2D.new()
region.name = optional_string(params, "name", "NavigationRegion2D")
var nav_poly := NavigationPolygon.new()
# Set parsed geometry source if available
if params.has("source_geometry_mode"):
var mode_str: String = str(params["source_geometry_mode"])
match mode_str:
"root_node": nav_poly.source_geometry_mode = NavigationPolygon.SOURCE_GEOMETRY_ROOT_NODE_CHILDREN
"groups_with_children": nav_poly.source_geometry_mode = NavigationPolygon.SOURCE_GEOMETRY_GROUPS_WITH_CHILDREN
"groups_explicit": nav_poly.source_geometry_mode = NavigationPolygon.SOURCE_GEOMETRY_GROUPS_EXPLICIT
if params.has("cell_size"):
nav_poly.cell_size = float(params["cell_size"])
if params.has("agent_radius"):
nav_poly.agent_radius = float(params["agent_radius"])
region.navigation_polygon = nav_poly
if params.has("navigation_layers"):
region.navigation_layers = int(params["navigation_layers"])
add_child_with_undo(node, region, root, "MCP: Add NavigationRegion2D")
return success({
"node_path": str(region.get_path()),
"type": "NavigationRegion2D",
"cell_size": nav_poly.cell_size,
"created": true,
})
func _bake_navigation_mesh(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node at '%s'" % node_path)
var root := get_edited_root()
if root == null:
return error_no_scene()
if node is NavigationRegion3D:
var region: NavigationRegion3D = node as NavigationRegion3D
if region.navigation_mesh == null:
return error_invalid_params("NavigationRegion3D has no NavigationMesh resource")
region.bake_navigation_mesh()
mark_current_scene_unsaved()
return success({
"node_path": node_path,
"type": "NavigationRegion3D",
"baked": true,
})
elif node is NavigationRegion2D:
var region: NavigationRegion2D = node as NavigationRegion2D
if region.navigation_polygon == null:
var nav_poly := NavigationPolygon.new()
region.navigation_polygon = nav_poly
# Set outline vertices from params
if params.has("outline"):
var outline_data: Array = params["outline"]
var outline := PackedVector2Array()
for point in outline_data:
if point is Array and point.size() >= 2:
outline.append(Vector2(float(point[0]), float(point[1])))
elif point is Dictionary:
outline.append(Vector2(float(point.get("x", 0)), float(point.get("y", 0))))
if outline.size() >= 3:
# Clear existing outlines
while region.navigation_polygon.get_outline_count() > 0:
region.navigation_polygon.remove_outline(0)
region.navigation_polygon.add_outline(outline)
region.navigation_polygon.make_polygons_from_outlines()
mark_current_scene_unsaved()
return success({
"node_path": node_path,
"type": "NavigationRegion2D",
"outline_vertices": outline.size(),
"baked": true,
})
else:
return error_invalid_params("Outline must have at least 3 vertices")
else:
# Try baking from source geometry
region.bake_navigation_polygon()
mark_current_scene_unsaved()
return success({
"node_path": node_path,
"type": "NavigationRegion2D",
"baked": true,
})
return error_invalid_params("Node '%s' is not a NavigationRegion2D or NavigationRegion3D" % node_path)
func _setup_navigation_agent(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node at '%s'" % node_path)
var root := get_edited_root()
if root == null:
return error_no_scene()
var force_mode: String = optional_string(params, "mode", "auto")
var is_3d: bool
match force_mode:
"2d": is_3d = false
"3d": is_3d = true
_: is_3d = _is_3d_context(node)
var agent_name: String = optional_string(params, "name", "NavigationAgent3D" if is_3d else "NavigationAgent2D")
if is_3d:
var agent := NavigationAgent3D.new()
agent.name = agent_name
if params.has("path_desired_distance"):
agent.path_desired_distance = float(params["path_desired_distance"])
if params.has("target_desired_distance"):
agent.target_desired_distance = float(params["target_desired_distance"])
if params.has("radius"):
agent.radius = float(params["radius"])
if params.has("neighbor_distance"):
agent.neighbor_distance = float(params["neighbor_distance"])
if params.has("max_neighbors"):
agent.max_neighbors = int(params["max_neighbors"])
if params.has("max_speed"):
agent.max_speed = float(params["max_speed"])
if params.has("avoidance_enabled"):
agent.avoidance_enabled = bool(params["avoidance_enabled"])
if params.has("navigation_layers"):
agent.navigation_layers = int(params["navigation_layers"])
add_child_with_undo(node, agent, root, "MCP: Add NavigationAgent3D")
return success({
"node_path": str(agent.get_path()),
"type": "NavigationAgent3D",
"radius": agent.radius,
"max_speed": agent.max_speed,
"avoidance_enabled": agent.avoidance_enabled,
"navigation_layers": agent.navigation_layers,
"created": true,
})
else:
var agent := NavigationAgent2D.new()
agent.name = agent_name
if params.has("path_desired_distance"):
agent.path_desired_distance = float(params["path_desired_distance"])
if params.has("target_desired_distance"):
agent.target_desired_distance = float(params["target_desired_distance"])
if params.has("radius"):
agent.radius = float(params["radius"])
if params.has("neighbor_distance"):
agent.neighbor_distance = float(params["neighbor_distance"])
if params.has("max_neighbors"):
agent.max_neighbors = int(params["max_neighbors"])
if params.has("max_speed"):
agent.max_speed = float(params["max_speed"])
if params.has("avoidance_enabled"):
agent.avoidance_enabled = bool(params["avoidance_enabled"])
if params.has("navigation_layers"):
agent.navigation_layers = int(params["navigation_layers"])
add_child_with_undo(node, agent, root, "MCP: Add NavigationAgent2D")
return success({
"node_path": str(agent.get_path()),
"type": "NavigationAgent2D",
"radius": agent.radius,
"max_speed": agent.max_speed,
"avoidance_enabled": agent.avoidance_enabled,
"navigation_layers": agent.navigation_layers,
"created": true,
})
func _set_navigation_layers(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node at '%s'" % node_path)
# Support setting by bitmask value
if params.has("layers"):
var layers_val: int = int(params["layers"])
if node is NavigationRegion2D:
set_property_with_undo(node, "navigation_layers", layers_val, "MCP: Set navigation layers")
elif node is NavigationRegion3D:
set_property_with_undo(node, "navigation_layers", layers_val, "MCP: Set navigation layers")
elif node is NavigationAgent2D:
set_property_with_undo(node, "navigation_layers", layers_val, "MCP: Set navigation layers")
elif node is NavigationAgent3D:
set_property_with_undo(node, "navigation_layers", layers_val, "MCP: Set navigation layers")
else:
return error_invalid_params("Node '%s' is not a navigation region or agent" % node_path)
return success({
"node_path": node_path,
"navigation_layers": layers_val,
"updated": true,
})
# Support setting individual layer bits by number
if params.has("layer_bits"):
var bits: Array = params["layer_bits"]
var current_layers: int = 0
# Calculate bitmask from layer numbers (1-based)
for bit in bits:
var layer_num: int = int(bit)
if layer_num >= 1 and layer_num <= 32:
current_layers |= (1 << (layer_num - 1))
if node is NavigationRegion2D:
set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers")
elif node is NavigationRegion3D:
set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers")
elif node is NavigationAgent2D:
set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers")
elif node is NavigationAgent3D:
set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers")
else:
return error_invalid_params("Node '%s' is not a navigation region or agent" % node_path)
return success({
"node_path": node_path,
"navigation_layers": current_layers,
"layer_bits": bits,
"updated": true,
})
# Support named layers from ProjectSettings
if params.has("layer_names"):
var names: Array = params["layer_names"]
var current_layers: int = 0
var is_2d: bool = node is NavigationRegion2D or node is NavigationAgent2D
var prefix: String = "layer_names/2d_navigation/layer_" if is_2d else "layer_names/3d_navigation/layer_"
for i in range(1, 33):
var setting_key: String = prefix + str(i)
if ProjectSettings.has_setting(setting_key):
var layer_name: String = str(ProjectSettings.get_setting(setting_key))
if layer_name in names:
current_layers |= (1 << (i - 1))
if node is NavigationRegion2D:
set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers")
elif node is NavigationRegion3D:
set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers")
elif node is NavigationAgent2D:
set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers")
elif node is NavigationAgent3D:
set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers")
else:
return error_invalid_params("Node '%s' is not a navigation region or agent" % node_path)
return success({
"node_path": node_path,
"navigation_layers": current_layers,
"layer_names": names,
"updated": true,
})
return error_invalid_params("Must provide 'layers' (bitmask), 'layer_bits' (array of layer numbers), or 'layer_names' (array of named layers)")
func _get_navigation_info(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node at '%s'" % node_path)
var regions: Array = []
var agents: Array = []
_collect_navigation_nodes(node, regions, agents)
# Collect named layers from ProjectSettings
var layer_names_2d: Dictionary = {}
var layer_names_3d: Dictionary = {}
for i in range(1, 33):
var key_2d: String = "layer_names/2d_navigation/layer_" + str(i)
var key_3d: String = "layer_names/3d_navigation/layer_" + str(i)
if ProjectSettings.has_setting(key_2d):
var name_2d: String = str(ProjectSettings.get_setting(key_2d))
if not name_2d.is_empty():
layer_names_2d[i] = name_2d
if ProjectSettings.has_setting(key_3d):
var name_3d: String = str(ProjectSettings.get_setting(key_3d))
if not name_3d.is_empty():
layer_names_3d[i] = name_3d
return success({
"node_path": node_path,
"regions": regions,
"agents": agents,
"region_count": regions.size(),
"agent_count": agents.size(),
"layer_names_2d": layer_names_2d,
"layer_names_3d": layer_names_3d,
})
func _collect_navigation_nodes(node: Node, regions: Array, agents: Array) -> void:
if node is NavigationRegion2D:
var region: NavigationRegion2D = node as NavigationRegion2D
var region_info := {
"path": str(region.get_path()),
"type": "NavigationRegion2D",
"enabled": region.enabled,
"navigation_layers": region.navigation_layers,
"has_polygon": region.navigation_polygon != null,
}
if region.navigation_polygon != null:
var nav_poly: NavigationPolygon = region.navigation_polygon
region_info["outline_count"] = nav_poly.get_outline_count()
region_info["polygon_count"] = nav_poly.get_polygon_count()
region_info["cell_size"] = nav_poly.cell_size
region_info["agent_radius"] = nav_poly.agent_radius
regions.append(region_info)
elif node is NavigationRegion3D:
var region: NavigationRegion3D = node as NavigationRegion3D
var region_info := {
"path": str(region.get_path()),
"type": "NavigationRegion3D",
"enabled": region.enabled,
"navigation_layers": region.navigation_layers,
"has_mesh": region.navigation_mesh != null,
}
if region.navigation_mesh != null:
var nav_mesh: NavigationMesh = region.navigation_mesh
region_info["agent_radius"] = nav_mesh.agent_radius
region_info["agent_height"] = nav_mesh.agent_height
region_info["agent_max_climb"] = nav_mesh.agent_max_climb
region_info["agent_max_slope"] = nav_mesh.agent_max_slope
region_info["cell_size"] = nav_mesh.cell_size
region_info["cell_height"] = nav_mesh.cell_height
regions.append(region_info)
if node is NavigationAgent2D:
var agent: NavigationAgent2D = node as NavigationAgent2D
agents.append({
"path": str(agent.get_path()),
"type": "NavigationAgent2D",
"radius": agent.radius,
"max_speed": agent.max_speed,
"path_desired_distance": agent.path_desired_distance,
"target_desired_distance": agent.target_desired_distance,
"neighbor_distance": agent.neighbor_distance,
"max_neighbors": agent.max_neighbors,
"avoidance_enabled": agent.avoidance_enabled,
"navigation_layers": agent.navigation_layers,
})
elif node is NavigationAgent3D:
var agent: NavigationAgent3D = node as NavigationAgent3D
agents.append({
"path": str(agent.get_path()),
"type": "NavigationAgent3D",
"radius": agent.radius,
"max_speed": agent.max_speed,
"path_desired_distance": agent.path_desired_distance,
"target_desired_distance": agent.target_desired_distance,
"neighbor_distance": agent.neighbor_distance,
"max_neighbors": agent.max_neighbors,
"avoidance_enabled": agent.avoidance_enabled,
"navigation_layers": agent.navigation_layers,
})
for child in node.get_children():
_collect_navigation_nodes(child, regions, agents)

View File

@@ -0,0 +1,706 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
const NodeUtils := preload("res://addons/godot_mcp/utils/node_utils.gd")
const PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd")
func get_commands() -> Dictionary:
return {
"add_node": _add_node,
"delete_node": _delete_node,
"duplicate_node": _duplicate_node,
"move_node": _move_node,
"update_property": _update_property,
"get_node_properties": _get_node_properties,
"add_resource": _add_resource,
"set_anchor_preset": _set_anchor_preset,
"rename_node": _rename_node,
"connect_signal": _connect_signal,
"disconnect_signal": _disconnect_signal,
"get_node_groups": _get_node_groups,
"set_node_groups": _set_node_groups,
"find_nodes_in_group": _find_nodes_in_group,
}
func _find_script_by_class_name(class_name_str: String) -> Script:
# Search project files for a script with matching class_name
var global_classes: Array = ProjectSettings.get_global_class_list()
for entry: Dictionary in global_classes:
if entry.get("class", "") == class_name_str:
var path: String = entry.get("path", "")
if not path.is_empty():
return load(path) as Script
return null
func _add_node(params: Dictionary) -> Dictionary:
var result := require_string(params, "type")
if result[1] != null:
return result[1]
var type: String = result[0]
var parent_path: String = optional_string(params, "parent_path", ".")
var node_name: String = optional_string(params, "name", "")
var properties: Dictionary = params.get("properties", {})
var root := get_edited_root()
if root == null:
return error_no_scene()
var parent := find_node_by_path(parent_path)
if parent == null:
return error_not_found("Parent node '%s'" % parent_path, "Use get_scene_tree to see available nodes")
var node: Node
var custom_script: Script = null
if ClassDB.class_exists(type):
node = ClassDB.instantiate(type)
else:
# Try to find a script with matching class_name
custom_script = _find_script_by_class_name(type)
if custom_script == null:
return error_invalid_params("Unknown node type: '%s'. Not found in ClassDB or as a script class_name. Use list_scripts to see available script classes." % type)
var base_type: String = custom_script.get_instance_base_type()
if not ClassDB.class_exists(base_type):
return error_invalid_params("Script '%s' extends '%s' which is not a valid node type" % [type, base_type])
node = ClassDB.instantiate(base_type)
node.set_script(custom_script)
if not node_name.is_empty():
node.name = node_name
# Apply properties
for prop_name: String in properties:
var prop_exists := false
for prop in node.get_property_list():
if prop["name"] == prop_name:
prop_exists = true
break
if prop_exists:
var current: Variant = node.get(prop_name)
var target_type := typeof(current)
node.set(prop_name, PropertyParser.parse_value(properties[prop_name], target_type))
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Add %s" % type)
undo_redo.add_do_method(parent, "add_child", node)
undo_redo.add_do_method(node, "set_owner", root)
undo_redo.add_do_reference(node)
undo_redo.add_undo_method(parent, "remove_child", node)
undo_redo.commit_action()
return success({
"node_path": str(root.get_path_to(node)),
"type": type,
"name": str(node.name),
})
func _delete_node(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
if node == root:
return error_invalid_params("Cannot delete the root node")
var parent := node.get_parent()
var node_name := str(node.name)
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Delete %s" % node_name)
undo_redo.add_do_method(parent, "remove_child", node)
undo_redo.add_undo_method(parent, "add_child", node)
undo_redo.add_undo_method(node, "set_owner", root)
undo_redo.add_undo_reference(node)
undo_redo.commit_action()
return success({"deleted": node_name})
func _duplicate_node(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var new_name: String = optional_string(params, "name", "")
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
if new_name.is_empty():
new_name = str(node.name) + "_copy"
var dup := node.duplicate()
dup.name = new_name
var parent := node.get_parent()
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Duplicate %s" % node.name)
undo_redo.add_do_method(parent, "add_child", dup)
undo_redo.add_do_method(dup, "set_owner", root)
undo_redo.add_do_reference(dup)
undo_redo.add_undo_method(parent, "remove_child", dup)
undo_redo.commit_action()
NodeUtils.set_owner_recursive(dup, root)
return success({
"original": str(root.get_path_to(node)),
"duplicate": str(root.get_path_to(dup)),
"name": str(dup.name),
})
func _move_node(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "new_parent_path")
if result2[1] != null:
return result2[1]
var new_parent_path: String = result2[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
if node == root:
return error_invalid_params("Cannot move the root node")
var new_parent := find_node_by_path(new_parent_path)
if new_parent == null:
return error_not_found("Target parent '%s'" % new_parent_path, "Use get_scene_tree to see available nodes")
# Check we're not moving a node into its own subtree
if new_parent == node or node.is_ancestor_of(new_parent):
return error_invalid_params("Cannot move a node into its own subtree")
var old_parent := node.get_parent()
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Move %s" % node.name)
undo_redo.add_do_method(old_parent, "remove_child", node)
undo_redo.add_do_method(new_parent, "add_child", node)
undo_redo.add_do_method(node, "set_owner", root)
undo_redo.add_undo_method(new_parent, "remove_child", node)
undo_redo.add_undo_method(old_parent, "add_child", node)
undo_redo.add_undo_method(node, "set_owner", root)
undo_redo.commit_action()
NodeUtils.set_owner_recursive(node, root)
return success({
"node": str(node.name),
"old_parent": str(root.get_path_to(old_parent)),
"new_parent": str(root.get_path_to(new_parent)),
"new_path": str(root.get_path_to(node)),
})
func _update_property(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "property")
if result2[1] != null:
return result2[1]
var property: String = result2[0]
if not params.has("value"):
return error_invalid_params("Missing required parameter: value")
var value: Variant = params["value"]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
# Check property exists
if not property in node:
var available: Array = []
for prop in node.get_property_list():
if prop["usage"] & PROPERTY_USAGE_EDITOR:
available.append(prop["name"])
return error_not_found("Property '%s' on %s" % [property, node.get_class()],
"Available: %s" % str(available.slice(0, 20)))
var old_value: Variant = node.get(property)
var target_type := typeof(old_value)
var parsed_value: Variant = PropertyParser.parse_value(value, target_type)
# Handle @export node references (e.g. @export var hud: HUD)
# typeof() returns TYPE_NIL when unset or TYPE_OBJECT when set,
# neither resolves a string path to a node — check the property hint instead
if value is String:
for prop in node.get_property_list():
if prop["name"] == property and prop["hint"] == PROPERTY_HINT_NODE_TYPE:
var target_node: Node = node.get_node_or_null(NodePath(value))
if target_node == null:
target_node = root.get_node_or_null(NodePath(value))
if target_node == null:
return error_not_found("Node '%s'" % value, "Could not resolve node path for property '%s'" % property)
parsed_value = target_node
break
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Set %s.%s" % [node.name, property])
undo_redo.add_do_property(node, property, parsed_value)
undo_redo.add_undo_property(node, property, old_value)
undo_redo.commit_action()
return success({
"node": str(root.get_path_to(node)),
"property": property,
"old_value": PropertyParser.serialize_value(old_value),
"new_value": PropertyParser.serialize_value(node.get(property)),
})
func _get_node_properties(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
var category: String = optional_string(params, "category", "")
var props := NodeUtils.get_node_properties_dict(node)
# Filter by category if specified
if not category.is_empty():
var filtered: Dictionary = {}
for key: String in props:
if key.begins_with(category):
filtered[key] = props[key]
props = filtered
return success({
"node_path": str(root.get_path_to(node)),
"type": node.get_class(),
"properties": props,
})
func _add_resource(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "property")
if result2[1] != null:
return result2[1]
var property: String = result2[0]
var result3 := require_string(params, "resource_type")
if result3[1] != null:
return result3[1]
var resource_type: String = result3[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
if not ClassDB.class_exists(resource_type):
return error_invalid_params("Unknown resource type: %s" % resource_type)
if not ClassDB.is_parent_class(resource_type, "Resource"):
return error_invalid_params("'%s' is not a Resource type" % resource_type)
var resource: Resource = ClassDB.instantiate(resource_type)
if resource == null:
return error_internal("Failed to create resource: %s" % resource_type)
# Apply resource properties if provided
var resource_props: Dictionary = params.get("resource_properties", {})
for prop_name: String in resource_props:
if prop_name in resource:
var current: Variant = resource.get(prop_name)
resource.set(prop_name, PropertyParser.parse_value(resource_props[prop_name], typeof(current)))
var old_value: Variant = node.get(property) if property in node else null
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Add %s to %s" % [resource_type, node.name])
undo_redo.add_do_property(node, property, resource)
undo_redo.add_undo_property(node, property, old_value)
undo_redo.commit_action()
return success({
"node_path": str(root.get_path_to(node)),
"property": property,
"resource_type": resource_type,
})
func _set_anchor_preset(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "preset")
if result2[1] != null:
return result2[1]
var preset_name: String = result2[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
if not node is Control:
return error_invalid_params("Node '%s' is not a Control (is %s)" % [node_path, node.get_class()])
var control: Control = node
var 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,
}
if not presets.has(preset_name):
return error_invalid_params("Unknown preset: '%s'. Available: %s" % [preset_name, presets.keys()])
var keep_offsets: bool = optional_bool(params, "keep_offsets", false)
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Set anchor preset on %s" % node.name)
# Store old values
var old_anchors := [control.anchor_left, control.anchor_top, control.anchor_right, control.anchor_bottom]
var old_offsets := [control.offset_left, control.offset_top, control.offset_right, control.offset_bottom]
var target: Control = control.duplicate() as Control
target.set_anchors_and_offsets_preset(presets[preset_name],
Control.PRESET_MODE_KEEP_SIZE if keep_offsets else Control.PRESET_MODE_MINSIZE)
undo_redo.add_do_property(control, "anchor_left", target.anchor_left)
undo_redo.add_do_property(control, "anchor_top", target.anchor_top)
undo_redo.add_do_property(control, "anchor_right", target.anchor_right)
undo_redo.add_do_property(control, "anchor_bottom", target.anchor_bottom)
undo_redo.add_do_property(control, "offset_left", target.offset_left)
undo_redo.add_do_property(control, "offset_top", target.offset_top)
undo_redo.add_do_property(control, "offset_right", target.offset_right)
undo_redo.add_do_property(control, "offset_bottom", target.offset_bottom)
undo_redo.add_undo_property(control, "anchor_left", old_anchors[0])
undo_redo.add_undo_property(control, "anchor_top", old_anchors[1])
undo_redo.add_undo_property(control, "anchor_right", old_anchors[2])
undo_redo.add_undo_property(control, "anchor_bottom", old_anchors[3])
undo_redo.add_undo_property(control, "offset_left", old_offsets[0])
undo_redo.add_undo_property(control, "offset_top", old_offsets[1])
undo_redo.add_undo_property(control, "offset_right", old_offsets[2])
undo_redo.add_undo_property(control, "offset_bottom", old_offsets[3])
target.free()
undo_redo.commit_action()
return success({"node_path": str(root.get_path_to(control)), "preset": preset_name})
func _rename_node(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "new_name")
if result2[1] != null:
return result2[1]
var new_name: String = result2[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
var old_name: String = node.name
var undo_redo := get_undo_redo()
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 success({"old_name": old_name, "new_name": str(node.name), "node_path": str(root.get_path_to(node))})
func _connect_signal(params: Dictionary) -> Dictionary:
var result := require_string(params, "source_path")
if result[1] != null:
return result[1]
var source_path: String = result[0]
var result2 := require_string(params, "signal_name")
if result2[1] != null:
return result2[1]
var signal_name: String = result2[0]
var result3 := require_string(params, "target_path")
if result3[1] != null:
return result3[1]
var target_path: String = result3[0]
var result4 := require_string(params, "method_name")
if result4[1] != null:
return result4[1]
var method_name: String = result4[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var source := find_node_by_path(source_path)
if source == null:
return error_not_found("Source node '%s'" % source_path)
var target := find_node_by_path(target_path)
if target == null:
return error_not_found("Target node '%s'" % target_path)
if not source.has_signal(signal_name):
return error_invalid_params("Signal '%s' not found on %s" % [signal_name, source.get_class()])
if source.is_connected(signal_name, Callable(target, method_name)):
return success({"already_connected": true, "signal": signal_name})
var callable := Callable(target, method_name)
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Connect signal")
undo_redo.add_do_method(source, "connect", signal_name, callable)
undo_redo.add_undo_method(source, "disconnect", signal_name, callable)
undo_redo.commit_action()
return success({
"source": str(root.get_path_to(source)),
"signal": signal_name,
"target": str(root.get_path_to(target)),
"method": method_name,
"connected": true,
})
func _disconnect_signal(params: Dictionary) -> Dictionary:
var result := require_string(params, "source_path")
if result[1] != null:
return result[1]
var source_path: String = result[0]
var result2 := require_string(params, "signal_name")
if result2[1] != null:
return result2[1]
var signal_name: String = result2[0]
var result3 := require_string(params, "target_path")
if result3[1] != null:
return result3[1]
var target_path: String = result3[0]
var result4 := require_string(params, "method_name")
if result4[1] != null:
return result4[1]
var method_name: String = result4[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var source := find_node_by_path(source_path)
if source == null:
return error_not_found("Source node '%s'" % source_path)
var target := find_node_by_path(target_path)
if target == null:
return error_not_found("Target node '%s'" % target_path)
if not source.is_connected(signal_name, Callable(target, method_name)):
return success({"was_connected": false})
var callable := Callable(target, method_name)
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Disconnect signal")
undo_redo.add_do_method(source, "disconnect", signal_name, callable)
undo_redo.add_undo_method(source, "connect", signal_name, callable)
undo_redo.commit_action()
return success({
"source": str(root.get_path_to(source)),
"signal": signal_name,
"target": str(root.get_path_to(target)),
"method": method_name,
"disconnected": true,
})
func _get_node_groups(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
var groups: Array = []
for group: StringName in node.get_groups():
var g := str(group)
# Filter out internal groups (start with _)
if not g.begins_with("_"):
groups.append(g)
return success({
"node_path": str(root.get_path_to(node)),
"groups": groups,
"count": groups.size(),
})
func _set_node_groups(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
if not params.has("groups") or not params["groups"] is Array:
return error_invalid_params("'groups' array is required")
var desired_groups: Array = params["groups"]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
# Get current non-internal groups
var current_groups: Array = []
for group: StringName in node.get_groups():
var g := str(group)
if not g.begins_with("_"):
current_groups.append(g)
var added: Array = []
var removed: Array = []
for group: String in current_groups:
if group not in desired_groups:
removed.append(group)
for group in desired_groups:
var g: String = str(group)
if g not in current_groups:
added.append(g)
if not added.is_empty() or not removed.is_empty():
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Set node groups")
for group: String in removed:
undo_redo.add_do_method(node, "remove_from_group", group)
undo_redo.add_undo_method(node, "add_to_group", group, true)
for group: String in added:
undo_redo.add_do_method(node, "add_to_group", group, true)
undo_redo.add_undo_method(node, "remove_from_group", group)
undo_redo.commit_action()
return success({
"node_path": str(root.get_path_to(node)),
"groups": desired_groups,
"added": added,
"removed": removed,
})
func _find_nodes_in_group(params: Dictionary) -> Dictionary:
var result := require_string(params, "group")
if result[1] != null:
return result[1]
var group_name: String = result[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var matches: Array = []
_find_in_group_recursive(root, root, group_name, matches)
return success({
"group": group_name,
"nodes": matches,
"count": matches.size(),
})
func _find_in_group_recursive(node: Node, root: Node, group_name: String, matches: Array) -> void:
if node.is_in_group(group_name):
matches.append({
"name": node.name,
"path": str(root.get_path_to(node)),
"type": node.get_class(),
})
for child in node.get_children():
_find_in_group_recursive(child, root, group_name, matches)

View File

@@ -0,0 +1,612 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
func get_commands() -> Dictionary:
return {
"create_particles": _create_particles,
"set_particle_material": _set_particle_material,
"set_particle_color_gradient": _set_particle_color_gradient,
"apply_particle_preset": _apply_particle_preset,
"get_particle_info": _get_particle_info,
}
func _get_particles_node(node_path: String) -> GPUParticles2D:
# Returns any GPUParticles2D or GPUParticles3D (both share similar API)
var node := find_node_by_path(node_path)
if node is GPUParticles2D:
return node as GPUParticles2D
return null
func _get_particles_node_any(node_path: String) -> Node:
var node := find_node_by_path(node_path)
if node is GPUParticles2D or node is GPUParticles3D:
return node
return null
func _parse_color(color_str: String) -> Color:
# Support hex "#RRGGBB", "#RRGGBBAA", or named colors
if color_str.begins_with("#"):
return Color.html(color_str)
# Try named color
match color_str.to_lower():
"red": return Color.RED
"green": return Color.GREEN
"blue": return Color.BLUE
"white": return Color.WHITE
"black": return Color.BLACK
"yellow": return Color.YELLOW
"orange": return Color(1.0, 0.5, 0.0)
"gray", "grey": return Color.GRAY
"cyan": return Color.CYAN
"magenta": return Color.MAGENTA
"transparent": return Color(0, 0, 0, 0)
# Try Expression parser for Color(r,g,b,a)
var expr := Expression.new()
if expr.parse(color_str) == OK:
var parsed = expr.execute()
if parsed is Color:
return parsed
return Color.WHITE
func _create_particles(params: Dictionary) -> Dictionary:
var result := require_string(params, "parent_path")
if result[1] != null:
return result[1]
var parent_path: String = result[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var parent := find_node_by_path(parent_path)
if parent == null:
return error_not_found("Node at '%s'" % parent_path)
var node_name: String = optional_string(params, "name", "Particles")
var is_3d: bool = optional_bool(params, "is_3d", false)
var amount: int = optional_int(params, "amount", 16)
var lifetime: float = float(params.get("lifetime", 1.0))
var one_shot: bool = optional_bool(params, "one_shot", false)
var explosiveness: float = float(params.get("explosiveness", 0.0))
var randomness: float = float(params.get("randomness", 0.0))
var emitting: bool = optional_bool(params, "emitting", true)
var particles_node: Node
if is_3d:
var p := GPUParticles3D.new()
p.name = node_name
p.amount = amount
p.lifetime = lifetime
p.one_shot = one_shot
p.explosiveness = explosiveness
p.randomness = randomness
p.emitting = emitting
var mat := ParticleProcessMaterial.new()
p.process_material = mat
particles_node = p
else:
var p := GPUParticles2D.new()
p.name = node_name
p.amount = amount
p.lifetime = lifetime
p.one_shot = one_shot
p.explosiveness = explosiveness
p.randomness = randomness
p.emitting = emitting
var mat := ParticleProcessMaterial.new()
p.process_material = mat
particles_node = p
add_child_with_undo(parent, particles_node, root, "MCP: Create particles")
return success({
"name": particles_node.name,
"parent": parent_path,
"is_3d": is_3d,
"amount": amount,
"lifetime": lifetime,
"one_shot": one_shot,
"created": true,
})
func _set_particle_material(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var node := _get_particles_node_any(node_path)
if node == null:
return error_not_found("GPUParticles2D/3D at '%s'" % node_path)
var old_mat: ParticleProcessMaterial = node.get("process_material")
var mat: ParticleProcessMaterial
if old_mat != null:
mat = old_mat.duplicate(true) as ParticleProcessMaterial
else:
mat = ParticleProcessMaterial.new()
var changes: Array = []
# Direction
if params.has("direction"):
var dir = params["direction"]
if dir is Dictionary:
mat.direction = Vector3(float(dir.get("x", 0)), float(dir.get("y", 0)), float(dir.get("z", 0)))
changes.append("direction")
elif dir is String:
var expr := Expression.new()
if expr.parse(dir) == OK:
var parsed = expr.execute()
if parsed is Vector3:
mat.direction = parsed
changes.append("direction")
# Spread
if params.has("spread"):
mat.spread = float(params["spread"])
changes.append("spread")
# Initial velocity
if params.has("initial_velocity_min"):
mat.initial_velocity_min = float(params["initial_velocity_min"])
changes.append("initial_velocity_min")
if params.has("initial_velocity_max"):
mat.initial_velocity_max = float(params["initial_velocity_max"])
changes.append("initial_velocity_max")
# Gravity
if params.has("gravity"):
var grav = params["gravity"]
if grav is Dictionary:
mat.gravity = Vector3(float(grav.get("x", 0)), float(grav.get("y", 0)), float(grav.get("z", 0)))
changes.append("gravity")
elif grav is String:
var expr := Expression.new()
if expr.parse(grav) == OK:
var parsed = expr.execute()
if parsed is Vector3:
mat.gravity = parsed
changes.append("gravity")
# Scale
if params.has("scale_min"):
mat.scale_min = float(params["scale_min"])
changes.append("scale_min")
if params.has("scale_max"):
mat.scale_max = float(params["scale_max"])
changes.append("scale_max")
# Color
if params.has("color"):
mat.color = _parse_color(str(params["color"]))
changes.append("color")
# Emission shape
if params.has("emission_shape"):
var shape_str: String = str(params["emission_shape"]).to_lower()
match shape_str:
"point":
mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_POINT
"sphere":
mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE
if params.has("emission_sphere_radius"):
mat.emission_sphere_radius = float(params["emission_sphere_radius"])
"sphere_surface":
mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE_SURFACE
if params.has("emission_sphere_radius"):
mat.emission_sphere_radius = float(params["emission_sphere_radius"])
"box":
mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
if params.has("emission_box_extents"):
var ext = params["emission_box_extents"]
if ext is Dictionary:
mat.emission_box_extents = Vector3(float(ext.get("x", 1)), float(ext.get("y", 1)), float(ext.get("z", 1)))
"ring":
mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_RING
if params.has("emission_ring_radius"):
mat.emission_ring_radius = float(params["emission_ring_radius"])
if params.has("emission_ring_inner_radius"):
mat.emission_ring_inner_radius = float(params["emission_ring_inner_radius"])
if params.has("emission_ring_height"):
mat.emission_ring_height = float(params["emission_ring_height"])
changes.append("emission_shape")
# Angular velocity
if params.has("angular_velocity_min"):
mat.angular_velocity_min = float(params["angular_velocity_min"])
changes.append("angular_velocity_min")
if params.has("angular_velocity_max"):
mat.angular_velocity_max = float(params["angular_velocity_max"])
changes.append("angular_velocity_max")
# Orbit velocity
if params.has("orbit_velocity_min"):
mat.orbit_velocity_min = float(params["orbit_velocity_min"])
changes.append("orbit_velocity_min")
if params.has("orbit_velocity_max"):
mat.orbit_velocity_max = float(params["orbit_velocity_max"])
changes.append("orbit_velocity_max")
# Damping
if params.has("damping_min"):
mat.damping_min = float(params["damping_min"])
changes.append("damping_min")
if params.has("damping_max"):
mat.damping_max = float(params["damping_max"])
changes.append("damping_max")
# Attractor interaction
if params.has("attractor_interaction_enabled"):
mat.attractor_interaction_enabled = bool(params["attractor_interaction_enabled"])
changes.append("attractor_interaction_enabled")
if not changes.is_empty():
set_property_with_undo(node, "process_material", mat, "MCP: Set particle material")
return success({"node_path": node_path, "changes": changes})
func _set_particle_color_gradient(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var node := _get_particles_node_any(node_path)
if node == null:
return error_not_found("GPUParticles2D/3D at '%s'" % node_path)
var old_mat: ParticleProcessMaterial = node.get("process_material")
var mat: ParticleProcessMaterial
if old_mat != null:
mat = old_mat.duplicate(true) as ParticleProcessMaterial
else:
mat = ParticleProcessMaterial.new()
if not params.has("stops") or not params["stops"] is Array:
return error_invalid_params("Missing required parameter: stops (array of {offset, color})")
var stops: Array = params["stops"]
if stops.is_empty():
return error_invalid_params("stops array must not be empty")
var gradient := Gradient.new()
# Remove default points
while gradient.get_point_count() > 0:
gradient.remove_point(0)
for stop in stops:
if stop is Dictionary:
var offset: float = float(stop.get("offset", 0.0))
var color: Color = _parse_color(str(stop.get("color", "#ffffff")))
gradient.add_point(offset, color)
var grad_tex := GradientTexture1D.new()
grad_tex.gradient = gradient
mat.color_ramp = grad_tex
set_property_with_undo(node, "process_material", mat, "MCP: Set particle color gradient")
return success({"node_path": node_path, "stops_count": gradient.get_point_count()})
func _apply_particle_preset(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "preset")
if result2[1] != null:
return result2[1]
var preset: String = result2[0].to_lower()
var node := _get_particles_node_any(node_path)
if node == null:
return error_not_found("GPUParticles2D/3D at '%s'" % node_path)
var old_state := _capture_particle_state(node)
var preset_state := {}
var mat := ParticleProcessMaterial.new()
var is_2d: bool = node is GPUParticles2D
# Default gravity for 2D (Y-down) vs 3D (Y-down)
var gravity_down := Vector3(0, 98.0 if is_2d else 9.8, 0)
var _gravity_up := Vector3(0, -98.0 if is_2d else -9.8, 0)
var gravity_none := Vector3.ZERO
match preset:
"explosion":
preset_state["amount"] = 32
preset_state["lifetime"] = 0.6
preset_state["one_shot"] = true
preset_state["explosiveness"] = 1.0
mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0)
mat.spread = 180.0
mat.initial_velocity_min = 100.0 if is_2d else 5.0
mat.initial_velocity_max = 200.0 if is_2d else 10.0
mat.gravity = gravity_down * 0.5
mat.damping_min = 2.0
mat.damping_max = 4.0
mat.scale_min = 0.5
mat.scale_max = 1.5
mat.color = Color(1.0, 0.6, 0.1)
_apply_gradient(mat, [
{"offset": 0.0, "color": Color.WHITE},
{"offset": 0.3, "color": Color(1.0, 0.8, 0.2)},
{"offset": 0.7, "color": Color(1.0, 0.3, 0.0)},
{"offset": 1.0, "color": Color(0.2, 0.0, 0.0, 0.0)},
])
"fire":
preset_state["amount"] = 24
preset_state["lifetime"] = 1.2
preset_state["one_shot"] = false
preset_state["explosiveness"] = 0.0
mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0)
mat.spread = 15.0
mat.initial_velocity_min = 30.0 if is_2d else 1.5
mat.initial_velocity_max = 60.0 if is_2d else 3.0
mat.gravity = gravity_none
mat.scale_min = 0.8
mat.scale_max = 1.5
_apply_gradient(mat, [
{"offset": 0.0, "color": Color(1.0, 1.0, 0.5)},
{"offset": 0.3, "color": Color(1.0, 0.6, 0.0)},
{"offset": 0.7, "color": Color(0.8, 0.2, 0.0)},
{"offset": 1.0, "color": Color(0.2, 0.0, 0.0, 0.0)},
])
"smoke":
preset_state["amount"] = 16
preset_state["lifetime"] = 3.0
preset_state["one_shot"] = false
preset_state["explosiveness"] = 0.0
mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0)
mat.spread = 25.0
mat.initial_velocity_min = 10.0 if is_2d else 0.5
mat.initial_velocity_max = 25.0 if is_2d else 1.2
mat.gravity = gravity_none
mat.scale_min = 1.5
mat.scale_max = 3.0
mat.damping_min = 1.0
mat.damping_max = 2.0
_apply_gradient(mat, [
{"offset": 0.0, "color": Color(0.5, 0.5, 0.5, 0.6)},
{"offset": 0.5, "color": Color(0.6, 0.6, 0.6, 0.3)},
{"offset": 1.0, "color": Color(0.7, 0.7, 0.7, 0.0)},
])
"sparks":
preset_state["amount"] = 48
preset_state["lifetime"] = 0.4
preset_state["one_shot"] = true
preset_state["explosiveness"] = 0.95
mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0)
mat.spread = 180.0
mat.initial_velocity_min = 200.0 if is_2d else 8.0
mat.initial_velocity_max = 400.0 if is_2d else 16.0
mat.gravity = gravity_down
mat.scale_min = 0.1
mat.scale_max = 0.3
mat.damping_min = 1.0
mat.damping_max = 3.0
_apply_gradient(mat, [
{"offset": 0.0, "color": Color(1.0, 1.0, 0.8)},
{"offset": 0.5, "color": Color(1.0, 0.7, 0.2)},
{"offset": 1.0, "color": Color(1.0, 0.3, 0.0, 0.0)},
])
"rain":
preset_state["amount"] = 64
preset_state["lifetime"] = 0.8
preset_state["one_shot"] = false
preset_state["explosiveness"] = 0.0
mat.direction = Vector3(0, 1, 0) if is_2d else Vector3(0, -1, 0)
mat.spread = 5.0
mat.initial_velocity_min = 300.0 if is_2d else 12.0
mat.initial_velocity_max = 400.0 if is_2d else 16.0
mat.gravity = gravity_down
mat.scale_min = 0.1
mat.scale_max = 0.2
mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
mat.emission_box_extents = Vector3(200, 0, 0) if is_2d else Vector3(5, 0, 5)
mat.color = Color(0.6, 0.7, 1.0, 0.7)
"snow":
preset_state["amount"] = 48
preset_state["lifetime"] = 4.0
preset_state["one_shot"] = false
preset_state["explosiveness"] = 0.0
mat.direction = Vector3(0, 1, 0) if is_2d else Vector3(0, -1, 0)
mat.spread = 20.0
mat.initial_velocity_min = 20.0 if is_2d else 0.8
mat.initial_velocity_max = 40.0 if is_2d else 1.5
mat.gravity = Vector3(0, 20, 0) if is_2d else Vector3(0, -0.5, 0)
mat.scale_min = 0.3
mat.scale_max = 0.8
mat.angular_velocity_min = -45.0
mat.angular_velocity_max = 45.0
mat.damping_min = 0.5
mat.damping_max = 1.5
mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
mat.emission_box_extents = Vector3(200, 0, 0) if is_2d else Vector3(5, 0, 5)
mat.color = Color(1.0, 1.0, 1.0, 0.9)
"magic":
preset_state["amount"] = 24
preset_state["lifetime"] = 2.0
preset_state["one_shot"] = false
preset_state["explosiveness"] = 0.0
mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0)
mat.spread = 180.0
mat.initial_velocity_min = 20.0 if is_2d else 1.0
mat.initial_velocity_max = 50.0 if is_2d else 2.5
mat.gravity = gravity_none
mat.orbit_velocity_min = 0.5
mat.orbit_velocity_max = 1.5
mat.scale_min = 0.3
mat.scale_max = 0.8
mat.damping_min = 1.0
mat.damping_max = 2.0
_apply_gradient(mat, [
{"offset": 0.0, "color": Color(0.3, 0.5, 1.0)},
{"offset": 0.25, "color": Color(1.0, 0.3, 0.8)},
{"offset": 0.5, "color": Color(0.3, 1.0, 0.5)},
{"offset": 0.75, "color": Color(1.0, 0.8, 0.2)},
{"offset": 1.0, "color": Color(0.5, 0.3, 1.0, 0.0)},
])
"dust":
preset_state["amount"] = 12
preset_state["lifetime"] = 5.0
preset_state["one_shot"] = false
preset_state["explosiveness"] = 0.0
mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0)
mat.spread = 180.0
mat.initial_velocity_min = 3.0 if is_2d else 0.1
mat.initial_velocity_max = 8.0 if is_2d else 0.3
mat.gravity = gravity_none
mat.scale_min = 0.2
mat.scale_max = 0.5
mat.damping_min = 0.5
mat.damping_max = 1.0
mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
mat.emission_box_extents = Vector3(100, 100, 0) if is_2d else Vector3(3, 3, 3)
_apply_gradient(mat, [
{"offset": 0.0, "color": Color(0.8, 0.75, 0.65, 0.0)},
{"offset": 0.2, "color": Color(0.8, 0.75, 0.65, 0.3)},
{"offset": 0.8, "color": Color(0.8, 0.75, 0.65, 0.3)},
{"offset": 1.0, "color": Color(0.8, 0.75, 0.65, 0.0)},
])
_:
return error_invalid_params("Unknown preset: '%s'. Valid presets: explosion, fire, smoke, sparks, rain, snow, magic, dust" % preset)
preset_state["process_material"] = mat
_register_particle_state_undo(node, old_state, preset_state, "MCP: Apply particle preset")
return success({"node_path": node_path, "preset": preset, "applied": true})
func _capture_particle_state(node: Node) -> Dictionary:
var state := {}
for property: String in ["amount", "lifetime", "one_shot", "explosiveness", "randomness", "emitting", "process_material"]:
if property in node:
state[property] = node.get(property)
return state
func _register_particle_state_undo(node: Node, old_state: Dictionary, new_state: Dictionary, action_name: String) -> void:
var undo_redo := get_undo_redo()
undo_redo.create_action(action_name)
for property: String in new_state:
undo_redo.add_do_property(node, property, new_state[property])
if new_state[property] is Resource:
undo_redo.add_do_reference(new_state[property])
undo_redo.add_undo_property(node, property, old_state.get(property, null))
if old_state.get(property, null) is Resource:
undo_redo.add_undo_reference(old_state[property])
undo_redo.commit_action()
func _apply_gradient(mat: ParticleProcessMaterial, stops: Array) -> void:
var gradient := Gradient.new()
# Remove default points before adding custom stops
for i in range(gradient.get_point_count() - 1, -1, -1):
gradient.remove_point(i)
for stop in stops:
gradient.add_point(stop["offset"], stop["color"])
var grad_tex := GradientTexture1D.new()
grad_tex.width = 64 # Smaller texture to avoid GPU issues in compatibility mode
grad_tex.gradient = gradient
# Defer color_ramp assignment to avoid editor crash during rendering
mat.set_deferred("color_ramp", grad_tex)
func _get_particle_info(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var node := _get_particles_node_any(node_path)
if node == null:
return error_not_found("GPUParticles2D/3D at '%s'" % node_path)
var info: Dictionary = {
"node_path": node_path,
"type": node.get_class(),
"amount": node.get("amount"),
"lifetime": node.get("lifetime"),
"one_shot": node.get("one_shot"),
"explosiveness": node.get("explosiveness"),
"randomness": node.get("randomness"),
"emitting": node.get("emitting"),
}
var mat: ParticleProcessMaterial = node.get("process_material")
if mat != null and mat is ParticleProcessMaterial:
var mat_info: Dictionary = {
"direction": str(mat.direction),
"spread": mat.spread,
"initial_velocity_min": mat.initial_velocity_min,
"initial_velocity_max": mat.initial_velocity_max,
"gravity": str(mat.gravity),
"scale_min": mat.scale_min,
"scale_max": mat.scale_max,
"color": str(mat.color),
"angular_velocity_min": mat.angular_velocity_min,
"angular_velocity_max": mat.angular_velocity_max,
"orbit_velocity_min": mat.orbit_velocity_min,
"orbit_velocity_max": mat.orbit_velocity_max,
"damping_min": mat.damping_min,
"damping_max": mat.damping_max,
"attractor_interaction_enabled": mat.attractor_interaction_enabled,
}
# Emission shape
var shape_name: String
match mat.emission_shape:
ParticleProcessMaterial.EMISSION_SHAPE_POINT: shape_name = "point"
ParticleProcessMaterial.EMISSION_SHAPE_SPHERE: shape_name = "sphere"
ParticleProcessMaterial.EMISSION_SHAPE_SPHERE_SURFACE: shape_name = "sphere_surface"
ParticleProcessMaterial.EMISSION_SHAPE_BOX: shape_name = "box"
ParticleProcessMaterial.EMISSION_SHAPE_RING: shape_name = "ring"
_: shape_name = "unknown(%d)" % mat.emission_shape
mat_info["emission_shape"] = shape_name
match mat.emission_shape:
ParticleProcessMaterial.EMISSION_SHAPE_SPHERE, ParticleProcessMaterial.EMISSION_SHAPE_SPHERE_SURFACE:
mat_info["emission_sphere_radius"] = mat.emission_sphere_radius
ParticleProcessMaterial.EMISSION_SHAPE_BOX:
mat_info["emission_box_extents"] = str(mat.emission_box_extents)
ParticleProcessMaterial.EMISSION_SHAPE_RING:
mat_info["emission_ring_radius"] = mat.emission_ring_radius
mat_info["emission_ring_inner_radius"] = mat.emission_ring_inner_radius
mat_info["emission_ring_height"] = mat.emission_ring_height
# Color gradient
if mat.color_ramp != null and mat.color_ramp is GradientTexture1D:
var grad_tex: GradientTexture1D = mat.color_ramp
if grad_tex.gradient != null:
var gradient_stops: Array = []
var grad: Gradient = grad_tex.gradient
for i in grad.get_point_count():
gradient_stops.append({
"offset": grad.get_offset(i),
"color": str(grad.get_color(i)),
})
mat_info["color_ramp"] = gradient_stops
info["material"] = mat_info
else:
info["material"] = null
return success(info)

View File

@@ -0,0 +1,757 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
const PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd")
func get_commands() -> Dictionary:
return {
"setup_collision": _setup_collision,
"set_physics_layers": _set_physics_layers,
"get_physics_layers": _get_physics_layers,
"add_raycast": _add_raycast,
"setup_physics_body": _setup_physics_body,
"get_collision_info": _get_collision_info,
}
## Determine if a node (or its ancestors) lives in a 2D or 3D context.
## Returns "2d", "3d", or "" if undetermined.
func _detect_dimension(node: Node) -> String:
if node is Node2D or node is Control:
return "2d"
if node is Node3D:
return "3d"
# Walk up the tree
var parent := node.get_parent()
while parent != null:
if parent is Node2D or parent is Control:
return "2d"
if parent is Node3D:
return "3d"
parent = parent.get_parent()
return ""
func _setup_collision(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "shape")
if result2[1] != null:
return result2[1]
var shape_name: String = result2[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
var dim := _detect_dimension(node)
if dim.is_empty():
# Allow explicit override
dim = optional_string(params, "dimension", "2d")
# Validate parent can have collision children
var valid_parents_2d := ["PhysicsBody2D", "Area2D", "StaticBody2D", "CharacterBody2D", "RigidBody2D", "AnimatableBody2D"]
var valid_parents_3d := ["PhysicsBody3D", "Area3D", "StaticBody3D", "CharacterBody3D", "RigidBody3D", "AnimatableBody3D"]
var is_valid_parent := false
if dim == "2d":
for vp: String in valid_parents_2d:
if node.is_class(vp):
is_valid_parent = true
break
else:
for vp: String in valid_parents_3d:
if node.is_class(vp):
is_valid_parent = true
break
if not is_valid_parent:
return error_invalid_params("Node '%s' (%s) is not a physics body or area. CollisionShape should be added to a PhysicsBody or Area node." % [node_path, node.get_class()])
# Create shape resource
var shape: Resource = null
var child_name := "CollisionShape"
if dim == "2d":
match shape_name:
"rectangle", "rect":
shape = RectangleShape2D.new()
var w: float = float(params.get("width", 32.0))
var h: float = float(params.get("height", 32.0))
shape.size = Vector2(w, h)
"circle":
shape = CircleShape2D.new()
shape.radius = float(params.get("radius", 16.0))
"capsule":
shape = CapsuleShape2D.new()
shape.radius = float(params.get("radius", 16.0))
shape.height = float(params.get("height", 40.0))
"segment":
shape = SegmentShape2D.new()
shape.a = Vector2(float(params.get("ax", 0.0)), float(params.get("ay", 0.0)))
shape.b = Vector2(float(params.get("bx", 32.0)), float(params.get("by", 0.0)))
"custom":
# ConvexPolygonShape2D — expects "points" as array of [x,y] pairs
shape = ConvexPolygonShape2D.new()
var points_data: Array = params.get("points", [])
var pool: PackedVector2Array = PackedVector2Array()
for p: Variant in points_data:
if p is Array and p.size() >= 2:
pool.append(Vector2(float(p[0]), float(p[1])))
if pool.size() >= 3:
shape.points = pool
_:
return error_invalid_params("Unknown 2D shape: '%s'. Available: rectangle, circle, capsule, segment, custom" % shape_name)
var collision_node := CollisionShape2D.new()
collision_node.shape = shape
collision_node.name = child_name
var disabled: bool = optional_bool(params, "disabled", false)
collision_node.disabled = disabled
var one_way: bool = optional_bool(params, "one_way_collision", false)
collision_node.one_way_collision = one_way
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Add CollisionShape2D to %s" % node.name)
undo_redo.add_do_method(node, "add_child", collision_node)
undo_redo.add_do_method(collision_node, "set_owner", root)
undo_redo.add_do_reference(collision_node)
undo_redo.add_undo_method(node, "remove_child", collision_node)
undo_redo.commit_action()
return success({
"node_path": str(root.get_path_to(collision_node)),
"shape_type": shape.get_class(),
"dimension": "2D",
})
else:
# 3D shapes
match shape_name:
"box", "rectangle", "rect":
shape = BoxShape3D.new()
var sx: float = float(params.get("width", 1.0))
var sy: float = float(params.get("height", 1.0))
var sz: float = float(params.get("depth", 1.0))
shape.size = Vector3(sx, sy, sz)
"sphere", "circle":
shape = SphereShape3D.new()
shape.radius = float(params.get("radius", 0.5))
"capsule":
shape = CapsuleShape3D.new()
shape.radius = float(params.get("radius", 0.5))
shape.height = float(params.get("height", 2.0))
"cylinder":
shape = CylinderShape3D.new()
shape.radius = float(params.get("radius", 0.5))
shape.height = float(params.get("height", 2.0))
"convex", "custom":
shape = ConvexPolygonShape3D.new()
var points_data: Array = params.get("points", [])
var pool: PackedVector3Array = PackedVector3Array()
for p: Variant in points_data:
if p is Array and p.size() >= 3:
pool.append(Vector3(float(p[0]), float(p[1]), float(p[2])))
if pool.size() >= 4:
shape.points = pool
_:
return error_invalid_params("Unknown 3D shape: '%s'. Available: box, sphere, capsule, cylinder, convex" % shape_name)
var collision_node := CollisionShape3D.new()
collision_node.shape = shape
collision_node.name = child_name
var disabled: bool = optional_bool(params, "disabled", false)
collision_node.disabled = disabled
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Add CollisionShape3D to %s" % node.name)
undo_redo.add_do_method(node, "add_child", collision_node)
undo_redo.add_do_method(collision_node, "set_owner", root)
undo_redo.add_do_reference(collision_node)
undo_redo.add_undo_method(node, "remove_child", collision_node)
undo_redo.commit_action()
return success({
"node_path": str(root.get_path_to(collision_node)),
"shape_type": shape.get_class(),
"dimension": "3D",
})
func _get_layer_name(dim: String, layer_index: int) -> String:
var setting_key := "layer_names/%s_physics/layer_%d" % [dim, layer_index]
if ProjectSettings.has_setting(setting_key):
var name_val: Variant = ProjectSettings.get_setting(setting_key)
if name_val is String and not (name_val as String).is_empty():
return name_val as String
return ""
func _layer_bitmask_to_info(bitmask: int, dim: String) -> Array:
var layers: Array = []
for i in range(1, 33):
if bitmask & (1 << (i - 1)):
var layer_name := _get_layer_name(dim, i)
var entry: Dictionary = {"layer": i}
if not layer_name.is_empty():
entry["name"] = layer_name
layers.append(entry)
return layers
func _set_physics_layers(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
# Check node has collision_layer/collision_mask properties
if not "collision_layer" in node:
return error_invalid_params("Node '%s' (%s) does not have collision_layer property" % [node_path, node.get_class()])
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Set physics layers on %s" % node.name)
var changes: Dictionary = {}
if params.has("collision_layer"):
var old_layer: int = node.get("collision_layer")
var new_layer: int = _parse_layer_value(params["collision_layer"])
undo_redo.add_do_property(node, "collision_layer", new_layer)
undo_redo.add_undo_property(node, "collision_layer", old_layer)
changes["collision_layer"] = new_layer
if params.has("collision_mask"):
var old_mask: int = node.get("collision_mask")
var new_mask: int = _parse_layer_value(params["collision_mask"])
undo_redo.add_do_property(node, "collision_mask", new_mask)
undo_redo.add_undo_property(node, "collision_mask", old_mask)
changes["collision_mask"] = new_mask
if changes.is_empty():
return error_invalid_params("Must provide collision_layer and/or collision_mask")
undo_redo.commit_action()
var dim := _detect_dimension(node)
if dim.is_empty():
dim = "2d"
var result_data: Dictionary = {
"node_path": str(root.get_path_to(node)),
}
if changes.has("collision_layer"):
result_data["collision_layer"] = changes["collision_layer"]
result_data["collision_layer_info"] = _layer_bitmask_to_info(changes["collision_layer"], dim)
if changes.has("collision_mask"):
result_data["collision_mask"] = changes["collision_mask"]
result_data["collision_mask_info"] = _layer_bitmask_to_info(changes["collision_mask"], dim)
return success(result_data)
## Parse layer value: can be an int bitmask, or an array of layer numbers [1, 3, 5]
func _parse_layer_value(value: Variant) -> int:
if value is int or value is float:
return int(value)
if value is Array:
var bitmask: int = 0
for layer_num: Variant in value:
var n: int = int(layer_num)
if n >= 1 and n <= 32:
bitmask |= (1 << (n - 1))
return bitmask
# Try parsing as int
return int(value)
func _get_physics_layers(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
if not "collision_layer" in node:
return error_invalid_params("Node '%s' (%s) does not have collision_layer property" % [node_path, node.get_class()])
var layer: int = node.get("collision_layer")
var mask: int = node.get("collision_mask")
var dim := _detect_dimension(node)
if dim.is_empty():
dim = "2d"
return success({
"node_path": str(root.get_path_to(node)),
"type": node.get_class(),
"collision_layer": layer,
"collision_layer_info": _layer_bitmask_to_info(layer, dim),
"collision_mask": mask,
"collision_mask_info": _layer_bitmask_to_info(mask, dim),
})
func _add_raycast(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
var dim := _detect_dimension(node)
if dim.is_empty():
dim = optional_string(params, "dimension", "2d")
var ray_name: String = optional_string(params, "name", "RayCast")
var enabled: bool = optional_bool(params, "enabled", true)
var collision_mask: int = optional_int(params, "collision_mask", 1)
var collide_with_areas: bool = optional_bool(params, "collide_with_areas", false)
var collide_with_bodies: bool = optional_bool(params, "collide_with_bodies", true)
var hit_from_inside: bool = optional_bool(params, "hit_from_inside", false)
var undo_redo := get_undo_redo()
if dim == "2d":
var ray := RayCast2D.new()
ray.name = ray_name
ray.enabled = enabled
ray.collision_mask = collision_mask
ray.collide_with_areas = collide_with_areas
ray.collide_with_bodies = collide_with_bodies
ray.hit_from_inside = hit_from_inside
var tx: float = float(params.get("target_x", 0.0))
var ty: float = float(params.get("target_y", 50.0))
ray.target_position = Vector2(tx, ty)
undo_redo.create_action("MCP: Add RayCast2D to %s" % node.name)
undo_redo.add_do_method(node, "add_child", ray)
undo_redo.add_do_method(ray, "set_owner", root)
undo_redo.add_do_reference(ray)
undo_redo.add_undo_method(node, "remove_child", ray)
undo_redo.commit_action()
return success({
"node_path": str(root.get_path_to(ray)),
"type": "RayCast2D",
"target_position": "Vector2(%s, %s)" % [tx, ty],
"collision_mask": collision_mask,
})
else:
var ray := RayCast3D.new()
ray.name = ray_name
ray.enabled = enabled
ray.collision_mask = collision_mask
ray.collide_with_areas = collide_with_areas
ray.collide_with_bodies = collide_with_bodies
ray.hit_from_inside = hit_from_inside
var tx: float = float(params.get("target_x", 0.0))
var ty: float = float(params.get("target_y", -1.0))
var tz: float = float(params.get("target_z", 0.0))
ray.target_position = Vector3(tx, ty, tz)
undo_redo.create_action("MCP: Add RayCast3D to %s" % node.name)
undo_redo.add_do_method(node, "add_child", ray)
undo_redo.add_do_method(ray, "set_owner", root)
undo_redo.add_do_reference(ray)
undo_redo.add_undo_method(node, "remove_child", ray)
undo_redo.commit_action()
return success({
"node_path": str(root.get_path_to(ray)),
"type": "RayCast3D",
"target_position": "Vector3(%s, %s, %s)" % [tx, ty, tz],
"collision_mask": collision_mask,
})
func _setup_physics_body(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Setup physics body %s" % node.name)
var applied: Dictionary = {}
if node is CharacterBody2D or node is CharacterBody3D:
# CharacterBody properties
if params.has("floor_stop_on_slope"):
var old_val: bool = node.floor_stop_on_slope
var new_val: bool = bool(params["floor_stop_on_slope"])
undo_redo.add_do_property(node, "floor_stop_on_slope", new_val)
undo_redo.add_undo_property(node, "floor_stop_on_slope", old_val)
applied["floor_stop_on_slope"] = new_val
if params.has("floor_max_angle"):
var old_val: float = node.floor_max_angle
var new_val: float = float(params["floor_max_angle"])
undo_redo.add_do_property(node, "floor_max_angle", new_val)
undo_redo.add_undo_property(node, "floor_max_angle", old_val)
applied["floor_max_angle"] = new_val
if params.has("floor_snap_length"):
var old_val: float = node.floor_snap_length
var new_val: float = float(params["floor_snap_length"])
undo_redo.add_do_property(node, "floor_snap_length", new_val)
undo_redo.add_undo_property(node, "floor_snap_length", old_val)
applied["floor_snap_length"] = new_val
if params.has("wall_min_slide_angle"):
var old_val: float = node.wall_min_slide_angle
var new_val: float = float(params["wall_min_slide_angle"])
undo_redo.add_do_property(node, "wall_min_slide_angle", new_val)
undo_redo.add_undo_property(node, "wall_min_slide_angle", old_val)
applied["wall_min_slide_angle"] = new_val
if params.has("motion_mode"):
var mode_str: String = str(params["motion_mode"])
var mode_val: int = 0
if node is CharacterBody2D:
match mode_str.to_lower():
"grounded":
mode_val = CharacterBody2D.MOTION_MODE_GROUNDED
"floating":
mode_val = CharacterBody2D.MOTION_MODE_FLOATING
_:
mode_val = int(params["motion_mode"])
else:
match mode_str.to_lower():
"grounded":
mode_val = CharacterBody3D.MOTION_MODE_GROUNDED
"floating":
mode_val = CharacterBody3D.MOTION_MODE_FLOATING
_:
mode_val = int(params["motion_mode"])
var old_val: int = node.motion_mode
undo_redo.add_do_property(node, "motion_mode", mode_val)
undo_redo.add_undo_property(node, "motion_mode", old_val)
applied["motion_mode"] = mode_str
if params.has("max_slides"):
var old_val: int = node.max_slides
var new_val: int = int(params["max_slides"])
undo_redo.add_do_property(node, "max_slides", new_val)
undo_redo.add_undo_property(node, "max_slides", old_val)
applied["max_slides"] = new_val
if params.has("slide_on_ceiling"):
var old_val: bool = node.slide_on_ceiling
var new_val: bool = bool(params["slide_on_ceiling"])
undo_redo.add_do_property(node, "slide_on_ceiling", new_val)
undo_redo.add_undo_property(node, "slide_on_ceiling", old_val)
applied["slide_on_ceiling"] = new_val
elif node is RigidBody2D or node is RigidBody3D:
# RigidBody properties
if params.has("mass"):
var old_val: float = node.mass
var new_val: float = float(params["mass"])
undo_redo.add_do_property(node, "mass", new_val)
undo_redo.add_undo_property(node, "mass", old_val)
applied["mass"] = new_val
if params.has("gravity_scale"):
var old_val: float = node.gravity_scale
var new_val: float = float(params["gravity_scale"])
undo_redo.add_do_property(node, "gravity_scale", new_val)
undo_redo.add_undo_property(node, "gravity_scale", old_val)
applied["gravity_scale"] = new_val
if params.has("linear_damp"):
var old_val: float = node.linear_damp
var new_val: float = float(params["linear_damp"])
undo_redo.add_do_property(node, "linear_damp", new_val)
undo_redo.add_undo_property(node, "linear_damp", old_val)
applied["linear_damp"] = new_val
if params.has("angular_damp"):
var old_val: float = node.angular_damp
var new_val: float = float(params["angular_damp"])
undo_redo.add_do_property(node, "angular_damp", new_val)
undo_redo.add_undo_property(node, "angular_damp", old_val)
applied["angular_damp"] = new_val
if params.has("freeze"):
var old_val: bool = node.freeze
var new_val: bool = bool(params["freeze"])
undo_redo.add_do_property(node, "freeze", new_val)
undo_redo.add_undo_property(node, "freeze", old_val)
applied["freeze"] = new_val
if params.has("freeze_mode"):
var mode_str: String = str(params["freeze_mode"])
var mode_val: int = 0
if node is RigidBody2D:
match mode_str.to_lower():
"static":
mode_val = RigidBody2D.FREEZE_MODE_STATIC
"kinematic":
mode_val = RigidBody2D.FREEZE_MODE_KINEMATIC
_:
mode_val = int(params["freeze_mode"])
else:
match mode_str.to_lower():
"static":
mode_val = RigidBody3D.FREEZE_MODE_STATIC
"kinematic":
mode_val = RigidBody3D.FREEZE_MODE_KINEMATIC
_:
mode_val = int(params["freeze_mode"])
var old_val: int = node.freeze_mode
undo_redo.add_do_property(node, "freeze_mode", mode_val)
undo_redo.add_undo_property(node, "freeze_mode", old_val)
applied["freeze_mode"] = mode_str
if params.has("continuous_cd"):
if node is RigidBody2D:
var ccd_str: String = str(params["continuous_cd"])
var ccd_val: int = 0
match ccd_str.to_lower():
"disabled":
ccd_val = RigidBody2D.CCD_MODE_DISABLED
"cast_ray":
ccd_val = RigidBody2D.CCD_MODE_CAST_RAY
"cast_shape":
ccd_val = RigidBody2D.CCD_MODE_CAST_SHAPE
_:
ccd_val = int(params["continuous_cd"])
var old_val: int = node.continuous_cd
undo_redo.add_do_property(node, "continuous_cd", ccd_val)
undo_redo.add_undo_property(node, "continuous_cd", old_val)
applied["continuous_cd"] = ccd_str
else:
var old_val: bool = node.continuous_cd
var new_val: bool = bool(params["continuous_cd"])
undo_redo.add_do_property(node, "continuous_cd", new_val)
undo_redo.add_undo_property(node, "continuous_cd", old_val)
applied["continuous_cd"] = new_val
if params.has("contact_monitor"):
var old_val: bool = node.contact_monitor
var new_val: bool = bool(params["contact_monitor"])
undo_redo.add_do_property(node, "contact_monitor", new_val)
undo_redo.add_undo_property(node, "contact_monitor", old_val)
applied["contact_monitor"] = new_val
if params.has("max_contacts_reported"):
var old_val: int = node.max_contacts_reported
var new_val: int = int(params["max_contacts_reported"])
undo_redo.add_do_property(node, "max_contacts_reported", new_val)
undo_redo.add_undo_property(node, "max_contacts_reported", old_val)
applied["max_contacts_reported"] = new_val
elif node is StaticBody2D or node is StaticBody3D or node is AnimatableBody2D or node is AnimatableBody3D:
# StaticBody / AnimatableBody shared properties
if params.has("physics_material_override"):
# We just note it — use add_resource for complex resource assignment
return error_invalid_params("Use add_resource to set physics_material_override")
else:
return error_invalid_params("Node '%s' (%s) is not a recognized physics body type. Supported: CharacterBody2D/3D, RigidBody2D/3D, StaticBody2D/3D, AnimatableBody2D/3D" % [node_path, node.get_class()])
if applied.is_empty():
undo_redo.commit_action()
return error_invalid_params("No valid properties provided for %s" % node.get_class())
undo_redo.commit_action()
return success({
"node_path": str(root.get_path_to(node)),
"type": node.get_class(),
"applied": applied,
})
func _get_collision_info(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
var include_children: bool = optional_bool(params, "include_children", true)
var info: Dictionary = {
"node_path": str(root.get_path_to(node)),
"type": node.get_class(),
}
# Collect physics body properties
if "collision_layer" in node:
var dim := _detect_dimension(node)
if dim.is_empty():
dim = "2d"
info["collision_layer"] = node.get("collision_layer")
info["collision_layer_info"] = _layer_bitmask_to_info(int(node.get("collision_layer")), dim)
info["collision_mask"] = node.get("collision_mask")
info["collision_mask_info"] = _layer_bitmask_to_info(int(node.get("collision_mask")), dim)
# Collect body-specific properties
if node is CharacterBody2D or node is CharacterBody3D:
info["body_settings"] = {
"motion_mode": node.motion_mode,
"floor_stop_on_slope": node.floor_stop_on_slope,
"floor_max_angle": node.floor_max_angle,
"floor_snap_length": node.floor_snap_length,
"wall_min_slide_angle": node.wall_min_slide_angle,
"max_slides": node.max_slides,
"slide_on_ceiling": node.slide_on_ceiling,
}
elif node is RigidBody2D or node is RigidBody3D:
info["body_settings"] = {
"mass": node.mass,
"gravity_scale": node.gravity_scale,
"linear_damp": node.linear_damp,
"angular_damp": node.angular_damp,
"freeze": node.freeze,
"freeze_mode": node.freeze_mode,
"contact_monitor": node.contact_monitor,
"max_contacts_reported": node.max_contacts_reported,
}
# Collect collision shapes
var shapes: Array = []
var raycasts: Array = []
var nodes_to_check: Array = [node]
if include_children:
var queue: Array = [node]
while queue.size() > 0:
var current: Node = queue.pop_front()
for child_idx in current.get_child_count():
var child := current.get_child(child_idx)
nodes_to_check.append(child)
queue.append(child)
for check_node: Node in nodes_to_check:
if check_node is CollisionShape2D:
var shape_info: Dictionary = {
"node_path": str(root.get_path_to(check_node)),
"disabled": check_node.disabled,
"one_way_collision": check_node.one_way_collision,
}
if check_node.shape != null:
shape_info["shape_type"] = check_node.shape.get_class()
if check_node.shape is RectangleShape2D:
shape_info["size"] = "Vector2(%s, %s)" % [check_node.shape.size.x, check_node.shape.size.y]
elif check_node.shape is CircleShape2D:
shape_info["radius"] = check_node.shape.radius
elif check_node.shape is CapsuleShape2D:
shape_info["radius"] = check_node.shape.radius
shape_info["height"] = check_node.shape.height
shapes.append(shape_info)
elif check_node is CollisionShape3D:
var shape_info: Dictionary = {
"node_path": str(root.get_path_to(check_node)),
"disabled": check_node.disabled,
}
if check_node.shape != null:
shape_info["shape_type"] = check_node.shape.get_class()
if check_node.shape is BoxShape3D:
shape_info["size"] = "Vector3(%s, %s, %s)" % [check_node.shape.size.x, check_node.shape.size.y, check_node.shape.size.z]
elif check_node.shape is SphereShape3D:
shape_info["radius"] = check_node.shape.radius
elif check_node.shape is CapsuleShape3D:
shape_info["radius"] = check_node.shape.radius
shape_info["height"] = check_node.shape.height
elif check_node.shape is CylinderShape3D:
shape_info["radius"] = check_node.shape.radius
shape_info["height"] = check_node.shape.height
shapes.append(shape_info)
elif check_node is CollisionPolygon2D:
shapes.append({
"node_path": str(root.get_path_to(check_node)),
"shape_type": "CollisionPolygon2D",
"disabled": check_node.disabled,
"one_way_collision": check_node.one_way_collision,
"polygon_points": check_node.polygon.size(),
})
elif check_node is CollisionPolygon3D:
shapes.append({
"node_path": str(root.get_path_to(check_node)),
"shape_type": "CollisionPolygon3D",
"disabled": check_node.disabled,
"polygon_points": check_node.polygon.size(),
})
elif check_node is RayCast2D:
raycasts.append({
"node_path": str(root.get_path_to(check_node)),
"type": "RayCast2D",
"enabled": check_node.enabled,
"target_position": "Vector2(%s, %s)" % [check_node.target_position.x, check_node.target_position.y],
"collision_mask": check_node.collision_mask,
"collide_with_areas": check_node.collide_with_areas,
"collide_with_bodies": check_node.collide_with_bodies,
})
elif check_node is RayCast3D:
raycasts.append({
"node_path": str(root.get_path_to(check_node)),
"type": "RayCast3D",
"enabled": check_node.enabled,
"target_position": "Vector3(%s, %s, %s)" % [check_node.target_position.x, check_node.target_position.y, check_node.target_position.z],
"collision_mask": check_node.collision_mask,
"collide_with_areas": check_node.collide_with_areas,
"collide_with_bodies": check_node.collide_with_bodies,
})
info["collision_shapes"] = shapes
info["raycasts"] = raycasts
return success(info)

View File

@@ -0,0 +1,69 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
func get_commands() -> Dictionary:
return {
"get_performance_monitors": _get_performance_monitors,
"get_editor_performance": _get_editor_performance,
}
func _get_performance_monitors(params: Dictionary) -> Dictionary:
# Return all available performance monitors
var monitors := {}
monitors["fps"] = Performance.get_monitor(Performance.TIME_FPS)
monitors["frame_time_msec"] = Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0
monitors["physics_frame_time_msec"] = Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0
monitors["navigation_process_msec"] = Performance.get_monitor(Performance.TIME_NAVIGATION_PROCESS) * 1000.0
monitors["memory_static"] = Performance.get_monitor(Performance.MEMORY_STATIC)
monitors["memory_static_max"] = Performance.get_monitor(Performance.MEMORY_STATIC_MAX)
monitors["object_count"] = Performance.get_monitor(Performance.OBJECT_COUNT)
monitors["object_resource_count"] = Performance.get_monitor(Performance.OBJECT_RESOURCE_COUNT)
monitors["object_node_count"] = Performance.get_monitor(Performance.OBJECT_NODE_COUNT)
monitors["object_orphan_node_count"] = Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT)
monitors["render_total_objects_in_frame"] = Performance.get_monitor(Performance.RENDER_TOTAL_OBJECTS_IN_FRAME)
monitors["render_total_primitives_in_frame"] = Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME)
monitors["render_total_draw_calls_in_frame"] = Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME)
monitors["render_video_mem_used"] = Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED)
monitors["physics_2d_active_objects"] = Performance.get_monitor(Performance.PHYSICS_2D_ACTIVE_OBJECTS)
monitors["physics_2d_collision_pairs"] = Performance.get_monitor(Performance.PHYSICS_2D_COLLISION_PAIRS)
monitors["physics_2d_island_count"] = Performance.get_monitor(Performance.PHYSICS_2D_ISLAND_COUNT)
monitors["physics_3d_active_objects"] = Performance.get_monitor(Performance.PHYSICS_3D_ACTIVE_OBJECTS)
monitors["physics_3d_collision_pairs"] = Performance.get_monitor(Performance.PHYSICS_3D_COLLISION_PAIRS)
monitors["physics_3d_island_count"] = Performance.get_monitor(Performance.PHYSICS_3D_ISLAND_COUNT)
monitors["navigation_active_maps"] = Performance.get_monitor(Performance.NAVIGATION_ACTIVE_MAPS)
monitors["navigation_region_count"] = Performance.get_monitor(Performance.NAVIGATION_REGION_COUNT)
monitors["navigation_agent_count"] = Performance.get_monitor(Performance.NAVIGATION_AGENT_COUNT)
# Filter by category if requested
var category: String = optional_string(params, "category", "")
if not category.is_empty():
var filtered := {}
for key: String in monitors:
if key.begins_with(category):
filtered[key] = monitors[key]
return success({"monitors": filtered, "category": category})
return success({"monitors": monitors})
func _get_editor_performance(params: Dictionary) -> Dictionary:
# Quick summary for common use
var summary := {
"fps": Performance.get_monitor(Performance.TIME_FPS),
"frame_time_msec": Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0,
"draw_calls": Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME),
"objects_in_frame": Performance.get_monitor(Performance.RENDER_TOTAL_OBJECTS_IN_FRAME),
"node_count": Performance.get_monitor(Performance.OBJECT_NODE_COUNT),
"orphan_nodes": Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT),
"memory_static_mb": Performance.get_monitor(Performance.MEMORY_STATIC) / (1024.0 * 1024.0),
"video_mem_mb": Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED) / (1024.0 * 1024.0),
}
return success(summary)

View File

@@ -0,0 +1,390 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
func get_commands() -> Dictionary:
return {
"get_project_info": _get_project_info,
"get_filesystem_tree": _get_filesystem_tree,
"search_files": _search_files,
"search_in_files": _search_in_files,
"get_project_settings": _get_project_settings,
"set_project_setting": _set_project_setting,
"uid_to_project_path": _uid_to_project_path,
"project_path_to_uid": _project_path_to_uid,
"add_autoload": _add_autoload,
"remove_autoload": _remove_autoload,
}
func _get_project_info(params: Dictionary) -> Dictionary:
var info := {}
info["project_name"] = ProjectSettings.get_setting("application/config/name", "")
info["godot_version"] = Engine.get_version_info()
info["project_path"] = ProjectSettings.globalize_path("res://")
info["main_scene"] = ProjectSettings.get_setting("application/run/main_scene", "")
# Viewport settings
info["viewport_width"] = ProjectSettings.get_setting("display/window/size/viewport_width", 0)
info["viewport_height"] = ProjectSettings.get_setting("display/window/size/viewport_height", 0)
info["window_width"] = ProjectSettings.get_setting("display/window/size/window_width_override", 0)
info["window_height"] = ProjectSettings.get_setting("display/window/size/window_height_override", 0)
# Rendering
info["renderer"] = ProjectSettings.get_setting("rendering/renderer/rendering_method", "")
# Autoloads
var autoloads := {}
for prop in ProjectSettings.get_property_list():
var name: String = prop["name"]
if name.begins_with("autoload/"):
autoloads[name.substr(9)] = ProjectSettings.get_setting(name)
info["autoloads"] = autoloads
return success(info)
func _get_filesystem_tree(params: Dictionary) -> Dictionary:
var path: String = optional_string(params, "path", "res://")
var filter: String = optional_string(params, "filter", "") # e.g. "*.gd", "*.tscn"
var max_depth: int = optional_int(params, "max_depth", 10)
var tree := _scan_directory(path, filter, max_depth, 0)
return success({"tree": tree})
func _scan_directory(path: String, filter: String, max_depth: int, depth: int) -> Dictionary:
var result := {"name": path.get_file(), "path": path, "type": "directory"}
if depth >= max_depth:
return result
var dir := DirAccess.open(path)
if dir == null:
return result
var children: Array = []
dir.list_dir_begin()
var file_name := dir.get_next()
while not file_name.is_empty():
if file_name.begins_with("."):
file_name = dir.get_next()
continue
var full_path := path.path_join(file_name)
if dir.current_is_dir():
children.append(_scan_directory(full_path, filter, max_depth, depth + 1))
else:
if filter.is_empty() or file_name.match(filter):
children.append({
"name": file_name,
"path": full_path,
"type": "file",
})
file_name = dir.get_next()
dir.list_dir_end()
if not children.is_empty():
result["children"] = children
return result
func _search_files(params: Dictionary) -> Dictionary:
var result := require_string(params, "query")
if result[1] != null:
return result[1]
var query: String = result[0]
var path: String = optional_string(params, "path", "res://")
var file_type: String = optional_string(params, "file_type", "") # e.g. "gd", "tscn"
var max_results: int = optional_int(params, "max_results", 50)
var matches: Array = []
_search_recursive(path, query, file_type, matches, max_results)
return success({"matches": matches, "count": matches.size()})
func _search_recursive(path: String, query: String, file_type: String, matches: Array, max_results: int) -> void:
if matches.size() >= max_results:
return
var dir := DirAccess.open(path)
if dir == null:
return
dir.list_dir_begin()
var file_name := dir.get_next()
while not file_name.is_empty() and matches.size() < max_results:
if file_name.begins_with("."):
file_name = dir.get_next()
continue
var full_path := path.path_join(file_name)
if dir.current_is_dir():
_search_recursive(full_path, query, file_type, matches, max_results)
else:
# Check file type filter
if not file_type.is_empty() and file_name.get_extension() != file_type:
file_name = dir.get_next()
continue
# Fuzzy match: check if query is contained in filename (case insensitive)
if file_name.to_lower().contains(query.to_lower()):
matches.append(full_path)
# Also check glob pattern
elif file_name.match(query):
matches.append(full_path)
file_name = dir.get_next()
dir.list_dir_end()
func _get_project_settings(params: Dictionary) -> Dictionary:
var section: String = optional_string(params, "section", "")
var key: String = optional_string(params, "key", "")
# If specific key requested
if not key.is_empty():
if ProjectSettings.has_setting(key):
var value = ProjectSettings.get_setting(key)
return success({"key": key, "value": str(value), "type": typeof(value)})
else:
return error_not_found("Setting '%s'" % key)
# If section requested, return all settings in that section
var settings := {}
for prop in ProjectSettings.get_property_list():
var name: String = prop["name"]
if section.is_empty() or name.begins_with(section):
settings[name] = str(ProjectSettings.get_setting(name))
return success({"settings": settings, "count": settings.size()})
func _set_project_setting(params: Dictionary) -> Dictionary:
var result := require_string(params, "key")
if result[1] != null:
return result[1]
var key: String = result[0]
if not params.has("value"):
return error_invalid_params("Missing required parameter: value")
var value = params["value"]
# Type conversion for common patterns
if value is String:
var s: String = value
# Try to parse typed values from string
if s.begins_with("Vector2("):
var expr := Expression.new()
if expr.parse(s) == OK:
var parsed = expr.execute()
if parsed is Vector2:
value = parsed
elif s == "true":
value = true
elif s == "false":
value = false
elif s.is_valid_int():
value = s.to_int()
elif s.is_valid_float():
value = s.to_float()
ProjectSettings.set_setting(key, value)
var err := ProjectSettings.save()
if err != OK:
return error_internal("Failed to save project settings: %s" % error_string(err))
return success({
"key": key,
"value": str(ProjectSettings.get_setting(key)),
"saved": true,
})
func _uid_to_project_path(params: Dictionary) -> Dictionary:
var result := require_string(params, "uid")
if result[1] != null:
return result[1]
var uid_str: String = result[0]
# Use ResourceUID to convert
var uid := ResourceUID.text_to_id(uid_str)
if uid == ResourceUID.INVALID_ID:
return error_invalid_params("Invalid UID format: %s" % uid_str)
if not ResourceUID.has_id(uid):
return error_not_found("UID '%s'" % uid_str)
var path := ResourceUID.get_id_path(uid)
return success({"uid": uid_str, "path": path})
func _project_path_to_uid(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
if not ResourceLoader.exists(path):
return error_not_found("Resource at '%s'" % path)
var uid := ResourceLoader.get_resource_uid(path)
if uid == ResourceUID.INVALID_ID:
return error(-32001, "No UID assigned to '%s'" % path)
var uid_str := ResourceUID.id_to_text(uid)
return success({"path": path, "uid": uid_str})
const _TEXT_EXTENSIONS: PackedStringArray = ["gd", "tscn", "tres", "cfg", "godot", "gdshader", "md", "txt", "json"]
func _search_in_files(params: Dictionary) -> Dictionary:
var result := require_string(params, "query")
if result[1] != null:
return result[1]
var query: String = result[0]
var path: String = optional_string(params, "path", "res://")
var max_results: int = optional_int(params, "max_results", 50)
var use_regex: bool = optional_bool(params, "regex", false)
var file_type: String = optional_string(params, "file_type", "")
var regex: RegEx = null
if use_regex:
regex = RegEx.new()
var err := regex.compile(query)
if err != OK:
return error_invalid_params("Invalid regex pattern: %s" % error_string(err))
var matches: Array = []
_search_in_files_recursive(path, query, regex, file_type, matches, max_results)
return success({"matches": matches, "count": matches.size(), "query": query})
func _search_in_files_recursive(path: String, query: String, regex: RegEx, file_type: String, matches: Array, max_results: int) -> void:
if matches.size() >= max_results:
return
var dir := DirAccess.open(path)
if dir == null:
return
dir.list_dir_begin()
var file_name := dir.get_next()
while not file_name.is_empty() and matches.size() < max_results:
if file_name.begins_with("."):
file_name = dir.get_next()
continue
var full_path := path.path_join(file_name)
if dir.current_is_dir():
# Skip addons and .godot directories
if file_name != "addons" and file_name != ".godot":
_search_in_files_recursive(full_path, query, regex, file_type, matches, max_results)
else:
var ext := file_name.get_extension()
# Filter by file type if specified, otherwise use text extensions
if not file_type.is_empty():
if ext != file_type:
file_name = dir.get_next()
continue
elif ext not in _TEXT_EXTENSIONS:
file_name = dir.get_next()
continue
var file := FileAccess.open(full_path, FileAccess.READ)
if file:
var content := file.get_as_text()
file.close()
var lines := content.split("\n")
for i in range(lines.size()):
if matches.size() >= max_results:
break
var line: String = lines[i]
var matched := false
if regex != null:
matched = regex.search(line) != null
else:
matched = line.contains(query)
if matched:
matches.append({
"file": full_path,
"line": i + 1,
"text": line.strip_edges(),
})
file_name = dir.get_next()
dir.list_dir_end()
func _add_autoload(params: Dictionary) -> Dictionary:
var result := require_string(params, "name")
if result[1] != null:
return result[1]
var autoload_name: String = result[0]
var result2 := require_string(params, "path")
if result2[1] != null:
return result2[1]
var autoload_path: String = result2[0]
if not FileAccess.file_exists(autoload_path):
return error_not_found("File '%s'" % autoload_path)
# Check if already exists
var setting_key := "autoload/" + autoload_name
if ProjectSettings.has_setting(setting_key):
return error(-32000, "Autoload '%s' already exists" % autoload_name, {
"current_value": str(ProjectSettings.get_setting(setting_key)),
"suggestion": "Use remove_autoload first to replace it",
})
# Autoload format: "*res://path.gd" (the * prefix means it's a singleton)
ProjectSettings.set_setting(setting_key, "*" + autoload_path)
var err := ProjectSettings.save()
if err != OK:
return error_internal("Failed to save project settings: %s" % error_string(err))
return success({
"name": autoload_name,
"path": autoload_path,
"added": true,
})
func _remove_autoload(params: Dictionary) -> Dictionary:
var result := require_string(params, "name")
if result[1] != null:
return result[1]
var autoload_name: String = result[0]
var setting_key := "autoload/" + autoload_name
if not ProjectSettings.has_setting(setting_key):
return error_not_found("Autoload '%s'" % autoload_name)
var old_value: String = str(ProjectSettings.get_setting(setting_key))
ProjectSettings.clear(setting_key)
var err := ProjectSettings.save()
if err != OK:
return error_internal("Failed to save project settings: %s" % error_string(err))
return success({
"name": autoload_name,
"old_path": old_value,
"removed": true,
})

View File

@@ -0,0 +1,201 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
const PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd")
func get_commands() -> Dictionary:
return {
"read_resource": _read_resource,
"edit_resource": _edit_resource,
"create_resource": _create_resource,
"get_resource_preview": _get_resource_preview,
}
func _read_resource(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
if not FileAccess.file_exists(path):
return error_not_found("Resource '%s'" % path)
var guard := guard_offline_scene_save(path)
if not guard.is_empty():
return guard
var resource: Resource = ResourceLoader.load(path)
if resource == null:
return error_internal("Failed to load resource: %s" % path)
var props: Dictionary = {}
for prop_info in resource.get_property_list():
var prop_name: String = prop_info["name"]
var usage: int = prop_info["usage"]
if not (usage & PROPERTY_USAGE_EDITOR):
continue
if prop_name.begins_with("_") or prop_name == "script" or prop_name == "resource_local_to_scene" or prop_name == "resource_name" or prop_name == "resource_path":
continue
props[prop_name] = PropertyParser.serialize_value(resource.get(prop_name))
return success({
"path": path,
"type": resource.get_class(),
"resource_name": resource.resource_name,
"properties": props,
})
func _edit_resource(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
if not params.has("properties") or not params["properties"] is Dictionary:
return error_invalid_params("'properties' dictionary is required")
var new_props: Dictionary = params["properties"]
if not FileAccess.file_exists(path):
return error_not_found("Resource '%s'" % path)
var guard := guard_offline_scene_save(path)
if not guard.is_empty():
return guard
var resource: Resource = ResourceLoader.load(path)
if resource == null:
return error_internal("Failed to load resource: %s" % path)
var changed: Dictionary = {}
for prop_name: String in new_props:
if not prop_name in resource:
continue
var old_value: Variant = resource.get(prop_name)
var target_type := typeof(old_value)
var new_value: Variant = PropertyParser.parse_value(new_props[prop_name], target_type)
resource.set(prop_name, new_value)
changed[prop_name] = {
"old": PropertyParser.serialize_value(old_value),
"new": PropertyParser.serialize_value(resource.get(prop_name)),
}
if changed.is_empty():
return success({"path": path, "changed": {}, "message": "No properties were changed"})
var err := ResourceSaver.save(resource, path)
if err != OK:
return error_internal("Failed to save resource: %s" % error_string(err))
return success({
"path": path,
"type": resource.get_class(),
"changed": changed,
})
func _create_resource(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
var result2 := require_string(params, "type")
if result2[1] != null:
return result2[1]
var resource_type: String = result2[0]
if not ClassDB.class_exists(resource_type):
return error_invalid_params("Unknown resource type: %s" % resource_type)
if not ClassDB.is_parent_class(resource_type, "Resource"):
return error_invalid_params("'%s' is not a Resource type" % resource_type)
var overwrite: bool = optional_bool(params, "overwrite", false)
if FileAccess.file_exists(path) and not overwrite:
return error(-32000, "Resource already exists: %s" % path, {"suggestion": "Set overwrite=true to replace"})
var guard := guard_offline_scene_save(path)
if not guard.is_empty():
return guard
var resource: Resource = ClassDB.instantiate(resource_type)
if resource == null:
return error_internal("Failed to instantiate: %s" % resource_type)
# Apply properties
var properties: Dictionary = params.get("properties", {})
for prop_name: String in properties:
if prop_name in resource:
var current: Variant = resource.get(prop_name)
resource.set(prop_name, PropertyParser.parse_value(properties[prop_name], typeof(current)))
var err := ResourceSaver.save(resource, path)
if err != OK:
return error_internal("Failed to save resource: %s" % error_string(err))
# Rescan filesystem
EditorInterface.get_resource_filesystem().scan()
return success({
"path": path,
"type": resource_type,
"properties_set": properties.keys(),
})
func _get_resource_preview(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
if not FileAccess.file_exists(path):
return error_not_found("Resource '%s'" % path)
var max_size: int = optional_int(params, "max_size", 256)
var image: Image = null
# Try loading as image file directly
var ext := path.get_extension().to_lower()
if ext in ["png", "jpg", "jpeg", "bmp", "webp", "svg"]:
image = Image.new()
var err := image.load(path)
if err != OK:
return error_internal("Failed to load image: %s" % error_string(err))
else:
# Try loading as resource and extracting image
var resource: Resource = ResourceLoader.load(path)
if resource == null:
return error_internal("Failed to load resource: %s" % path)
if resource is Texture2D:
image = (resource as Texture2D).get_image()
elif resource is Image:
image = resource as Image
else:
return error_invalid_params("Resource type '%s' does not have an image preview" % resource.get_class())
if image == null:
return error_internal("Could not extract image from resource")
# Resize if needed
if image.get_width() > max_size or image.get_height() > max_size:
var scale_x := float(max_size) / float(image.get_width())
var scale_y := float(max_size) / float(image.get_height())
var scale := minf(scale_x, scale_y)
var new_w := int(image.get_width() * scale)
var new_h := int(image.get_height() * scale)
image.resize(new_w, new_h, Image.INTERPOLATE_LANCZOS)
var png_buffer := image.save_png_to_buffer()
var base64 := Marshalls.raw_to_base64(png_buffer)
return success({
"image_base64": base64,
"width": image.get_width(),
"height": image.get_height(),
"format": "png",
"path": path,
})

View File

@@ -0,0 +1,398 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
## Editor-side commands for runtime game inspection.
## Communicates with MCPGameInspector autoload via file-based IPC.
func get_commands() -> Dictionary:
return {
"get_game_scene_tree": _get_game_scene_tree,
"get_game_node_properties": _get_game_node_properties,
"set_game_node_property": _set_game_node_property,
"capture_frames": _capture_frames,
"monitor_properties": _monitor_properties,
"execute_game_script": _execute_game_script,
"start_recording": _start_recording,
"stop_recording": _stop_recording,
"replay_recording": _replay_recording,
"find_nodes_by_script": _find_nodes_by_script,
"get_autoload": _get_autoload,
"batch_get_properties": _batch_get_properties,
"find_ui_elements": _find_ui_elements,
"click_button_by_text": _click_button_by_text,
"wait_for_node": _wait_for_node,
"find_nearby_nodes": _find_nearby_nodes,
"navigate_to": _navigate_to,
"move_to": _move_to,
"watch_signals": _watch_signals,
}
func _get_game_scene_tree(params: Dictionary) -> Dictionary:
var max_depth: int = optional_int(params, "max_depth", -1)
var cmd_params := {"max_depth": max_depth}
var script_filter: String = optional_string(params, "script_filter")
if not script_filter.is_empty():
cmd_params["script_filter"] = script_filter
var type_filter: String = optional_string(params, "type_filter")
if not type_filter.is_empty():
cmd_params["type_filter"] = type_filter
var named_only: bool = optional_bool(params, "named_only", false)
if named_only:
cmd_params["named_only"] = true
return await _send_game_command("get_scene_tree", cmd_params)
func _get_game_node_properties(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var cmd_params := {"node_path": result[0]}
# Optional property filter
if params.has("properties") and params["properties"] is Array:
cmd_params["properties"] = params["properties"]
return await _send_game_command("get_node_properties", cmd_params)
func _set_game_node_property(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var prop_result := require_string(params, "property")
if prop_result[1] != null:
return prop_result[1]
if not params.has("value"):
return error_invalid_params("Missing required parameter: value")
return await _send_game_command("set_node_property", {
"node_path": result[0],
"property": prop_result[0],
"value": params["value"],
})
func _execute_game_script(params: Dictionary) -> Dictionary:
var result := require_string(params, "code")
if result[1] != null:
return result[1]
return await _send_game_command("execute_script", {
"code": result[0],
}, 10.0)
func _capture_frames(params: Dictionary) -> Dictionary:
var count: int = optional_int(params, "count", 5)
var frame_interval: int = optional_int(params, "frame_interval", 10)
var half_resolution: bool = optional_bool(params, "half_resolution", true)
# Dynamic timeout: allow enough time for frame capture
# At 60fps, 30 frames * 10 interval = 300 frames = 5 seconds + overhead
var estimated_seconds: float = (count * frame_interval) / 60.0 + 2.0
var timeout := minf(estimated_seconds, 25.0)
return await _send_game_command("capture_frames", {
"count": count,
"frame_interval": frame_interval,
"half_resolution": half_resolution,
}, timeout)
func _monitor_properties(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
if not params.has("properties") or not params["properties"] is Array:
return error_invalid_params("'properties' array is required")
var frame_count: int = optional_int(params, "frame_count", 60)
var frame_interval: int = optional_int(params, "frame_interval", 1)
# Dynamic timeout
var estimated_seconds: float = (frame_count * frame_interval) / 60.0 + 2.0
var timeout := minf(estimated_seconds, 25.0)
return await _send_game_command("monitor_properties", {
"node_path": result[0],
"properties": params["properties"],
"frame_count": frame_count,
"frame_interval": frame_interval,
}, timeout)
func _start_recording(params: Dictionary) -> Dictionary:
return await _send_game_command("start_recording", {})
func _stop_recording(params: Dictionary) -> Dictionary:
return await _send_game_command("stop_recording", {}, 5.0)
func _replay_recording(params: Dictionary) -> Dictionary:
if not params.has("events") or not params["events"] is Array:
return error_invalid_params("'events' array is required")
var speed: float = float(params.get("speed", 1.0))
# Calculate timeout based on event duration
var max_time_ms: int = 0
for event_data: Dictionary in params["events"]:
var t: int = int(event_data.get("time_ms", 0))
if t > max_time_ms:
max_time_ms = t
var timeout := (max_time_ms / 1000.0 / speed) + 5.0
return await _send_game_command("replay_recording", {
"events": params["events"],
"speed": speed,
}, minf(timeout, 120.0))
func _find_nodes_by_script(params: Dictionary) -> Dictionary:
var result := require_string(params, "script")
if result[1] != null:
return result[1]
var cmd_params := {"script": result[0]}
if params.has("properties") and params["properties"] is Array:
cmd_params["properties"] = params["properties"]
return await _send_game_command("find_nodes_by_script", cmd_params)
func _get_autoload(params: Dictionary) -> Dictionary:
var result := require_string(params, "name")
if result[1] != null:
return result[1]
var cmd_params := {"name": result[0]}
if params.has("properties") and params["properties"] is Array:
cmd_params["properties"] = params["properties"]
return await _send_game_command("get_autoload", cmd_params)
func _batch_get_properties(params: Dictionary) -> Dictionary:
if not params.has("nodes") or not params["nodes"] is Array:
return error_invalid_params("'nodes' array is required")
return await _send_game_command("batch_get_properties", {
"nodes": params["nodes"],
})
func _find_ui_elements(params: Dictionary) -> Dictionary:
var cmd_params := {}
var type_filter: String = optional_string(params, "type_filter")
if not type_filter.is_empty():
cmd_params["type_filter"] = type_filter
return await _send_game_command("find_ui_elements", cmd_params)
func _click_button_by_text(params: Dictionary) -> Dictionary:
var result := require_string(params, "text")
if result[1] != null:
return result[1]
var cmd_params := {"text": result[0]}
var partial: bool = optional_bool(params, "partial", true)
cmd_params["partial"] = partial
return await _send_game_command("click_button_by_text", cmd_params)
func _wait_for_node(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var timeout: float = float(params.get("timeout", 5.0))
var poll_frames: int = optional_int(params, "poll_frames", 5)
return await _send_game_command("wait_for_node", {
"node_path": result[0],
"timeout": timeout,
"poll_frames": poll_frames,
}, timeout + 2.0)
func _find_nearby_nodes(params: Dictionary) -> Dictionary:
if not params.has("position"):
return error_invalid_params("Missing required parameter: position")
var cmd_params: Dictionary = {"position": params["position"]}
if params.has("radius"):
cmd_params["radius"] = float(params["radius"])
var type_filter: String = optional_string(params, "type_filter")
if not type_filter.is_empty():
cmd_params["type_filter"] = type_filter
var group_filter: String = optional_string(params, "group_filter")
if not group_filter.is_empty():
cmd_params["group_filter"] = group_filter
if params.has("max_results"):
cmd_params["max_results"] = int(params["max_results"])
return await _send_game_command("find_nearby_nodes", cmd_params)
func _navigate_to(params: Dictionary) -> Dictionary:
if not params.has("target"):
return error_invalid_params("Missing required parameter: target")
var cmd_params: Dictionary = {"target": params["target"]}
var player_path: String = optional_string(params, "player_path")
if not player_path.is_empty():
cmd_params["player_path"] = player_path
var camera_path: String = optional_string(params, "camera_path")
if not camera_path.is_empty():
cmd_params["camera_path"] = camera_path
if params.has("move_speed"):
cmd_params["move_speed"] = float(params["move_speed"])
return await _send_game_command("navigate_to", cmd_params)
func _move_to(params: Dictionary) -> Dictionary:
if not params.has("target"):
return error_invalid_params("Missing required parameter: target")
var cmd_params: Dictionary = {"target": params["target"]}
var player_path: String = optional_string(params, "player_path")
if not player_path.is_empty():
cmd_params["player_path"] = player_path
var camera_path: String = optional_string(params, "camera_path")
if not camera_path.is_empty():
cmd_params["camera_path"] = camera_path
if params.has("arrival_radius"):
cmd_params["arrival_radius"] = float(params["arrival_radius"])
if params.has("timeout"):
cmd_params["timeout"] = float(params["timeout"])
if params.has("run"):
cmd_params["run"] = bool(params["run"])
if params.has("look_at_target"):
cmd_params["look_at_target"] = bool(params["look_at_target"])
# Dynamic timeout: game-side timeout + overhead for IPC polling
var game_timeout: float = float(params.get("timeout", 15.0))
var ipc_timeout: float = game_timeout + 5.0
return await _send_game_command("move_to", cmd_params, ipc_timeout)
func _watch_signals(params: Dictionary) -> Dictionary:
if not params.has("node_paths") or not params["node_paths"] is Array:
return error_invalid_params("Missing required parameter: node_paths (Array)")
var cmd_params: Dictionary = {"node_paths": params["node_paths"]}
if params.has("signal_filter") and params["signal_filter"] is Array:
cmd_params["signal_filter"] = params["signal_filter"]
var duration_ms: int = optional_int(params, "duration_ms", 5000)
cmd_params["duration_ms"] = duration_ms
# Dynamic timeout: duration + overhead
var timeout_sec: float = (duration_ms / 1000.0) + 5.0
return await _send_game_command("watch_signals", cmd_params, timeout_sec)
# ── IPC Helper ────────────────────────────────────────────────────────────────
func _send_game_command(command: String, params: Dictionary, timeout_sec: float = 5.0) -> Dictionary:
var ei := get_editor()
if not ei.is_playing_scene():
return error(-32000, "No scene is currently playing", {"suggestion": "Use play_scene first"})
var user_dir := get_game_user_dir()
var request_path := user_dir + "/mcp_game_request"
var response_path := user_dir + "/mcp_game_response"
# Clean stale response
if FileAccess.file_exists(response_path):
DirAccess.remove_absolute(response_path)
# Write request
var request_data := JSON.stringify({"command": command, "params": params})
var req := FileAccess.open(request_path, FileAccess.WRITE)
if req == null:
return error_internal("Could not create game request file")
req.store_string(request_data)
req.close()
# Poll for response
var attempts := int(timeout_sec / 0.1)
while attempts > 0:
await get_tree().create_timer(0.1).timeout
if FileAccess.file_exists(response_path):
break
# Check if game is still running
if not ei.is_playing_scene():
if FileAccess.file_exists(request_path):
DirAccess.remove_absolute(request_path)
return error(-32000, "Game stopped during command execution")
attempts -= 1
if not FileAccess.file_exists(response_path):
# Try to auto-resume the debugger (runtime error may have paused the game)
if ei.is_playing_scene():
_try_debugger_continue()
# Give the game a chance to recover and write a response
for _retry in 20:
await get_tree().create_timer(0.1).timeout
if FileAccess.file_exists(response_path):
break
if not FileAccess.file_exists(response_path):
if FileAccess.file_exists(request_path):
DirAccess.remove_absolute(request_path)
return error(-32000, "Game command timed out after %.1fs" % timeout_sec, {
"suggestion": "Ensure the game is running and MCPGameInspector autoload is active",
})
# Read response
var file := FileAccess.open(response_path, FileAccess.READ)
if file == null:
return error_internal("Could not read game response file")
var text := file.get_as_text()
file.close()
DirAccess.remove_absolute(response_path)
var parsed = JSON.parse_string(text)
if parsed == null or not parsed is Dictionary:
return error_internal("Invalid response JSON from game")
if parsed.has("error"):
return error(-32000, str(parsed["error"]))
return success(parsed)
## Press the debugger "Continue" button to resume a paused game process.
func _try_debugger_continue() -> void:
var base := EditorInterface.get_base_control()
if base == null:
return
var queue: Array[Node] = [base]
while not queue.is_empty():
var node := queue.pop_front()
if node.get_class() == "ScriptEditorDebugger":
var inner: Array[Node] = [node]
while not inner.is_empty():
var n := inner.pop_front()
if n is Button and n.tooltip_text == "Continue":
n.emit_signal("pressed")
push_warning("[MCP] Auto-resumed debugger after runtime error")
return
for c in n.get_children():
inner.append(c)
return
for child in node.get_children():
queue.append(child)

View File

@@ -0,0 +1,680 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
const PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd")
const NodeUtils := preload("res://addons/godot_mcp/utils/node_utils.gd")
func get_commands() -> Dictionary:
return {
"add_mesh_instance": _add_mesh_instance,
"setup_lighting": _setup_lighting,
"set_material_3d": _set_material_3d,
"setup_environment": _setup_environment,
"setup_camera_3d": _setup_camera_3d,
"add_gridmap": _add_gridmap,
}
## ─── Helpers ───────────────────────────────────────────────────────────────
func _optional_float(params: Dictionary, key: String, default: float) -> float:
if params.has(key):
return float(params[key])
return default
func _parse_color_param(params: Dictionary, key: String, default: Color) -> Color:
if not params.has(key):
return default
var val: Variant = params[key]
if val is String:
return PropertyParser.parse_value(val, TYPE_COLOR)
if val is Dictionary:
return Color(
float(val.get("r", default.r)),
float(val.get("g", default.g)),
float(val.get("b", default.b)),
float(val.get("a", default.a))
)
return default
func _parse_vector3_param(params: Dictionary, key: String, default: Vector3) -> Vector3:
if not params.has(key):
return default
var val: Variant = params[key]
if val is String:
return PropertyParser.parse_value(val, TYPE_VECTOR3)
if val is Dictionary:
return Vector3(
float(val.get("x", default.x)),
float(val.get("y", default.y)),
float(val.get("z", default.z))
)
if val is Array and val.size() >= 3:
return Vector3(float(val[0]), float(val[1]), float(val[2]))
return default
func _add_child_with_undo(node: Node, parent: Node, root: Node, action_name: String) -> void:
var undo_redo := get_undo_redo()
undo_redo.create_action(action_name)
undo_redo.add_do_method(parent, "add_child", node)
undo_redo.add_do_method(node, "set_owner", root)
undo_redo.add_do_reference(node)
undo_redo.add_undo_method(parent, "remove_child", node)
undo_redo.commit_action()
## ─── 1. add_mesh_instance ──────────────────────────────────────────────────
func _add_mesh_instance(params: Dictionary) -> Dictionary:
var root := get_edited_root()
if root == null:
return error_no_scene()
var parent_path: String = optional_string(params, "parent_path", ".")
var parent := find_node_by_path(parent_path)
if parent == null:
return error_not_found("Parent node '%s'" % parent_path)
var node_name: String = optional_string(params, "name", "MeshInstance3D")
var mesh_type: String = optional_string(params, "mesh_type", "")
var mesh_file: String = optional_string(params, "mesh_file", "")
if mesh_type.is_empty() and mesh_file.is_empty():
return error_invalid_params("Either 'mesh_type' or 'mesh_file' is required")
var mesh_instance := MeshInstance3D.new()
mesh_instance.name = node_name
if not mesh_file.is_empty():
# Load .glb / .gltf / .obj
if not ResourceLoader.exists(mesh_file):
mesh_instance.queue_free()
return error_not_found("Mesh file '%s'" % mesh_file, "Provide a valid res:// path to .glb, .gltf, or .obj")
var loaded: Resource = load(mesh_file)
if loaded is Mesh:
mesh_instance.mesh = loaded as Mesh
elif loaded is PackedScene:
# For .glb/.gltf we instantiate and steal the first MeshInstance3D's mesh
var scene_instance: Node = (loaded as PackedScene).instantiate()
var found_mesh: Mesh = null
var search_nodes: Array[Node] = [scene_instance]
while not search_nodes.is_empty():
var n: Node = search_nodes.pop_front()
if n is MeshInstance3D and (n as MeshInstance3D).mesh != null:
found_mesh = (n as MeshInstance3D).mesh
break
for child in n.get_children():
search_nodes.append(child)
scene_instance.queue_free()
if found_mesh == null:
mesh_instance.queue_free()
return error_invalid_params("No mesh found in '%s'" % mesh_file)
mesh_instance.mesh = found_mesh
else:
mesh_instance.queue_free()
return error_invalid_params("'%s' is not a Mesh or PackedScene" % mesh_file)
else:
# Primitive mesh
var mesh_classes := {
"BoxMesh": BoxMesh,
"SphereMesh": SphereMesh,
"CylinderMesh": CylinderMesh,
"CapsuleMesh": CapsuleMesh,
"PlaneMesh": PlaneMesh,
"PrismMesh": PrismMesh,
"TorusMesh": TorusMesh,
"QuadMesh": QuadMesh,
}
if not mesh_classes.has(mesh_type):
mesh_instance.queue_free()
return error_invalid_params("Unknown mesh_type '%s'. Available: %s" % [mesh_type, mesh_classes.keys()])
var mesh_res: Mesh = mesh_classes[mesh_type].new()
# Apply mesh properties if provided
var mesh_properties: Dictionary = params.get("mesh_properties", {})
for prop_name: String in mesh_properties:
if prop_name in mesh_res:
var current: Variant = mesh_res.get(prop_name)
mesh_res.set(prop_name, PropertyParser.parse_value(mesh_properties[prop_name], typeof(current)))
mesh_instance.mesh = mesh_res
# Transform
var position := _parse_vector3_param(params, "position", Vector3.ZERO)
var rotation_deg := _parse_vector3_param(params, "rotation", Vector3.ZERO)
var scale_vec := _parse_vector3_param(params, "scale", Vector3.ONE)
mesh_instance.position = position
mesh_instance.rotation_degrees = rotation_deg
mesh_instance.scale = scale_vec
_add_child_with_undo(mesh_instance, parent, root, "MCP: Add MeshInstance3D")
return success({
"node_path": str(root.get_path_to(mesh_instance)),
"name": str(mesh_instance.name),
"mesh_type": mesh_type if mesh_file.is_empty() else mesh_file,
})
## ─── 2. setup_lighting ────────────────────────────────────────────────────
func _setup_lighting(params: Dictionary) -> Dictionary:
var root := get_edited_root()
if root == null:
return error_no_scene()
var parent_path: String = optional_string(params, "parent_path", ".")
var parent := find_node_by_path(parent_path)
if parent == null:
return error_not_found("Parent node '%s'" % parent_path)
var light_type: String = optional_string(params, "light_type", "")
var preset: String = optional_string(params, "preset", "")
var node_name: String = optional_string(params, "name", "")
# Preset configurations
if not preset.is_empty():
match preset:
"sun":
light_type = "DirectionalLight3D"
if node_name.is_empty():
node_name = "SunLight"
"indoor":
light_type = "OmniLight3D"
if node_name.is_empty():
node_name = "IndoorLight"
"dramatic":
light_type = "SpotLight3D"
if node_name.is_empty():
node_name = "DramaticLight"
_:
return error_invalid_params("Unknown preset '%s'. Available: sun, indoor, dramatic" % preset)
if light_type.is_empty():
return error_invalid_params("Either 'light_type' or 'preset' is required")
var light: Light3D
match light_type:
"DirectionalLight3D":
light = DirectionalLight3D.new()
"OmniLight3D":
light = OmniLight3D.new()
"SpotLight3D":
light = SpotLight3D.new()
_:
return error_invalid_params("Unknown light_type '%s'. Available: DirectionalLight3D, OmniLight3D, SpotLight3D" % light_type)
if node_name.is_empty():
node_name = light_type
light.name = node_name
# Common properties
light.light_color = _parse_color_param(params, "color", Color.WHITE)
light.light_energy = _optional_float(params, "energy", 1.0)
light.shadow_enabled = optional_bool(params, "shadows", false)
# Type-specific properties
if light is OmniLight3D:
var omni: OmniLight3D = light as OmniLight3D
omni.omni_range = _optional_float(params, "range", 5.0)
omni.omni_attenuation = _optional_float(params, "attenuation", 1.0)
elif light is SpotLight3D:
var spot: SpotLight3D = light as SpotLight3D
spot.spot_range = _optional_float(params, "range", 5.0)
spot.spot_attenuation = _optional_float(params, "attenuation", 1.0)
spot.spot_angle = _optional_float(params, "spot_angle", 45.0)
spot.spot_angle_attenuation = _optional_float(params, "spot_angle_attenuation", 1.0)
# Apply preset defaults after type creation
if not preset.is_empty():
match preset:
"sun":
light.light_energy = _optional_float(params, "energy", 1.0)
light.shadow_enabled = optional_bool(params, "shadows", true)
light.rotation_degrees = _parse_vector3_param(params, "rotation", Vector3(-45, -30, 0))
"indoor":
light.light_energy = _optional_float(params, "energy", 0.8)
light.light_color = _parse_color_param(params, "color", Color(1.0, 0.95, 0.85))
if light is OmniLight3D:
(light as OmniLight3D).omni_range = _optional_float(params, "range", 8.0)
"dramatic":
light.light_energy = _optional_float(params, "energy", 2.0)
light.shadow_enabled = optional_bool(params, "shadows", true)
if light is SpotLight3D:
(light as SpotLight3D).spot_angle = _optional_float(params, "spot_angle", 25.0)
(light as SpotLight3D).spot_range = _optional_float(params, "range", 10.0)
# Position / rotation
light.position = _parse_vector3_param(params, "position", Vector3.ZERO)
if params.has("rotation"):
light.rotation_degrees = _parse_vector3_param(params, "rotation", light.rotation_degrees)
_add_child_with_undo(light, parent, root, "MCP: Add %s" % light_type)
return success({
"node_path": str(root.get_path_to(light)),
"name": str(light.name),
"light_type": light_type,
"preset": preset,
})
## ─── 3. set_material_3d ───────────────────────────────────────────────────
func _set_material_3d(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path)
if not node is MeshInstance3D:
return error_invalid_params("Node '%s' is not a MeshInstance3D (is %s)" % [node_path, node.get_class()])
var mesh_inst: MeshInstance3D = node as MeshInstance3D
var surface_index: int = optional_int(params, "surface_index", 0)
var mat := StandardMaterial3D.new()
# Albedo
mat.albedo_color = _parse_color_param(params, "albedo_color", Color.WHITE)
if params.has("albedo_texture"):
var tex_path: String = params["albedo_texture"]
if ResourceLoader.exists(tex_path):
mat.albedo_texture = load(tex_path) as Texture2D
# PBR
mat.metallic = _optional_float(params, "metallic", 0.0)
mat.roughness = _optional_float(params, "roughness", 1.0)
if params.has("metallic_texture"):
var tex_path: String = params["metallic_texture"]
if ResourceLoader.exists(tex_path):
mat.metallic_texture = load(tex_path) as Texture2D
if params.has("roughness_texture"):
var tex_path: String = params["roughness_texture"]
if ResourceLoader.exists(tex_path):
mat.roughness_texture = load(tex_path) as Texture2D
if params.has("normal_texture"):
mat.normal_enabled = true
var tex_path: String = params["normal_texture"]
if ResourceLoader.exists(tex_path):
mat.normal_texture = load(tex_path) as Texture2D
# Emission
if params.has("emission") or params.has("emission_color"):
mat.emission_enabled = true
mat.emission = _parse_color_param(params, "emission", _parse_color_param(params, "emission_color", Color.BLACK))
mat.emission_energy_multiplier = _optional_float(params, "emission_energy", 1.0)
if params.has("emission_texture"):
mat.emission_enabled = true
var tex_path: String = params["emission_texture"]
if ResourceLoader.exists(tex_path):
mat.emission_texture = load(tex_path) as Texture2D
# Transparency
if params.has("transparency"):
var transparency_val: String = str(params["transparency"])
match transparency_val.to_upper():
"DISABLED", "0":
mat.transparency = BaseMaterial3D.TRANSPARENCY_DISABLED
"ALPHA", "1":
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
"ALPHA_SCISSOR", "2":
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR
"ALPHA_HASH", "3":
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_HASH
"ALPHA_DEPTH_PRE_PASS", "4":
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_DEPTH_PRE_PASS
# Cull mode
if params.has("cull_mode"):
var cull_val: String = str(params["cull_mode"])
match cull_val.to_upper():
"BACK", "0":
mat.cull_mode = BaseMaterial3D.CULL_BACK
"FRONT", "1":
mat.cull_mode = BaseMaterial3D.CULL_FRONT
"DISABLED", "2":
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
# Apply
var old_mat: Material = mesh_inst.get_surface_override_material(surface_index)
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Set material on %s" % mesh_inst.name)
undo_redo.add_do_method(mesh_inst, "set_surface_override_material", surface_index, mat)
undo_redo.add_undo_method(mesh_inst, "set_surface_override_material", surface_index, old_mat)
undo_redo.commit_action()
return success({
"node_path": str(root.get_path_to(mesh_inst)),
"surface_index": surface_index,
"albedo_color": str(mat.albedo_color),
"metallic": mat.metallic,
"roughness": mat.roughness,
})
## ─── 4. setup_environment ─────────────────────────────────────────────────
func _setup_environment(params: Dictionary) -> Dictionary:
var root := get_edited_root()
if root == null:
return error_no_scene()
var parent_path: String = optional_string(params, "parent_path", ".")
var parent := find_node_by_path(parent_path)
if parent == null:
return error_not_found("Parent node '%s'" % parent_path)
var node_name: String = optional_string(params, "name", "WorldEnvironment")
# Check if a WorldEnvironment already exists at the target
var node_path: String = optional_string(params, "node_path", "")
var world_env: WorldEnvironment = null
var is_existing := false
if not node_path.is_empty():
var existing := find_node_by_path(node_path)
if existing != null and existing is WorldEnvironment:
world_env = existing as WorldEnvironment
is_existing = true
if world_env == null:
world_env = WorldEnvironment.new()
world_env.name = node_name
var env: Environment = world_env.environment
if env == null:
env = Environment.new()
# Background / Sky
var bg_mode: String = optional_string(params, "background_mode", "sky")
match bg_mode.to_lower():
"sky":
env.background_mode = Environment.BG_SKY
"color":
env.background_mode = Environment.BG_COLOR
env.background_color = _parse_color_param(params, "background_color", Color(0.3, 0.3, 0.3))
"canvas":
env.background_mode = Environment.BG_CANVAS
"clear_color":
env.background_mode = Environment.BG_CLEAR_COLOR
# Procedural sky
if params.has("sky") and params["sky"] is Dictionary:
var sky_params: Dictionary = params["sky"]
var sky_mat := ProceduralSkyMaterial.new()
sky_mat.sky_top_color = _parse_color_param(sky_params, "sky_top_color", Color(0.385, 0.454, 0.55))
sky_mat.sky_horizon_color = _parse_color_param(sky_params, "sky_horizon_color", Color(0.646, 0.654, 0.67))
sky_mat.ground_bottom_color = _parse_color_param(sky_params, "ground_bottom_color", Color(0.2, 0.169, 0.133))
sky_mat.ground_horizon_color = _parse_color_param(sky_params, "ground_horizon_color", Color(0.646, 0.654, 0.67))
sky_mat.sun_angle_max = _optional_float(sky_params, "sun_angle_max", 30.0) if sky_params.has("sun_angle_max") else 30.0
sky_mat.sky_curve = _optional_float(sky_params, "sky_curve", 0.15) if sky_params.has("sky_curve") else 0.15
var sky := Sky.new()
sky.sky_material = sky_mat
env.sky = sky
env.background_mode = Environment.BG_SKY
# Ambient light
if params.has("ambient_light_color"):
env.ambient_light_color = _parse_color_param(params, "ambient_light_color", Color.WHITE)
env.ambient_light_energy = _optional_float(params, "ambient_light_energy", 1.0) if params.has("ambient_light_energy") else env.ambient_light_energy
if params.has("ambient_light_source"):
var src: String = str(params["ambient_light_source"])
match src.to_upper():
"BACKGROUND", "0":
env.ambient_light_source = Environment.AMBIENT_SOURCE_BG
"DISABLED", "1":
env.ambient_light_source = Environment.AMBIENT_SOURCE_DISABLED
"COLOR", "2":
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
"SKY", "3":
env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
# Tonemap
if params.has("tonemap_mode"):
var tm: String = str(params["tonemap_mode"])
match tm.to_upper():
"LINEAR", "0":
env.tonemap_mode = Environment.TONE_MAPPER_LINEAR
"REINHARDT", "1":
env.tonemap_mode = Environment.TONE_MAPPER_REINHARDT
"FILMIC", "2":
env.tonemap_mode = Environment.TONE_MAPPER_FILMIC
"ACES", "3":
env.tonemap_mode = Environment.TONE_MAPPER_ACES
"AGX", "4":
env.tonemap_mode = 4 # Environment.TONE_MAPPER_AGX (Godot 4.4+)
if params.has("tonemap_exposure"):
env.tonemap_exposure = _optional_float(params, "tonemap_exposure", 1.0)
if params.has("tonemap_white"):
env.tonemap_white = _optional_float(params, "tonemap_white", 1.0)
# Fog
if params.has("fog_enabled"):
env.fog_enabled = optional_bool(params, "fog_enabled", false)
if env.fog_enabled or params.has("fog_light_color"):
env.fog_light_color = _parse_color_param(params, "fog_light_color", Color(0.518, 0.553, 0.608))
env.fog_density = _optional_float(params, "fog_density", 0.01) if params.has("fog_density") else env.fog_density
env.fog_light_energy = _optional_float(params, "fog_light_energy", 1.0) if params.has("fog_light_energy") else env.fog_light_energy
# Glow
if params.has("glow_enabled"):
env.glow_enabled = optional_bool(params, "glow_enabled", false)
if env.glow_enabled:
env.glow_intensity = _optional_float(params, "glow_intensity", 0.8) if params.has("glow_intensity") else env.glow_intensity
env.glow_strength = _optional_float(params, "glow_strength", 1.0) if params.has("glow_strength") else env.glow_strength
env.glow_bloom = _optional_float(params, "glow_bloom", 0.0) if params.has("glow_bloom") else env.glow_bloom
# SSAO
if params.has("ssao_enabled"):
env.ssao_enabled = optional_bool(params, "ssao_enabled", false)
if env.ssao_enabled:
env.ssao_radius = _optional_float(params, "ssao_radius", 1.0) if params.has("ssao_radius") else env.ssao_radius
env.ssao_intensity = _optional_float(params, "ssao_intensity", 2.0) if params.has("ssao_intensity") else env.ssao_intensity
# SSR
if params.has("ssr_enabled"):
env.ssr_enabled = optional_bool(params, "ssr_enabled", false)
if env.ssr_enabled:
env.ssr_max_steps = optional_int(params, "ssr_max_steps", 64) if params.has("ssr_max_steps") else env.ssr_max_steps
env.ssr_fade_in = _optional_float(params, "ssr_fade_in", 0.15) if params.has("ssr_fade_in") else env.ssr_fade_in
env.ssr_fade_out = _optional_float(params, "ssr_fade_out", 2.0) if params.has("ssr_fade_out") else env.ssr_fade_out
# SDFGI
if params.has("sdfgi_enabled"):
env.sdfgi_enabled = optional_bool(params, "sdfgi_enabled", false)
world_env.environment = env
if not is_existing:
_add_child_with_undo(world_env, parent, root, "MCP: Add WorldEnvironment")
var features: Array = []
if env.fog_enabled: features.append("fog")
if env.glow_enabled: features.append("glow")
if env.ssao_enabled: features.append("ssao")
if env.ssr_enabled: features.append("ssr")
if env.sdfgi_enabled: features.append("sdfgi")
return success({
"node_path": str(root.get_path_to(world_env)),
"name": str(world_env.name),
"background_mode": bg_mode,
"features": features,
"is_existing": is_existing,
})
## ─── 5. setup_camera_3d ──────────────────────────────────────────────────
func _setup_camera_3d(params: Dictionary) -> Dictionary:
var root := get_edited_root()
if root == null:
return error_no_scene()
var parent_path: String = optional_string(params, "parent_path", ".")
var parent := find_node_by_path(parent_path)
if parent == null:
return error_not_found("Parent node '%s'" % parent_path)
# Check if we're configuring an existing camera
var node_path: String = optional_string(params, "node_path", "")
var camera: Camera3D = null
var is_existing := false
if not node_path.is_empty():
var existing := find_node_by_path(node_path)
if existing != null and existing is Camera3D:
camera = existing as Camera3D
is_existing = true
elif existing != null:
return error_invalid_params("Node '%s' is not a Camera3D (is %s)" % [node_path, existing.get_class()])
if camera == null:
camera = Camera3D.new()
camera.name = optional_string(params, "name", "Camera3D")
# Projection
var projection_str: String = optional_string(params, "projection", "")
if not projection_str.is_empty():
match projection_str.to_lower():
"perspective", "0":
camera.projection = Camera3D.PROJECTION_PERSPECTIVE
"orthogonal", "orthographic", "1":
camera.projection = Camera3D.PROJECTION_ORTHOGONAL
"frustum", "2":
camera.projection = Camera3D.PROJECTION_FRUSTUM
# Properties
if params.has("fov"):
camera.fov = _optional_float(params, "fov", 75.0)
if params.has("size"):
camera.size = _optional_float(params, "size", 1.0)
if params.has("near"):
camera.near = _optional_float(params, "near", 0.05)
if params.has("far"):
camera.far = _optional_float(params, "far", 4000.0)
if params.has("cull_mask"):
camera.cull_mask = optional_int(params, "cull_mask", 1048575)
# Make current
camera.current = optional_bool(params, "current", false)
# Transform
camera.position = _parse_vector3_param(params, "position", camera.position if is_existing else Vector3(0, 1, 3))
if params.has("rotation"):
camera.rotation_degrees = _parse_vector3_param(params, "rotation", camera.rotation_degrees)
if params.has("look_at"):
var target := _parse_vector3_param(params, "look_at", Vector3.ZERO)
# We need to set position first, then use look_at
camera.look_at(target)
# Environment override
if params.has("environment_path"):
var env_path: String = params["environment_path"]
if ResourceLoader.exists(env_path):
var env_res: Resource = load(env_path)
if env_res is Environment:
camera.environment = env_res as Environment
if not is_existing:
_add_child_with_undo(camera, parent, root, "MCP: Add Camera3D")
return success({
"node_path": str(root.get_path_to(camera)),
"name": str(camera.name),
"projection": "perspective" if camera.projection == Camera3D.PROJECTION_PERSPECTIVE else "orthogonal",
"fov": camera.fov,
"position": str(camera.position),
"is_existing": is_existing,
})
## ─── 6. add_gridmap ──────────────────────────────────────────────────────
func _add_gridmap(params: Dictionary) -> Dictionary:
var root := get_edited_root()
if root == null:
return error_no_scene()
var parent_path: String = optional_string(params, "parent_path", ".")
var parent := find_node_by_path(parent_path)
if parent == null:
return error_not_found("Parent node '%s'" % parent_path)
var node_name: String = optional_string(params, "name", "GridMap")
# Check for existing GridMap to configure
var node_path: String = optional_string(params, "node_path", "")
var gridmap: GridMap = null
var is_existing := false
if not node_path.is_empty():
var existing := find_node_by_path(node_path)
if existing != null and existing is GridMap:
gridmap = existing as GridMap
is_existing = true
elif existing != null:
return error_invalid_params("Node '%s' is not a GridMap (is %s)" % [node_path, existing.get_class()])
if gridmap == null:
gridmap = GridMap.new()
gridmap.name = node_name
# Mesh library
if params.has("mesh_library_path"):
var lib_path: String = params["mesh_library_path"]
if not ResourceLoader.exists(lib_path):
if not is_existing:
gridmap.queue_free()
return error_not_found("MeshLibrary '%s'" % lib_path, "Provide a valid res:// path to a .meshlib or .tres file")
var lib: Resource = load(lib_path)
if lib is MeshLibrary:
gridmap.mesh_library = lib as MeshLibrary
else:
if not is_existing:
gridmap.queue_free()
return error_invalid_params("'%s' is not a MeshLibrary" % lib_path)
# Cell size
if params.has("cell_size"):
gridmap.cell_size = _parse_vector3_param(params, "cell_size", Vector3(2, 2, 2))
# Position
gridmap.position = _parse_vector3_param(params, "position", gridmap.position if is_existing else Vector3.ZERO)
if not is_existing:
_add_child_with_undo(gridmap, parent, root, "MCP: Add GridMap")
# Set cells
var cells: Array = params.get("cells", [])
var cells_set: int = 0
for cell in cells:
if cell is Dictionary:
var x: int = int(cell.get("x", 0))
var y: int = int(cell.get("y", 0))
var z: int = int(cell.get("z", 0))
var item: int = int(cell.get("item", 0))
var orientation: int = int(cell.get("orientation", 0))
gridmap.set_cell_item(Vector3i(x, y, z), item, orientation)
cells_set += 1
return success({
"node_path": str(root.get_path_to(gridmap)),
"name": str(gridmap.name),
"cells_set": cells_set,
"is_existing": is_existing,
"has_mesh_library": gridmap.mesh_library != null,
})

View File

@@ -0,0 +1,331 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
const NodeUtils := preload("res://addons/godot_mcp/utils/node_utils.gd")
const PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd")
func get_commands() -> Dictionary:
return {
"get_scene_tree": _get_scene_tree,
"get_scene_file_content": _get_scene_file_content,
"create_scene": _create_scene,
"open_scene": _open_scene,
"delete_scene": _delete_scene,
"add_scene_instance": _add_scene_instance,
"play_scene": _play_scene,
"stop_scene": _stop_scene,
"save_scene": _save_scene,
"get_scene_exports": _get_scene_exports,
}
func _get_scene_tree(params: Dictionary) -> Dictionary:
var root := get_edited_root()
if root == null:
return error_no_scene()
var max_depth: int = optional_int(params, "max_depth", -1)
var tree := NodeUtils.get_node_tree(root, max_depth)
return success({"scene_path": root.scene_file_path, "tree": tree})
func _get_scene_file_content(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
if not FileAccess.file_exists(path):
return error_not_found("Scene file '%s'" % path)
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return error_internal("Cannot read file: %s" % error_string(FileAccess.get_open_error()))
var content := file.get_as_text()
file.close()
return success({"path": path, "content": content, "size": content.length()})
func _create_scene(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
var guard := guard_offline_scene_save(path)
if not guard.is_empty():
return guard
var root_type: String = optional_string(params, "root_type", "Node2D")
var root_name: String = optional_string(params, "root_name", "")
# Validate root type exists
if not ClassDB.class_exists(root_type):
return error_invalid_params("Unknown node type: %s" % root_type)
# Create the scene
var root: Node = ClassDB.instantiate(root_type)
if root_name.is_empty():
root_name = path.get_file().get_basename()
root.name = root_name
var scene := PackedScene.new()
var err := scene.pack(root)
root.queue_free()
if err != OK:
return error_internal("Failed to pack scene: %s" % error_string(err))
# Ensure directory exists
var dir_path := path.get_base_dir()
if not DirAccess.dir_exists_absolute(dir_path):
DirAccess.make_dir_recursive_absolute(dir_path)
err = ResourceSaver.save(scene, path)
if err != OK:
return error_internal("Failed to save scene: %s" % error_string(err))
# Refresh filesystem
EditorInterface.get_resource_filesystem().scan()
return success({"path": path, "root_type": root_type, "root_name": root_name})
func _open_scene(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
if not FileAccess.file_exists(path):
return error_not_found("Scene file '%s'" % path)
EditorInterface.open_scene_from_path(path)
return success({"path": path, "opened": true})
func _delete_scene(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
if not FileAccess.file_exists(path):
return error_not_found("Scene file '%s'" % path)
var err := DirAccess.remove_absolute(path)
if err != OK:
return error_internal("Failed to delete scene: %s" % error_string(err))
# Also remove .import file if exists
var import_path := path + ".import"
if FileAccess.file_exists(import_path):
DirAccess.remove_absolute(import_path)
EditorInterface.get_resource_filesystem().scan()
return success({"path": path, "deleted": true})
func _add_scene_instance(params: Dictionary) -> Dictionary:
var result := require_string(params, "scene_path")
if result[1] != null:
return result[1]
var scene_path: String = result[0]
var parent_path: String = optional_string(params, "parent_path", ".")
var instance_name: String = optional_string(params, "name", "")
var root := get_edited_root()
if root == null:
return error_no_scene()
if not FileAccess.file_exists(scene_path):
return error_not_found("Scene file '%s'" % scene_path)
var parent := find_node_by_path(parent_path)
if parent == null:
return error_not_found("Parent node '%s'" % parent_path, "Use get_scene_tree to see available nodes")
var packed: PackedScene = load(scene_path)
if packed == null:
return error_internal("Failed to load scene: %s" % scene_path)
var instance := packed.instantiate()
if not instance_name.is_empty():
instance.name = instance_name
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Add scene instance")
undo_redo.add_do_method(parent, "add_child", instance)
undo_redo.add_do_method(instance, "set_owner", root)
undo_redo.add_do_reference(instance)
undo_redo.add_undo_method(parent, "remove_child", instance)
undo_redo.commit_action()
NodeUtils.set_owner_recursive(instance, root)
return success({
"node_path": str(root.get_path_to(instance)),
"scene_path": scene_path,
"name": instance.name,
})
func _play_scene(params: Dictionary) -> Dictionary:
var mode: String = optional_string(params, "mode", "main") # "main", "current", or path
match mode:
"main":
EditorInterface.play_main_scene()
"current":
EditorInterface.play_current_scene()
_:
# Treat as scene path
if not FileAccess.file_exists(mode):
return error_not_found("Scene file '%s'" % mode)
EditorInterface.play_custom_scene(mode)
return success({"playing": true, "mode": mode})
func _stop_scene(_params: Dictionary) -> Dictionary:
if not EditorInterface.is_playing_scene():
return success({"stopped": false, "message": "No scene is currently playing"})
EditorInterface.stop_playing_scene()
# Clean up temp files
_cleanup_screenshot_files()
_cleanup_input_files()
_cleanup_inspector_files()
return success({"stopped": true})
func _save_scene(params: Dictionary) -> Dictionary:
var root := get_edited_root()
if root == null:
return error_no_scene()
var path: String = optional_string(params, "path", "")
if path.is_empty():
path = root.scene_file_path
if path.is_empty():
return error_invalid_params("No save path specified and scene has no existing path")
var normalized_path := normalize_project_path(path)
if is_scene_path_open(normalized_path) and not is_active_scene_path(normalized_path):
return error_conflict(
"Refusing to save inactive open scene '%s' from the active editor scene" % normalized_path,
{
"path": normalized_path,
"active_scene": normalize_project_path(root.scene_file_path),
"open_scenes": get_open_scene_paths(),
"suggestion": "Open the target scene tab before saving it, or close it before offline edits.",
}
)
var dir_path := normalized_path.get_base_dir()
if not DirAccess.dir_exists_absolute(dir_path):
DirAccess.make_dir_recursive_absolute(dir_path)
var err: int
var save_method: String
if root.scene_file_path.is_empty() or normalize_project_path(root.scene_file_path) != normalized_path:
EditorInterface.save_scene_as(normalized_path)
err = OK
save_method = "EditorInterface.save_scene_as"
else:
err = EditorInterface.save_scene()
save_method = "EditorInterface.save_scene"
if err != OK:
return error_internal("Failed to save scene via %s: %s" % [save_method, error_string(err)])
return success({"path": normalized_path, "saved": true, "method": save_method})
func _get_scene_exports(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
if not FileAccess.file_exists(path):
return error_not_found("Scene file '%s'" % path)
var packed: PackedScene = load(path)
if packed == null:
return error_internal("Failed to load scene: %s" % path)
var instance: Node = packed.instantiate()
if instance == null:
return error_internal("Failed to instantiate scene: %s" % path)
var nodes_data: Array = []
_collect_exports_recursive(instance, instance, nodes_data)
instance.queue_free()
return success({
"path": path,
"nodes": nodes_data,
"count": nodes_data.size(),
})
func _collect_exports_recursive(node: Node, root: Node, nodes_data: Array) -> void:
var script: Script = node.get_script()
if script != null:
var exports: Dictionary = {}
for prop_info in script.get_script_property_list():
var usage: int = prop_info["usage"]
if (usage & PROPERTY_USAGE_EDITOR) and (usage & PROPERTY_USAGE_SCRIPT_VARIABLE):
var prop_name: String = prop_info["name"]
exports[prop_name] = {
"value": PropertyParser.serialize_value(node.get(prop_name)),
"type": prop_info["type"],
"hint": prop_info.get("hint", 0),
"hint_string": prop_info.get("hint_string", ""),
}
if not exports.is_empty():
var node_path := "." if node == root else str(root.get_path_to(node))
nodes_data.append({
"node_path": node_path,
"node_name": node.name,
"node_type": node.get_class(),
"script_path": script.resource_path,
"exports": exports,
})
for child in node.get_children():
_collect_exports_recursive(child, root, nodes_data)
func _cleanup_screenshot_files() -> void:
var user_dir := get_game_user_dir()
var request_path := user_dir + "/mcp_screenshot_request"
var screenshot_path := user_dir + "/mcp_screenshot.png"
if FileAccess.file_exists(request_path):
DirAccess.remove_absolute(request_path)
if FileAccess.file_exists(screenshot_path):
DirAccess.remove_absolute(screenshot_path)
func _cleanup_input_files() -> void:
var user_dir := get_game_user_dir()
var commands_path := user_dir + "/mcp_input_commands"
if FileAccess.file_exists(commands_path):
DirAccess.remove_absolute(commands_path)
func _cleanup_inspector_files() -> void:
var user_dir := get_game_user_dir()
var request_path := user_dir + "/mcp_game_request"
var response_path := user_dir + "/mcp_game_response"
if FileAccess.file_exists(request_path):
DirAccess.remove_absolute(request_path)
if FileAccess.file_exists(response_path):
DirAccess.remove_absolute(response_path)

View File

@@ -0,0 +1,369 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
func get_commands() -> Dictionary:
return {
"list_scripts": _list_scripts,
"read_script": _read_script,
"create_script": _create_script,
"edit_script": _edit_script,
"attach_script": _attach_script,
"get_open_scripts": _get_open_scripts,
"validate_script": _validate_script,
}
func _guard_script_file_path(path: String, operation: String) -> Dictionary:
var ext := path.get_extension().to_lower()
if ext in ["gd", "cs"]:
return {}
return error(
-32602,
"%s only supports script files (.gd, .cs): %s" % [operation, normalize_project_path(path)],
{
"path": normalize_project_path(path),
"extension": ext,
"suggestion": "Use scene commands for .tscn/.scn files and shader commands for shader resources.",
}
)
func _list_scripts(params: Dictionary) -> Dictionary:
var path: String = optional_string(params, "path", "res://")
var recursive: bool = optional_bool(params, "recursive", true)
var scripts: Array = []
_find_scripts(path, recursive, scripts)
return success({"scripts": scripts, "count": scripts.size()})
func _find_scripts(path: String, recursive: bool, scripts: Array) -> void:
var dir := DirAccess.open(path)
if dir == null:
return
dir.list_dir_begin()
var file_name := dir.get_next()
while not file_name.is_empty():
if file_name.begins_with("."):
file_name = dir.get_next()
continue
var full_path := path.path_join(file_name)
if dir.current_is_dir():
if recursive:
_find_scripts(full_path, recursive, scripts)
elif file_name.get_extension() in ["gd", "cs", "gdshader"]:
var info := {"path": full_path, "type": file_name.get_extension()}
# Get basic file info
var file := FileAccess.open(full_path, FileAccess.READ)
if file:
info["size"] = file.get_length()
# Read first line for class/extends info
var first_line := file.get_line().strip_edges()
if first_line.begins_with("class_name "):
info["class_name"] = first_line.substr(11).strip_edges()
elif first_line.begins_with("extends "):
info["extends"] = first_line.substr(8).strip_edges()
file.close()
scripts.append(info)
file_name = dir.get_next()
dir.list_dir_end()
func _read_script(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
if not FileAccess.file_exists(path):
return error_not_found("Script '%s'" % path)
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return error_internal("Cannot read script: %s" % error_string(FileAccess.get_open_error()))
var content := file.get_as_text()
var line_count := content.count("\n") + 1
file.close()
return success({
"path": path,
"content": content,
"line_count": line_count,
"size": content.length(),
})
func _create_script(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
var path_guard := _guard_script_file_path(path, "create_script")
if not path_guard.is_empty():
return path_guard
var content: String = optional_string(params, "content", "")
var base_class: String = optional_string(params, "extends", "Node")
var class_name_str: String = optional_string(params, "class_name", "")
var force: bool = optional_bool(params, "force", false)
var guard := guard_text_resource_write(path, force)
if not guard.is_empty():
return guard
# Generate template if no content provided
if content.is_empty():
var lines: PackedStringArray = []
if not class_name_str.is_empty():
lines.append("class_name %s" % class_name_str)
lines.append("extends %s" % base_class)
lines.append("")
lines.append("")
lines.append("func _ready() -> void:")
lines.append("\tpass")
lines.append("")
content = "\n".join(lines)
# Ensure directory exists
var dir_path := path.get_base_dir()
if not DirAccess.dir_exists_absolute(dir_path):
DirAccess.make_dir_recursive_absolute(dir_path)
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
return error_internal("Cannot create script: %s" % error_string(FileAccess.get_open_error()))
file.store_string(content)
file.close()
EditorInterface.get_resource_filesystem().scan()
# Pre-load so the script is available immediately
if ResourceLoader.exists(path):
var script = load(path)
if script is Script:
script.reload(true)
return success({"path": path, "created": true})
func _edit_script(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
var path_guard := _guard_script_file_path(path, "edit_script")
if not path_guard.is_empty():
return path_guard
if not FileAccess.file_exists(path):
return error_not_found("Script '%s'" % path)
var force: bool = optional_bool(params, "force", false)
var guard := guard_text_resource_write(path, force)
if not guard.is_empty():
return guard
# Read current content
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return error_internal("Cannot read script: %s" % error_string(FileAccess.get_open_error()))
var content := file.get_as_text()
file.close()
var changes_made := 0
# Support search-and-replace
if params.has("replacements") and params["replacements"] is Array:
var replacements: Array = params["replacements"]
for replacement in replacements:
if replacement is Dictionary:
var search: String = replacement.get("search", "")
var replace: String = replacement.get("replace", "")
if not search.is_empty():
var use_regex: bool = replacement.get("regex", false)
if use_regex:
var regex := RegEx.new()
var err := regex.compile(search)
if err == OK:
var new_content := regex.sub(content, replace, true)
if new_content != content:
content = new_content
changes_made += 1
else:
if content.contains(search):
content = content.replace(search, replace)
changes_made += 1
# Support 1-based inclusive line range replacement
elif params.has("content") and (params.has("start_line") or params.has("end_line")):
if not params.has("start_line"):
return error_invalid_params("start_line is required when end_line is provided")
var start_line: int = int(params["start_line"])
var end_line: int = int(params.get("end_line", start_line))
var lines := content.split("\n")
if start_line < 1:
return error_invalid_params("start_line must be >= 1")
if end_line < start_line:
return error_invalid_params("end_line must be >= start_line")
if start_line > lines.size():
return error_invalid_params("start_line is beyond the end of the file")
if end_line > lines.size():
return error_invalid_params("end_line is beyond the end of the file")
var replacement_lines := str(params["content"]).split("\n")
var start_index := start_line - 1
var remove_count := end_line - start_line + 1
for _i in range(remove_count):
lines.remove_at(start_index)
for i in range(replacement_lines.size()):
lines.insert(start_index + i, replacement_lines[i])
content = "\n".join(lines)
changes_made = 1
# Support full content replacement
elif params.has("content"):
content = str(params["content"])
changes_made = 1
# Support insert at line
elif params.has("insert_at_line") and params.has("text"):
var line_num: int = int(params["insert_at_line"])
var text: String = str(params["text"])
var lines := content.split("\n")
line_num = clampi(line_num, 0, lines.size())
lines.insert(line_num, text)
content = "\n".join(lines)
changes_made = 1
if changes_made == 0:
return success({"path": path, "changes_made": 0, "message": "No changes applied"})
# Write back
file = FileAccess.open(path, FileAccess.WRITE)
if file == null:
return error_internal("Cannot write script: %s" % error_string(FileAccess.get_open_error()))
file.store_string(content)
file.close()
# Reload the script resource so the editor picks up changes immediately
_reload_script(path)
return success({"path": path, "changes_made": changes_made})
## Force-reload a script so the editor reflects disk changes immediately.
func _reload_script(path: String) -> void:
# First, trigger a filesystem scan so Godot knows the file changed
EditorInterface.get_resource_filesystem().scan()
# If the script is already loaded in memory, reload it
if ResourceLoader.exists(path):
var script = load(path)
if script is Script:
script.reload(true)
# If the script is open in the script editor, the reload above updates it.
# But we also need to notify the editor to refresh its error indicators.
EditorInterface.get_script_editor().notification(Control.NOTIFICATION_VISIBILITY_CHANGED)
func _attach_script(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "script_path")
if result2[1] != null:
return result2[1]
var script_path: String = result2[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
if not FileAccess.file_exists(script_path):
return error_not_found("Script '%s'" % script_path)
var script: Script = load(script_path)
if script == null:
return error_internal("Failed to load script: %s" % script_path)
var old_script: Variant = node.get_script()
var undo_redo := get_undo_redo()
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 success({
"node_path": str(root.get_path_to(node)),
"script_path": script_path,
"attached": true,
})
func _validate_script(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
var path_guard := _guard_script_file_path(path, "validate_script")
if not path_guard.is_empty():
return path_guard
if not FileAccess.file_exists(path):
return error_not_found("Script '%s'" % path)
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return error_internal("Cannot read script: %s" % error_string(FileAccess.get_open_error()))
var source_code := file.get_as_text()
file.close()
var script := GDScript.new()
script.source_code = source_code
var err := script.reload()
if err == OK:
return success({"path": path, "valid": true, "message": "Script compiles successfully"})
return success({
"path": path,
"valid": false,
"error_code": err,
"error_string": error_string(err),
"message": "Compilation failed. Use get_output_log or get_editor_errors for details.",
})
func _get_open_scripts(_params: Dictionary) -> Dictionary:
var script_editor := EditorInterface.get_script_editor()
var open_scripts: Array = []
for script_base in script_editor.get_open_scripts():
var info := {
"path": script_base.resource_path,
"type": script_base.get_class(),
}
open_scripts.append(info)
return success({"scripts": open_scripts, "count": open_scripts.size()})

View File

@@ -0,0 +1,239 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
func get_commands() -> Dictionary:
return {
"create_shader": _create_shader,
"read_shader": _read_shader,
"edit_shader": _edit_shader,
"assign_shader_material": _assign_shader_material,
"set_shader_param": _set_shader_param,
"get_shader_params": _get_shader_params,
}
func _create_shader(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
var content: String = optional_string(params, "content", "")
var shader_type: String = optional_string(params, "shader_type", "spatial")
var force: bool = optional_bool(params, "force", false)
var guard := guard_text_resource_write(path, force)
if not guard.is_empty():
return guard
if content.is_empty():
match shader_type:
"spatial":
content = "shader_type spatial;\n\nvoid vertex() {\n\t// Called for every vertex\n}\n\nvoid fragment() {\n\t// Called for every pixel\n\tALBEDO = vec3(1.0);\n}\n"
"canvas_item":
content = "shader_type canvas_item;\n\nvoid vertex() {\n\t// Called for every vertex\n}\n\nvoid fragment() {\n\t// Called for every pixel\n\tCOLOR = vec4(1.0);\n}\n"
"particles":
content = "shader_type particles;\n\nvoid start() {\n\t// Called when particle spawns\n}\n\nvoid process() {\n\t// Called every frame per particle\n}\n"
"sky":
content = "shader_type sky;\n\nvoid sky() {\n\tCOLOR = vec3(0.3, 0.5, 0.8);\n}\n"
# Ensure directory exists
var dir_path := path.get_base_dir()
if not DirAccess.dir_exists_absolute(dir_path):
DirAccess.make_dir_recursive_absolute(dir_path)
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
return error_internal("Cannot create shader: %s" % error_string(FileAccess.get_open_error()))
file.store_string(content)
file.close()
_refresh_loaded_shader(path, content)
return success({"path": path, "shader_type": shader_type, "created": true})
func _read_shader(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
if not FileAccess.file_exists(path):
return error_not_found("Shader '%s'" % path)
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return error_internal("Cannot read shader: %s" % error_string(FileAccess.get_open_error()))
var content := file.get_as_text()
file.close()
return success({"path": path, "content": content, "size": content.length()})
func _refresh_loaded_shader(path: String, content: String) -> void:
var normalized := normalize_project_path(path)
if normalized.is_empty():
return
if ResourceLoader.has_cached(normalized):
var shader := Shader.new()
shader.code = content
shader.take_over_path(normalized)
shader.emit_changed()
EditorInterface.get_resource_filesystem().update_file(normalized)
func _edit_shader(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
if not FileAccess.file_exists(path):
return error_not_found("Shader '%s'" % path)
var force: bool = optional_bool(params, "force", false)
var guard := guard_text_resource_write(path, force)
if not guard.is_empty():
return guard
var changes_made := 0
var content := ""
if params.has("content"):
content = str(params["content"])
changes_made = 1
elif params.has("replacements") and params["replacements"] is Array:
# Read current
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return error_internal("Cannot read shader")
content = file.get_as_text()
file.close()
for replacement in params["replacements"]:
if replacement is Dictionary:
var search: String = replacement.get("search", "")
var replace: String = replacement.get("replace", "")
if not search.is_empty() and content.contains(search):
content = content.replace(search, replace)
changes_made += 1
if changes_made > 0:
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
return error_internal("Cannot write shader: %s" % error_string(FileAccess.get_open_error()))
file.store_string(content)
file.close()
_refresh_loaded_shader(path, content)
return success({"path": path, "changes_made": changes_made})
func _assign_shader_material(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "shader_path")
if result2[1] != null:
return result2[1]
var shader_path: String = result2[0]
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node at '%s'" % node_path)
if not ResourceLoader.exists(shader_path):
return error_not_found("Shader '%s'" % shader_path)
var shader: Shader = load(shader_path)
if shader == null:
return error_internal("Failed to load shader")
var material := ShaderMaterial.new()
material.shader = shader
if node is CanvasItem:
set_property_with_undo(node, "material", material, "MCP: Assign shader material")
elif node is MeshInstance3D:
set_property_with_undo(node, "material_override", material, "MCP: Assign shader material")
else:
# Try generic material property
if "material" in node:
set_property_with_undo(node, "material", material, "MCP: Assign shader material")
else:
return error_invalid_params("Node '%s' (%s) does not support materials" % [node_path, node.get_class()])
return success({"node_path": node_path, "shader_path": shader_path, "assigned": true})
func _set_shader_param(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "param")
if result2[1] != null:
return result2[1]
var param_name: String = result2[0]
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node at '%s'" % node_path)
var material: ShaderMaterial = null
if node is CanvasItem and (node as CanvasItem).material is ShaderMaterial:
material = (node as CanvasItem).material
elif node is MeshInstance3D and (node as MeshInstance3D).material_override is ShaderMaterial:
material = (node as MeshInstance3D).material_override
if material == null:
return error(-32000, "Node has no ShaderMaterial")
var value = params.get("value")
if value is String:
var s: String = value
var expr := Expression.new()
if expr.parse(s) == OK:
var parsed = expr.execute()
if parsed != null:
value = parsed
material.set_shader_parameter(param_name, value)
return success({"node_path": node_path, "param": param_name, "value": str(value)})
func _get_shader_params(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node at '%s'" % node_path)
var material: ShaderMaterial = null
if node is CanvasItem and (node as CanvasItem).material is ShaderMaterial:
material = (node as CanvasItem).material
elif node is MeshInstance3D and (node as MeshInstance3D).material_override is ShaderMaterial:
material = (node as MeshInstance3D).material_override
if material == null:
return error(-32000, "Node has no ShaderMaterial")
var shader_params: Dictionary = {}
for prop in material.get_property_list():
var pname: String = prop["name"]
if pname.begins_with("shader_parameter/"):
var key := pname.substr(17)
shader_params[key] = str(material.get(pname))
return success({"node_path": node_path, "params": shader_params})

View File

@@ -0,0 +1,580 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
## Test automation framework tools.
## Editor-side orchestration + runtime assertions via file-based IPC.
func get_commands() -> Dictionary:
return {
"run_test_scenario": _run_test_scenario,
"assert_node_state": _assert_node_state,
"assert_screen_text": _assert_screen_text,
"run_stress_test": _run_stress_test,
"get_test_report": _get_test_report,
}
# ── Internal test result accumulator ──────────────────────────────────────────
var _test_results: Array[Dictionary] = []
# ── Commands ──────────────────────────────────────────────────────────────────
func _run_test_scenario(params: Dictionary) -> Dictionary:
## Execute a test scenario: optionally play a scene, run a sequence of steps
## (input simulation, waits, assertions, screenshots), return pass/fail results.
##
## Steps array: [{type: "input"|"wait"|"assert"|"screenshot", ...params}]
## - input: {type:"input", action:str, pressed:bool} or {type:"input", keycode:str}
## - wait: {type:"wait", seconds:float} or {type:"wait", node_path:str, timeout:float}
## - assert: {type:"assert", node_path:str, property:str, expected:val, operator:str}
## - screenshot: {type:"screenshot"} — captures a frame for visual inspection
if not params.has("steps") or not params["steps"] is Array:
return error_invalid_params("Missing required parameter: steps (Array)")
var steps: Array = params["steps"]
if steps.is_empty():
return error_invalid_params("Steps array is empty")
var scene_path: String = optional_string(params, "scene_path")
var ei := get_editor()
# Play scene if requested
if not scene_path.is_empty():
if ei.is_playing_scene():
ei.stop_playing_scene()
await get_tree().create_timer(0.5).timeout
if scene_path == "main":
ei.play_main_scene()
elif scene_path == "current":
ei.play_current_scene()
else:
if not FileAccess.file_exists(scene_path):
return error_not_found("Scene file '%s'" % scene_path)
ei.play_custom_scene(scene_path)
# Wait for game to start
await get_tree().create_timer(1.0).timeout
# Verify game is running
if not ei.is_playing_scene():
return error(-32000, "No scene is currently playing", {
"suggestion": "Provide scene_path or use play_scene first"
})
var results: Array[Dictionary] = []
var pass_count: int = 0
var fail_count: int = 0
var error_count: int = 0
for i in steps.size():
var step: Dictionary = steps[i]
if not step.has("type"):
results.append({"step": i, "error": "Missing 'type' field"})
error_count += 1
continue
var step_type: String = str(step["type"])
var step_result: Dictionary = {"step": i, "type": step_type}
match step_type:
"input":
var input_result := await _execute_input_step(step)
step_result.merge(input_result)
"wait":
var wait_result := await _execute_wait_step(step)
step_result.merge(wait_result)
"assert":
var assert_result := await _execute_assert_step(step)
step_result.merge(assert_result)
if assert_result.get("passed", false):
pass_count += 1
else:
fail_count += 1
"screenshot":
var screenshot_result := await _send_game_command("capture_frames", {
"count": 1,
"frame_interval": 1,
"half_resolution": optional_bool(step, "half_resolution", true),
}, 5.0)
if screenshot_result.has("result"):
step_result["captured"] = true
else:
step_result["captured"] = false
step_result["error"] = "Screenshot capture failed"
error_count += 1
_:
step_result["error"] = "Unknown step type: %s" % step_type
error_count += 1
results.append(step_result)
# Check if game crashed between steps
if not ei.is_playing_scene():
results.append({"step": i + 1, "error": "Game stopped unexpectedly"})
error_count += 1
break
var summary := {
"total_steps": steps.size(),
"completed_steps": results.size(),
"assertions_passed": pass_count,
"assertions_failed": fail_count,
"errors": error_count,
"all_passed": fail_count == 0 and error_count == 0,
"results": results,
}
# Store results for get_test_report
_test_results.append_array(results)
return success(summary)
func _assert_node_state(params: Dictionary) -> Dictionary:
## Assert a node's property equals expected value in the running game.
## Supports operators: eq, neq, gt, lt, gte, lte, contains, type_is.
## Returns pass/fail with actual value.
var path_result := require_string(params, "node_path")
if path_result[1] != null:
return path_result[1]
var prop_result := require_string(params, "property")
if prop_result[1] != null:
return prop_result[1]
if not params.has("expected"):
return error_invalid_params("Missing required parameter: expected")
var operator: String = optional_string(params, "operator", "eq")
var valid_operators := ["eq", "neq", "gt", "lt", "gte", "lte", "contains", "type_is"]
if operator not in valid_operators:
return error_invalid_params("Invalid operator '%s'. Valid: %s" % [operator, str(valid_operators)])
var result := await _send_game_command("assert_node_state", {
"node_path": path_result[0],
"property": prop_result[0],
"expected": params["expected"],
"operator": operator,
}, 5.0)
# Store for test report
if result.has("result"):
_test_results.append(result["result"])
return result
func _assert_screen_text(params: Dictionary) -> Dictionary:
## Assert that specific text is visible on screen.
## Uses find_ui_elements internally to check all visible UI text.
var text_result := require_string(params, "text")
if text_result[1] != null:
return text_result[1]
var expected_text: String = text_result[0]
var partial: bool = optional_bool(params, "partial", true)
var case_sensitive: bool = optional_bool(params, "case_sensitive", true)
# Use find_ui_elements to get all visible UI text
var ui_result := await _send_game_command("find_ui_elements", {})
if ui_result.has("error"):
return ui_result
var elements: Array = []
if ui_result.has("result") and ui_result["result"].has("elements"):
elements = ui_result["result"]["elements"]
var found := false
var matched_element: Dictionary = {}
var all_texts: Array[String] = []
for element: Dictionary in elements:
var element_text: String = str(element.get("text", ""))
if element_text.is_empty():
continue
all_texts.append(element_text)
var search_text := expected_text
var compare_text := element_text
if not case_sensitive:
search_text = search_text.to_lower()
compare_text = compare_text.to_lower()
if partial:
if compare_text.contains(search_text):
found = true
matched_element = element
break
else:
if compare_text == search_text:
found = true
matched_element = element
break
var assertion := {
"passed": found,
"expected_text": expected_text,
"partial": partial,
"case_sensitive": case_sensitive,
}
if found:
assertion["matched_element"] = {
"text": matched_element.get("text", ""),
"type": matched_element.get("type", ""),
"path": matched_element.get("path", ""),
}
else:
assertion["visible_texts"] = all_texts
# Store for test report
_test_results.append(assertion)
return success(assertion)
func _run_stress_test(params: Dictionary) -> Dictionary:
## Run rapid random inputs for N seconds and check for crashes.
## Returns frame count, timing, and any errors from game output.
var duration: float = float(params.get("duration", 5.0))
if duration <= 0 or duration > 60:
return error_invalid_params("Duration must be between 0 and 60 seconds")
var ei := get_editor()
if not ei.is_playing_scene():
return error(-32000, "No scene is currently playing", {
"suggestion": "Use play_scene first"
})
# Record initial error count from log
var initial_errors := _count_log_errors()
# Generate random input events
var actions := ["ui_up", "ui_down", "ui_left", "ui_right", "ui_accept", "ui_cancel"]
# Add common game actions if specified
var custom_actions: Array = params.get("actions", [])
for action in custom_actions:
actions.append(str(action))
var events_sent: int = 0
var start_time := Time.get_ticks_msec()
var duration_ms := int(duration * 1000.0)
while Time.get_ticks_msec() - start_time < duration_ms:
if not ei.is_playing_scene():
var elapsed := (Time.get_ticks_msec() - start_time) / 1000.0
return success({
"completed": false,
"crashed": true,
"elapsed_seconds": elapsed,
"events_sent": events_sent,
"error": "Game stopped during stress test",
})
# Send a batch of random inputs
var batch: Array = []
for j in 3:
var action_name: String = actions[randi() % actions.size()]
batch.append({
"type": "action",
"action": action_name,
"pressed": true,
"strength": 1.0,
})
batch.append({
"type": "action",
"action": action_name,
"pressed": false,
"strength": 0.0,
})
# Write input commands directly (same as input_commands)
var json := JSON.stringify({
"sequence_events": batch,
"frame_delay": 1,
})
var file := FileAccess.open("user://mcp_input_commands", FileAccess.WRITE)
if file:
file.store_string(json)
file.close()
events_sent += batch.size()
await get_tree().create_timer(0.1).timeout
var elapsed := (Time.get_ticks_msec() - start_time) / 1000.0
var final_errors := _count_log_errors()
var new_errors := final_errors - initial_errors
# Check if game is still running
var still_running := ei.is_playing_scene()
return success({
"completed": true,
"crashed": not still_running,
"duration_seconds": elapsed,
"events_sent": events_sent,
"new_errors": new_errors,
"game_still_running": still_running,
})
func _get_test_report(params: Dictionary) -> Dictionary:
## Collect and format results from accumulated assertions into a test report.
## Returns pass count, fail count, and detailed results.
var clear: bool = optional_bool(params, "clear", true)
var pass_count: int = 0
var fail_count: int = 0
var details: Array[Dictionary] = []
for result: Dictionary in _test_results:
var passed: bool = result.get("passed", false)
if passed:
pass_count += 1
else:
fail_count += 1
details.append(result)
var report := {
"total": _test_results.size(),
"passed": pass_count,
"failed": fail_count,
"pass_rate": ("%.1f%%" % (100.0 * pass_count / _test_results.size())) if not _test_results.is_empty() else "N/A",
"all_passed": fail_count == 0 and not _test_results.is_empty(),
"details": details,
}
if clear:
_test_results.clear()
return success(report)
# ── Step Executors (for run_test_scenario) ────────────────────────────────────
func _execute_input_step(step: Dictionary) -> Dictionary:
## Execute an input step: simulate action or key press.
var events: Array = []
if step.has("action"):
var pressed: bool = step.get("pressed", true) as bool
events.append({
"type": "action",
"action": str(step["action"]),
"pressed": pressed,
"strength": float(step.get("strength", 1.0)),
})
# Auto-release if pressed
if pressed and step.get("auto_release", true):
events.append({
"type": "action",
"action": str(step["action"]),
"pressed": false,
"strength": 0.0,
})
elif step.has("keycode"):
var pressed: bool = step.get("pressed", true) as bool
events.append({
"type": "key",
"keycode": str(step["keycode"]),
"pressed": pressed,
"shift": step.get("shift", false),
"ctrl": step.get("ctrl", false),
"alt": step.get("alt", false),
})
else:
return {"error": "Input step requires 'action' or 'keycode'"}
var json := JSON.stringify({
"sequence_events": events,
"frame_delay": int(step.get("frame_delay", 1)),
})
var file := FileAccess.open("user://mcp_input_commands", FileAccess.WRITE)
if file == null:
return {"error": "Failed to write input commands"}
file.store_string(json)
file.close()
return {"sent": true, "event_count": events.size()}
func _execute_wait_step(step: Dictionary) -> Dictionary:
## Execute a wait step: wait for seconds or wait for a node to appear.
if step.has("node_path"):
var timeout: float = float(step.get("timeout", 5.0))
var result := await _send_game_command("wait_for_node", {
"node_path": str(step["node_path"]),
"timeout": timeout,
"poll_frames": int(step.get("poll_frames", 5)),
}, timeout + 2.0)
if result.has("error"):
return {"error": "Wait for node failed: %s" % str(result["error"])}
return {"waited_for": str(step["node_path"]), "found": true}
else:
var seconds: float = float(step.get("seconds", 1.0))
await get_tree().create_timer(seconds).timeout
return {"waited_seconds": seconds}
func _execute_assert_step(step: Dictionary) -> Dictionary:
## Execute an assertion step within a scenario.
if step.has("text"):
# Screen text assertion
var ui_result := await _send_game_command("find_ui_elements", {})
if ui_result.has("error"):
return {"passed": false, "error": "Could not get UI elements"}
var elements: Array = []
if ui_result.has("result") and ui_result["result"].has("elements"):
elements = ui_result["result"]["elements"]
var expected_text: String = str(step["text"])
var partial: bool = step.get("partial", true) as bool
for element: Dictionary in elements:
var element_text: String = str(element.get("text", ""))
if partial and element_text.contains(expected_text):
return {"passed": true, "type": "screen_text", "expected": expected_text, "found_in": element_text}
elif not partial and element_text == expected_text:
return {"passed": true, "type": "screen_text", "expected": expected_text, "found_in": element_text}
return {"passed": false, "type": "screen_text", "expected": expected_text, "error": "Text not found on screen"}
elif step.has("node_path") and step.has("property"):
# Node state assertion
var result := await _send_game_command("assert_node_state", {
"node_path": str(step["node_path"]),
"property": str(step["property"]),
"expected": step.get("expected", null),
"operator": str(step.get("operator", "eq")),
}, 5.0)
if result.has("result"):
return result["result"]
elif result.has("error"):
return {"passed": false, "error": str(result["error"])}
return {"passed": false, "error": "Unknown assertion error"}
else:
return {"passed": false, "error": "Assert step requires 'text' or 'node_path'+'property'"}
# ── IPC Helper ────────────────────────────────────────────────────────────────
func _send_game_command(command: String, params: Dictionary = {}, timeout_sec: float = 5.0) -> Dictionary:
var ei := get_editor()
if not ei.is_playing_scene():
return error(-32000, "No scene is currently playing", {"suggestion": "Use play_scene first"})
var user_dir := get_game_user_dir()
var request_path := user_dir + "/mcp_game_request"
var response_path := user_dir + "/mcp_game_response"
# Clean stale response
if FileAccess.file_exists(response_path):
DirAccess.remove_absolute(response_path)
# Write request
var request_data := JSON.stringify({"command": command, "params": params})
var req := FileAccess.open(request_path, FileAccess.WRITE)
if req == null:
return error_internal("Could not create game request file")
req.store_string(request_data)
req.close()
# Poll for response
var attempts := int(timeout_sec / 0.1)
while attempts > 0:
await get_tree().create_timer(0.1).timeout
if FileAccess.file_exists(response_path):
break
# Check if game is still running
if not ei.is_playing_scene():
if FileAccess.file_exists(request_path):
DirAccess.remove_absolute(request_path)
return error(-32000, "Game stopped during command execution")
attempts -= 1
if not FileAccess.file_exists(response_path):
# Try to auto-resume the debugger
if ei.is_playing_scene():
_try_debugger_continue()
for _retry in 20:
await get_tree().create_timer(0.1).timeout
if FileAccess.file_exists(response_path):
break
if not FileAccess.file_exists(response_path):
if FileAccess.file_exists(request_path):
DirAccess.remove_absolute(request_path)
return error(-32000, "Game command timed out after %.1fs" % timeout_sec, {
"suggestion": "Ensure the game is running and MCPGameInspector autoload is active",
})
# Read response
var file := FileAccess.open(response_path, FileAccess.READ)
if file == null:
return error_internal("Could not read game response file")
var text := file.get_as_text()
file.close()
DirAccess.remove_absolute(response_path)
var parsed = JSON.parse_string(text)
if parsed == null or not parsed is Dictionary:
return error_internal("Invalid response JSON from game")
if parsed.has("error"):
return error(-32000, str(parsed["error"]))
return success(parsed)
## Press the debugger "Continue" button to resume a paused game process.
func _try_debugger_continue() -> void:
var base := EditorInterface.get_base_control()
if base == null:
return
var queue: Array[Node] = [base]
while not queue.is_empty():
var node := queue.pop_front()
if node.get_class() == "ScriptEditorDebugger":
var inner: Array[Node] = [node]
while not inner.is_empty():
var n := inner.pop_front()
if n is Button and n.tooltip_text == "Continue":
n.emit_signal("pressed")
push_warning("[MCP] Auto-resumed debugger after runtime error")
return
for c in n.get_children():
inner.append(c)
return
for child in node.get_children():
queue.append(child)
# ── Utility ───────────────────────────────────────────────────────────────────
func _count_log_errors() -> int:
var count: int = 0
var log_path := "user://logs/godot.log"
if FileAccess.file_exists(log_path):
var file := FileAccess.open(log_path, FileAccess.READ)
if file:
var content := file.get_as_text()
file.close()
var lines := content.split("\n")
for line: String in lines:
if line.contains("ERROR") or line.contains("SCRIPT ERROR"):
count += 1
return count

View File

@@ -0,0 +1,424 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
func get_commands() -> Dictionary:
return {
"create_theme": _create_theme,
"set_theme_color": _set_theme_color,
"set_theme_constant": _set_theme_constant,
"set_theme_font_size": _set_theme_font_size,
"set_theme_stylebox": _set_theme_stylebox,
"setup_control": _setup_control,
"get_theme_info": _get_theme_info,
}
func _create_theme(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
var theme := Theme.new()
# Optionally set default font size
var font_size: int = optional_int(params, "default_font_size", 0)
if font_size > 0:
theme.default_font_size = font_size
var scene_guard := guard_offline_scene_save(path)
if scene_guard != null:
return scene_guard
var err := ResourceSaver.save(theme, path)
if err != OK:
return error_internal("Failed to save theme: %s" % error_string(err))
EditorInterface.get_resource_filesystem().scan()
return success({"path": path, "created": true})
func _set_theme_color(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "name")
if result2[1] != null:
return result2[1]
var color_name: String = result2[0]
var result3 := require_string(params, "color")
if result3[1] != null:
return result3[1]
var color_str: String = result3[0]
var node := find_node_by_path(node_path)
if node == null or not (node is Control):
return error_not_found("Control node at '%s'" % node_path)
var control: Control = node
var color := Color(color_str)
var theme_type: String = optional_string(params, "theme_type", "")
if theme_type.is_empty():
theme_type = control.get_class()
var had_old := control.has_theme_color_override(color_name)
var old_value: Variant = control.get("theme_override_colors/" + color_name) if had_old else null
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Set theme color override")
undo_redo.add_do_method(control, "add_theme_color_override", color_name, color)
undo_redo.add_undo_method(self, "_restore_theme_override", control, "color", color_name, had_old, old_value)
undo_redo.commit_action()
return success({"node_path": node_path, "name": color_name, "color": color_str})
func _set_theme_constant(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "name")
if result2[1] != null:
return result2[1]
var const_name: String = result2[0]
var node := find_node_by_path(node_path)
if node == null or not (node is Control):
return error_not_found("Control node at '%s'" % node_path)
var control: Control = node
var value: int = int(params.get("value", 0))
var had_old := control.has_theme_constant_override(const_name)
var old_value: Variant = control.get("theme_override_constants/" + const_name) if had_old else null
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Set theme constant override")
undo_redo.add_do_method(control, "add_theme_constant_override", const_name, value)
undo_redo.add_undo_method(self, "_restore_theme_override", control, "constant", const_name, had_old, old_value)
undo_redo.commit_action()
return success({"node_path": node_path, "name": const_name, "value": value})
func _set_theme_font_size(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "name")
if result2[1] != null:
return result2[1]
var font_name: String = result2[0]
var node := find_node_by_path(node_path)
if node == null or not (node is Control):
return error_not_found("Control node at '%s'" % node_path)
var control: Control = node
var size: int = int(params.get("size", 16))
var had_old := control.has_theme_font_size_override(font_name)
var old_value: Variant = control.get("theme_override_font_sizes/" + font_name) if had_old else null
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Set theme font size override")
undo_redo.add_do_method(control, "add_theme_font_size_override", font_name, size)
undo_redo.add_undo_method(self, "_restore_theme_override", control, "font_size", font_name, had_old, old_value)
undo_redo.commit_action()
return success({"node_path": node_path, "name": font_name, "size": size})
func _set_theme_stylebox(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "name")
if result2[1] != null:
return result2[1]
var style_name: String = result2[0]
var node := find_node_by_path(node_path)
if node == null or not (node is Control):
return error_not_found("Control node at '%s'" % node_path)
var control: Control = node
var stylebox := StyleBoxFlat.new()
var bg_color: String = optional_string(params, "bg_color", "")
if not bg_color.is_empty():
stylebox.bg_color = Color(bg_color)
var border_color: String = optional_string(params, "border_color", "")
if not border_color.is_empty():
stylebox.border_color = Color(border_color)
var border_width: int = optional_int(params, "border_width", 0)
if border_width > 0:
stylebox.border_width_left = border_width
stylebox.border_width_top = border_width
stylebox.border_width_right = border_width
stylebox.border_width_bottom = border_width
var corner_radius: int = optional_int(params, "corner_radius", 0)
if corner_radius > 0:
stylebox.corner_radius_top_left = corner_radius
stylebox.corner_radius_top_right = corner_radius
stylebox.corner_radius_bottom_left = corner_radius
stylebox.corner_radius_bottom_right = corner_radius
var padding: int = optional_int(params, "padding", 0)
if padding > 0:
stylebox.content_margin_left = padding
stylebox.content_margin_top = padding
stylebox.content_margin_right = padding
stylebox.content_margin_bottom = padding
var had_old := control.has_theme_stylebox_override(style_name)
var old_value: Variant = control.get("theme_override_styles/" + style_name) if had_old else null
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Set theme stylebox override")
undo_redo.add_do_method(control, "add_theme_stylebox_override", style_name, stylebox)
undo_redo.add_do_reference(stylebox)
undo_redo.add_undo_method(self, "_restore_theme_override", control, "stylebox", style_name, had_old, old_value)
if old_value is Resource:
undo_redo.add_undo_reference(old_value)
undo_redo.commit_action()
return success({"node_path": node_path, "name": style_name, "type": "StyleBoxFlat"})
func _setup_control(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var node := find_node_by_path(node_path)
if node == null or not (node is Control):
return error_not_found("Control node at '%s'" % node_path)
var control: Control = node
var applied: Array = []
var old_state := _capture_control_setup_state(control)
var target: Control = control.duplicate() as Control
# Anchor preset
var anchor_preset: String = optional_string(params, "anchor_preset", "")
if not anchor_preset.is_empty():
var preset_map := {
"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,
}
if preset_map.has(anchor_preset):
target.set_anchors_and_offsets_preset(preset_map[anchor_preset])
applied.append("anchor_preset=%s" % anchor_preset)
# Min size
var min_size_str: String = optional_string(params, "min_size", "")
if not min_size_str.is_empty():
var expr := Expression.new()
if expr.parse(min_size_str) == OK:
var val = expr.execute()
if val is Vector2:
target.custom_minimum_size = val
applied.append("min_size=%s" % min_size_str)
# Size flags horizontal
var sf_h: String = optional_string(params, "size_flags_h", "")
if not sf_h.is_empty():
var flags_map := {
"fill": Control.SIZE_FILL,
"expand": Control.SIZE_EXPAND,
"fill_expand": Control.SIZE_EXPAND_FILL,
"shrink_center": Control.SIZE_SHRINK_CENTER,
"shrink_end": Control.SIZE_SHRINK_END,
}
if flags_map.has(sf_h):
target.size_flags_horizontal = flags_map[sf_h]
applied.append("size_flags_h=%s" % sf_h)
# Size flags vertical
var sf_v: String = optional_string(params, "size_flags_v", "")
if not sf_v.is_empty():
var flags_map := {
"fill": Control.SIZE_FILL,
"expand": Control.SIZE_EXPAND,
"fill_expand": Control.SIZE_EXPAND_FILL,
"shrink_center": Control.SIZE_SHRINK_CENTER,
"shrink_end": Control.SIZE_SHRINK_END,
}
if flags_map.has(sf_v):
target.size_flags_vertical = flags_map[sf_v]
applied.append("size_flags_v=%s" % sf_v)
# Margins (for MarginContainer)
if params.has("margins") and params["margins"] is Dictionary:
var margins: Dictionary = params["margins"]
if target is MarginContainer:
if margins.has("left"):
target.add_theme_constant_override("margin_left", int(margins["left"]))
if margins.has("top"):
target.add_theme_constant_override("margin_top", int(margins["top"]))
if margins.has("right"):
target.add_theme_constant_override("margin_right", int(margins["right"]))
if margins.has("bottom"):
target.add_theme_constant_override("margin_bottom", int(margins["bottom"]))
applied.append("margins=%s" % str(margins))
# Separation (for VBox/HBoxContainer)
if params.has("separation"):
var sep: int = int(params["separation"])
if target is BoxContainer:
target.add_theme_constant_override("separation", sep)
applied.append("separation=%d" % sep)
# Grow direction horizontal
var grow_h: String = optional_string(params, "grow_h", "")
if not grow_h.is_empty():
var grow_map := {
"begin": Control.GROW_DIRECTION_BEGIN,
"end": Control.GROW_DIRECTION_END,
"both": Control.GROW_DIRECTION_BOTH,
}
if grow_map.has(grow_h):
target.grow_horizontal = grow_map[grow_h]
applied.append("grow_h=%s" % grow_h)
# Grow direction vertical
var grow_v: String = optional_string(params, "grow_v", "")
if not grow_v.is_empty():
var grow_map := {
"begin": Control.GROW_DIRECTION_BEGIN,
"end": Control.GROW_DIRECTION_END,
"both": Control.GROW_DIRECTION_BOTH,
}
if grow_map.has(grow_v):
target.grow_vertical = grow_map[grow_v]
applied.append("grow_v=%s" % grow_v)
if not applied.is_empty():
var new_state := _capture_control_setup_state(target)
_register_control_setup_undo(control, old_state, new_state)
target.free()
return success({"node_path": node_path, "applied": applied, "count": applied.size()})
func _restore_theme_override(control: Control, kind: String, override_name: String, had_old: bool, old_value: Variant) -> void:
match kind:
"color":
if had_old:
control.add_theme_color_override(override_name, old_value)
else:
control.remove_theme_color_override(override_name)
"constant":
if had_old:
control.add_theme_constant_override(override_name, old_value)
else:
control.remove_theme_constant_override(override_name)
"font_size":
if had_old:
control.add_theme_font_size_override(override_name, old_value)
else:
control.remove_theme_font_size_override(override_name)
"stylebox":
if had_old:
control.add_theme_stylebox_override(override_name, old_value)
else:
control.remove_theme_stylebox_override(override_name)
func _capture_control_setup_state(control: Control) -> Dictionary:
var state := {"properties": {}, "theme_constants": {}}
for property: String in [
"anchor_left", "anchor_top", "anchor_right", "anchor_bottom",
"offset_left", "offset_top", "offset_right", "offset_bottom",
"custom_minimum_size", "size_flags_horizontal", "size_flags_vertical",
"grow_horizontal", "grow_vertical",
]:
state["properties"][property] = control.get(property)
for constant_name: String in ["margin_left", "margin_top", "margin_right", "margin_bottom", "separation"]:
var had_override := control.has_theme_constant_override(constant_name)
state["theme_constants"][constant_name] = {
"had": had_override,
"value": control.get("theme_override_constants/" + constant_name) if had_override else null,
}
return state
func _register_control_setup_undo(control: Control, old_state: Dictionary, new_state: Dictionary) -> void:
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Setup Control")
for property: String in new_state["properties"]:
undo_redo.add_do_property(control, property, new_state["properties"][property])
undo_redo.add_undo_property(control, property, old_state["properties"][property])
for constant_name: String in new_state["theme_constants"]:
var new_constant: Dictionary = new_state["theme_constants"][constant_name]
var old_constant: Dictionary = old_state["theme_constants"][constant_name]
undo_redo.add_do_method(self, "_restore_theme_override", control, "constant", constant_name, new_constant["had"], new_constant["value"])
undo_redo.add_undo_method(self, "_restore_theme_override", control, "constant", constant_name, old_constant["had"], old_constant["value"])
undo_redo.commit_action()
func _get_theme_info(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var node := find_node_by_path(node_path)
if node == null or not (node is Control):
return error_not_found("Control node at '%s'" % node_path)
var control: Control = node
var info := {"node_path": node_path, "class": control.get_class()}
# Check if node has a theme
var theme := control.theme
if theme:
info["theme_path"] = theme.resource_path
info["type_list"] = Array(theme.get_type_list())
# List overrides
var overrides := {"colors": {}, "constants": {}, "font_sizes": {}, "styleboxes": {}}
for prop in control.get_property_list():
var pname: String = prop["name"]
if pname.begins_with("theme_override_colors/"):
var key := pname.substr(22)
overrides["colors"][key] = "#" + (control.get(pname) as Color).to_html()
elif pname.begins_with("theme_override_constants/"):
var key := pname.substr(25)
overrides["constants"][key] = control.get(pname)
elif pname.begins_with("theme_override_font_sizes/"):
var key := pname.substr(26)
overrides["font_sizes"][key] = control.get(pname)
elif pname.begins_with("theme_override_styles/"):
var key := pname.substr(22)
var style = control.get(pname)
overrides["styleboxes"][key] = style.get_class() if style else null
info["overrides"] = overrides
return success(info)

View File

@@ -0,0 +1,217 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
func get_commands() -> Dictionary:
return {
"tilemap_set_cell": _tilemap_set_cell,
"tilemap_fill_rect": _tilemap_fill_rect,
"tilemap_get_cell": _tilemap_get_cell,
"tilemap_clear": _tilemap_clear,
"tilemap_get_info": _tilemap_get_info,
"tilemap_get_used_cells": _tilemap_get_used_cells,
}
func _find_tilemap(node_path: String) -> TileMapLayer:
var node := find_node_by_path(node_path)
if node is TileMapLayer:
return node as TileMapLayer
return null
func _tilemap_set_cell(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var tilemap := _find_tilemap(node_path)
if tilemap == null:
return error_not_found("TileMapLayer at '%s'" % node_path)
var x: int = int(params.get("x", 0))
var y: int = int(params.get("y", 0))
var source_id: int = int(params.get("source_id", 0))
var atlas_x: int = int(params.get("atlas_x", 0))
var atlas_y: int = int(params.get("atlas_y", 0))
var alternative: int = int(params.get("alternative", 0))
var coords := Vector2i(x, y)
var old_cells := [_capture_cell(tilemap, coords)]
var new_cells := [_make_cell(coords, source_id, Vector2i(atlas_x, atlas_y), alternative)]
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Set TileMap cell")
_add_do_set_cells(undo_redo, tilemap, new_cells)
_add_undo_set_cells(undo_redo, tilemap, old_cells)
undo_redo.commit_action()
return success({"x": x, "y": y, "source_id": source_id, "atlas_coords": [atlas_x, atlas_y]})
func _tilemap_fill_rect(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var tilemap := _find_tilemap(node_path)
if tilemap == null:
return error_not_found("TileMapLayer at '%s'" % node_path)
var x1: int = int(params.get("x1", 0))
var y1: int = int(params.get("y1", 0))
var x2: int = int(params.get("x2", 0))
var y2: int = int(params.get("y2", 0))
var source_id: int = int(params.get("source_id", 0))
var atlas_x: int = int(params.get("atlas_x", 0))
var atlas_y: int = int(params.get("atlas_y", 0))
var alternative: int = int(params.get("alternative", 0))
var count := 0
var old_cells: Array = []
var new_cells: Array = []
for cx in range(mini(x1, x2), maxi(x1, x2) + 1):
for cy in range(mini(y1, y2), maxi(y1, y2) + 1):
var coords := Vector2i(cx, cy)
old_cells.append(_capture_cell(tilemap, coords))
new_cells.append(_make_cell(coords, source_id, Vector2i(atlas_x, atlas_y), alternative))
count += 1
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Fill TileMap rect")
_add_do_set_cells(undo_redo, tilemap, new_cells)
_add_undo_set_cells(undo_redo, tilemap, old_cells)
undo_redo.commit_action()
return success({"filled": count, "rect": [x1, y1, x2, y2]})
func _tilemap_get_cell(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var tilemap := _find_tilemap(node_path)
if tilemap == null:
return error_not_found("TileMapLayer at '%s'" % node_path)
var x: int = int(params.get("x", 0))
var y: int = int(params.get("y", 0))
var coords := Vector2i(x, y)
var source_id := tilemap.get_cell_source_id(coords)
var atlas_coords := tilemap.get_cell_atlas_coords(coords)
var alternative := tilemap.get_cell_alternative_tile(coords)
return success({
"x": x, "y": y,
"source_id": source_id,
"atlas_coords": [atlas_coords.x, atlas_coords.y],
"alternative": alternative,
"empty": source_id == -1,
})
func _tilemap_clear(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var tilemap := _find_tilemap(node_path)
if tilemap == null:
return error_not_found("TileMapLayer at '%s'" % node_path)
var old_cells: Array = []
for coords: Vector2i in tilemap.get_used_cells():
old_cells.append(_capture_cell(tilemap, coords))
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Clear TileMap")
undo_redo.add_do_method(tilemap, "clear")
_add_undo_set_cells(undo_redo, tilemap, old_cells)
undo_redo.commit_action()
return success({"cleared": true})
func _make_cell(coords: Vector2i, source_id: int, atlas_coords: Vector2i, alternative: int) -> Dictionary:
return {
"coords": coords,
"source_id": source_id,
"atlas_coords": atlas_coords,
"alternative": alternative,
}
func _capture_cell(tilemap: TileMapLayer, coords: Vector2i) -> Dictionary:
return _make_cell(
coords,
tilemap.get_cell_source_id(coords),
tilemap.get_cell_atlas_coords(coords),
tilemap.get_cell_alternative_tile(coords)
)
func _add_do_set_cells(undo_redo: EditorUndoRedoManager, tilemap: TileMapLayer, cells: Array) -> void:
for cell: Dictionary in cells:
undo_redo.add_do_method(tilemap, "set_cell", cell["coords"], cell["source_id"], cell["atlas_coords"], cell["alternative"])
func _add_undo_set_cells(undo_redo: EditorUndoRedoManager, tilemap: TileMapLayer, cells: Array) -> void:
for cell: Dictionary in cells:
undo_redo.add_undo_method(tilemap, "set_cell", cell["coords"], cell["source_id"], cell["atlas_coords"], cell["alternative"])
func _tilemap_get_info(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var tilemap := _find_tilemap(node_path)
if tilemap == null:
return error_not_found("TileMapLayer at '%s'" % node_path)
var tile_set := tilemap.tile_set
var sources: Array = []
if tile_set:
for i in tile_set.get_source_count():
var source_id := tile_set.get_source_id(i)
var source := tile_set.get_source(source_id)
var info := {"id": source_id, "type": source.get_class()}
if source is TileSetAtlasSource:
var atlas: TileSetAtlasSource = source
info["texture"] = atlas.texture.resource_path if atlas.texture else ""
info["tile_count"] = atlas.get_tiles_count()
sources.append(info)
return success({
"node_path": node_path,
"used_cells": tilemap.get_used_cells().size(),
"tile_set_sources": sources,
"tile_size": [tile_set.tile_size.x, tile_set.tile_size.y] if tile_set else [0, 0],
})
func _tilemap_get_used_cells(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var tilemap := _find_tilemap(node_path)
if tilemap == null:
return error_not_found("TileMapLayer at '%s'" % node_path)
var max_count: int = optional_int(params, "max_count", 500)
var cells: Array = []
var used := tilemap.get_used_cells()
for i in mini(used.size(), max_count):
var pos: Vector2i = used[i]
cells.append({"x": pos.x, "y": pos.y, "source_id": tilemap.get_cell_source_id(pos)})
return success({"cells": cells, "total": used.size(), "returned": cells.size()})