refactor: 拆分 claude-dev-stack 为 windows-dev-stack 和 wsl-dev-stack

将原 claude-dev-stack 目录拆分为独立的 Windows 和 WSL 部署栈,便于分别维护和使用。

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-29 01:11:20 +08:00
parent e8693dad2a
commit dd3eb24d0f
488 changed files with 33927 additions and 0 deletions

View File

@@ -0,0 +1,120 @@
@tool
extends Node
var editor_plugin: EditorPlugin
var _command_handlers: Dictionary = {} # method_name -> Callable
var _disabled_tools: Dictionary = {} # method_name -> true
const TOOL_CONFIG_PATH := "user://mcp_tool_config.cfg"
func _ready() -> void:
_load_tool_config()
_register_commands()
func _register_commands() -> void:
var command_classes := [
preload("res://addons/godot_mcp/commands/project_commands.gd"),
preload("res://addons/godot_mcp/commands/scene_commands.gd"),
preload("res://addons/godot_mcp/commands/node_commands.gd"),
preload("res://addons/godot_mcp/commands/script_commands.gd"),
preload("res://addons/godot_mcp/commands/editor_commands.gd"),
preload("res://addons/godot_mcp/commands/input_commands.gd"),
preload("res://addons/godot_mcp/commands/runtime_commands.gd"),
preload("res://addons/godot_mcp/commands/animation_commands.gd"),
preload("res://addons/godot_mcp/commands/tilemap_commands.gd"),
preload("res://addons/godot_mcp/commands/theme_commands.gd"),
preload("res://addons/godot_mcp/commands/profiling_commands.gd"),
preload("res://addons/godot_mcp/commands/batch_commands.gd"),
preload("res://addons/godot_mcp/commands/shader_commands.gd"),
preload("res://addons/godot_mcp/commands/export_commands.gd"),
preload("res://addons/godot_mcp/commands/resource_commands.gd"),
preload("res://addons/godot_mcp/commands/input_map_commands.gd"),
preload("res://addons/godot_mcp/commands/scene_3d_commands.gd"),
preload("res://addons/godot_mcp/commands/physics_commands.gd"),
preload("res://addons/godot_mcp/commands/analysis_commands.gd"),
preload("res://addons/godot_mcp/commands/animation_tree_commands.gd"),
preload("res://addons/godot_mcp/commands/audio_commands.gd"),
preload("res://addons/godot_mcp/commands/navigation_commands.gd"),
preload("res://addons/godot_mcp/commands/particle_commands.gd"),
preload("res://addons/godot_mcp/commands/test_commands.gd"),
preload("res://addons/godot_mcp/commands/android_commands.gd"),
]
for cmd_class in command_classes:
var cmd: Node = cmd_class.new()
cmd.editor_plugin = editor_plugin
add_child(cmd)
var methods: Dictionary = cmd.get_commands()
for method_name: String in methods:
_command_handlers[method_name] = methods[method_name]
print("[MCP] Registered %d commands" % _command_handlers.size())
func execute(method: String, params: Dictionary) -> Dictionary:
if not _command_handlers.has(method):
return {
"error": {
"code": -32601,
"message": "Method not found: %s" % method,
"data": {"available_methods": _command_handlers.keys()}
}
}
if _disabled_tools.has(method):
return {
"error": {
"code": -32603,
"message": "Tool '%s' is disabled in MCP Server settings" % method
}
}
var handler: Callable = _command_handlers[method]
var result: Dictionary = await handler.call(params)
return result
func get_available_methods() -> Array:
return _command_handlers.keys()
func is_tool_disabled(method: String) -> bool:
return _disabled_tools.has(method)
func set_tool_disabled(method: String, disabled: bool) -> void:
if disabled:
_disabled_tools[method] = true
else:
_disabled_tools.erase(method)
_save_tool_config()
func set_all_tools_disabled(disabled: bool) -> void:
if disabled:
for method: String in _command_handlers:
_disabled_tools[method] = true
else:
_disabled_tools.clear()
_save_tool_config()
func _load_tool_config() -> void:
var cfg := ConfigFile.new()
if cfg.load(TOOL_CONFIG_PATH) != OK:
return
if not cfg.has_section("disabled_tools"):
return
for method: String in cfg.get_section_keys("disabled_tools"):
if cfg.get_value("disabled_tools", method, false):
_disabled_tools[method] = true
func _save_tool_config() -> void:
var cfg := ConfigFile.new()
for method: String in _disabled_tools:
cfg.set_value("disabled_tools", method, true)
cfg.save(TOOL_CONFIG_PATH)

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()})

View File

@@ -0,0 +1,198 @@
## Autoload injected by Godot MCP Pro plugin at runtime.
## Monitors for input commands from the editor and dispatches them as Input events.
extends Node
const COMMANDS_PATH := "user://mcp_input_commands"
var _sequence_queue: Array = [] # Array of event dicts
var _sequence_frame_delay: int = 0
var _sequence_frames_waited: int = 0
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
func _process(_delta: float) -> void:
# Process queued sequence events
if not _sequence_queue.is_empty():
_process_sequence_tick()
# Check for new commands from file
if FileAccess.file_exists(COMMANDS_PATH):
_process_commands()
func _process_commands() -> void:
var file := FileAccess.open(COMMANDS_PATH, FileAccess.READ)
if file == null:
return
var text := file.get_as_text()
file.close()
DirAccess.remove_absolute(COMMANDS_PATH)
var parsed = JSON.parse_string(text)
if parsed == null:
push_warning("[MCP Input] Failed to parse input commands JSON")
return
# Check if this is a sequence command (dict with "events" and "frame_delay")
if parsed is Dictionary and parsed.has("sequence_events"):
_start_sequence(parsed)
return
# Otherwise treat as immediate event(s)
var events: Array = parsed if parsed is Array else [parsed]
for event_data: Dictionary in events:
var event := _create_event(event_data)
if event != null:
_dispatch_event(event, event_data)
func _start_sequence(data: Dictionary) -> void:
_sequence_queue = data.get("sequence_events", []).duplicate()
_sequence_frame_delay = data.get("frame_delay", 1)
_sequence_frames_waited = 0
# Dispatch first event immediately
if not _sequence_queue.is_empty():
_dispatch_next_sequence_event()
func _process_sequence_tick() -> void:
_sequence_frames_waited += 1
if _sequence_frames_waited >= _sequence_frame_delay:
_sequence_frames_waited = 0
_dispatch_next_sequence_event()
func _dispatch_next_sequence_event() -> void:
if _sequence_queue.is_empty():
return
var event_data: Dictionary = _sequence_queue.pop_front()
var event := _create_event(event_data)
if event != null:
_dispatch_event(event, event_data)
## Dispatch an input event using the appropriate method.
## Mouse drag motions (button_mask > 0) auto-promote to push_input to bypass
## GUI consumption and reach _unhandled_input — needed for camera-pan use
## cases where UI Controls would otherwise swallow drag events. But for UI
## drag-and-drop *testing* we want events to reach the GUI dispatcher so
## hit-testing and _get_drag_data / _drop_data fire. So: respect an explicit
## "unhandled": false in the event payload — only auto-promote when the
## caller did NOT pass an "unhandled" key. Default behavior preserved.
func _dispatch_event(event: InputEvent, event_data: Dictionary = {}) -> void:
var force_unhandled: bool
if event_data.has("unhandled"):
force_unhandled = bool(event_data.get("unhandled"))
else:
force_unhandled = event is InputEventMouseMotion and event.button_mask != 0
if force_unhandled:
var vp := get_viewport()
if vp:
vp.push_input(event, true)
else:
Input.parse_input_event(event)
else:
Input.parse_input_event(event)
func _create_event(data: Dictionary) -> InputEvent:
var type: String = data.get("type", "")
match type:
"key":
return _create_key_event(data)
"mouse_button":
return _create_mouse_button_event(data)
"mouse_motion":
return _create_mouse_motion_event(data)
"action":
return _create_action_event(data)
_:
push_warning("[MCP Input] Unknown event type: %s" % type)
return null
## Convert viewport coordinates to window coordinates for Input.parse_input_event().
## Godot applies viewport.get_final_transform() to mouse events internally,
## so we must pass window-space coordinates (pre-transform).
func _viewport_to_window(viewport_pos: Vector2) -> Vector2:
var vp := get_viewport()
if vp == null:
return viewport_pos
var xform := vp.get_final_transform()
return xform * viewport_pos
func _create_key_event(data: Dictionary) -> InputEventKey:
var event := InputEventKey.new()
var keycode_str: String = data.get("keycode", "")
if keycode_str.begins_with("KEY_"):
var constant_value = ClassDB.class_get_integer_constant("@GlobalScope", keycode_str)
if constant_value != 0:
event.keycode = constant_value
else:
event.keycode = OS.find_keycode_from_string(keycode_str.substr(4))
else:
event.keycode = OS.find_keycode_from_string(keycode_str)
event.pressed = data.get("pressed", true)
event.shift_pressed = data.get("shift", false)
event.ctrl_pressed = data.get("ctrl", false)
event.alt_pressed = data.get("alt", false)
return event
func _extract_position(data: Dictionary) -> Vector2:
# Support nested {"position": {"x": ..., "y": ...}} or flat {"x": ..., "y": ...}
var pos = data.get("position", null)
if pos is Dictionary:
return Vector2(pos.get("x", 0.0), pos.get("y", 0.0))
return Vector2(data.get("x", 0.0), data.get("y", 0.0))
func _create_mouse_button_event(data: Dictionary) -> InputEventMouseButton:
var event := InputEventMouseButton.new()
event.button_index = data.get("button", MOUSE_BUTTON_LEFT)
event.pressed = data.get("pressed", true)
event.double_click = data.get("double_click", false)
var window_pos := _viewport_to_window(_extract_position(data))
event.position = window_pos
event.global_position = window_pos
return event
func _create_mouse_motion_event(data: Dictionary) -> InputEventMouseMotion:
var event := InputEventMouseMotion.new()
var window_pos := _viewport_to_window(_extract_position(data))
event.position = window_pos
event.global_position = window_pos
# Support nested {"relative": {"x": ..., "y": ...}} or flat {"relative_x": ..., "relative_y": ...}
var rel_x: float = 0.0
var rel_y: float = 0.0
var rel = data.get("relative", null)
if rel is Dictionary:
rel_x = float(rel.get("x", 0.0))
rel_y = float(rel.get("y", 0.0))
else:
rel_x = float(data.get("relative_x", 0.0))
rel_y = float(data.get("relative_y", 0.0))
# Scale relative movement by the same transform (scale only, no offset)
var vp := get_viewport()
if vp:
var scale := vp.get_final_transform().get_scale()
event.relative = Vector2(rel_x, rel_y) * scale
else:
event.relative = Vector2(rel_x, rel_y)
# Set button_mask so drag detection works (e.g. camera pan checks button_mask)
var button_mask: int = int(data.get("button_mask", 0))
event.button_mask = button_mask
return event
func _create_action_event(data: Dictionary) -> InputEventAction:
var event := InputEventAction.new()
event.action = data.get("action", "")
event.pressed = data.get("pressed", true)
event.strength = data.get("strength", 1.0)
return event

View File

@@ -0,0 +1,34 @@
## Autoload injected by Godot MCP Pro plugin at runtime.
## Monitors for screenshot requests from the editor and captures the game viewport.
extends Node
const REQUEST_PATH := "user://mcp_screenshot_request"
const SCREENSHOT_PATH := "user://mcp_screenshot.png"
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
func _process(_delta: float) -> void:
if FileAccess.file_exists(REQUEST_PATH):
_take_screenshot()
func _take_screenshot() -> void:
# Delete request file immediately to avoid re-triggering
DirAccess.remove_absolute(REQUEST_PATH)
# Wait one frame so the viewport has a fully rendered image
# process_always=true (default) so the timer ticks even when tree is paused
await get_tree().create_timer(0.05).timeout
var viewport := get_viewport()
if viewport == null:
return
var image := viewport.get_texture().get_image()
if image == null:
return
image.save_png(SCREENSHOT_PATH)

View File

@@ -0,0 +1,7 @@
[plugin]
name="Godot MCP Pro"
description="Premium MCP server for AI-powered Godot development. Connects via WebSocket to expose 172 editor tools."
author="godot-mcp-pro"
version="1.14.1"
script="plugin.gd"

View File

@@ -0,0 +1,183 @@
@tool
extends EditorPlugin
const _MCP_AUTOLOADS: Array[Array] = [
["autoload/MCPScreenshot", "res://addons/godot_mcp/mcp_screenshot_service.gd"],
["autoload/MCPInputService", "res://addons/godot_mcp/mcp_input_service.gd"],
["autoload/MCPGameInspector", "res://addons/godot_mcp/mcp_game_inspector_service.gd"],
]
const _MCP_TEMP_FILES: Array[String] = [
"mcp_game_request",
"mcp_game_response",
"mcp_input_commands",
"mcp_screenshot_request",
]
var websocket_server: Node
var command_router: Node
var status_panel: Control
var auto_dismiss_dialogs: bool = false
# Track which autoloads THIS session injected (vs project-owned)
var _session_injected_autoloads: Array[String] = []
func _enter_tree() -> void:
# Create command router
command_router = preload("res://addons/godot_mcp/command_router.gd").new()
command_router.name = "MCPCommandRouter"
command_router.editor_plugin = self
add_child(command_router)
# Create WebSocket server
websocket_server = preload("res://addons/godot_mcp/websocket_server.gd").new()
websocket_server.name = "MCPWebSocketServer"
websocket_server.command_router = command_router
add_child(websocket_server)
# Create status panel
var panel_scene: PackedScene = preload("res://addons/godot_mcp/ui/status_panel.tscn")
status_panel = panel_scene.instantiate()
add_control_to_bottom_panel(status_panel, "MCP Pro")
status_panel.call_deferred("setup", websocket_server, command_router)
# Inject MCP autoloads into project settings
_inject_autoloads()
websocket_server.start_server()
var cfg := ConfigFile.new()
var ver := "unknown"
if cfg.load("res://addons/godot_mcp/plugin.cfg") == OK:
ver = cfg.get_value("plugin", "version", "unknown")
print("[MCP] Godot MCP Pro v%s started (ports 6505-6514)" % ver)
func _exit_tree() -> void:
# Remove MCP autoloads and clean up temp files
_remove_autoloads()
_cleanup_temp_files()
if websocket_server:
websocket_server.stop_server()
if status_panel:
remove_control_from_bottom_panel(status_panel)
status_panel.queue_free()
if command_router:
command_router.queue_free()
if websocket_server:
websocket_server.queue_free()
print("[MCP] Godot MCP Pro stopped")
func _inject_autoloads() -> void:
_session_injected_autoloads.clear()
var changed := false
for entry: Array in _MCP_AUTOLOADS:
var key: String = entry[0]
var script: String = entry[1]
if not ProjectSettings.has_setting(key):
ProjectSettings.set_setting(key, "*" + script)
_session_injected_autoloads.append(key)
changed = true
if changed:
ProjectSettings.save()
func _remove_autoloads() -> void:
# Only remove autoloads that THIS session injected.
# Pre-existing project-owned autoloads are preserved.
var changed := false
for key: String in _session_injected_autoloads:
if ProjectSettings.has_setting(key):
ProjectSettings.set_setting(key, null)
changed = true
_session_injected_autoloads.clear()
if changed:
ProjectSettings.save()
var _dialog_check_timer: float = 0.0
const _DIALOG_CHECK_INTERVAL: float = 0.5 # Check every 0.5 seconds
func _process(delta: float) -> void:
# Check if game inspector requested debugger continue
var flag_path := OS.get_user_data_dir() + "/mcp_debugger_continue"
if FileAccess.file_exists(flag_path):
DirAccess.remove_absolute(flag_path)
_try_debugger_continue()
# Periodically check for blocking editor dialogs (only when enabled by AI)
if auto_dismiss_dialogs:
_dialog_check_timer += delta
if _dialog_check_timer >= _DIALOG_CHECK_INTERVAL:
_dialog_check_timer = 0.0
_auto_dismiss_dialogs()
func _try_debugger_continue() -> void:
# Last resort: find and press the debugger Continue button to unstick the game
var base: Node = EditorInterface.get_base_control()
var continue_btn := _find_debugger_continue_button(base)
if continue_btn and continue_btn.visible and not continue_btn.disabled:
continue_btn.emit_signal("pressed")
push_warning("[MCP] Auto-pressed debugger Continue button")
else:
push_warning("[MCP] Could not find debugger Continue button")
func _find_debugger_continue_button(node: Node) -> Button:
# Search for the Continue button in ScriptEditorDebugger
if node is Button:
var btn: Button = node
if btn.tooltip_text.contains("Continue") or btn.text == "Continue":
return btn
for child in node.get_children():
var found: Button = _find_debugger_continue_button(child)
if found:
return found
return null
func _auto_dismiss_dialogs() -> void:
var base: Node = EditorInterface.get_base_control()
if not base:
return
_find_and_dismiss_dialogs(base)
func _find_and_dismiss_dialogs(node: Node) -> void:
if node is AcceptDialog and node.visible:
var dialog: AcceptDialog = node
# Never dismiss file dialogs or non-modal popups
if dialog is FileDialog:
return
if not dialog.exclusive:
return
# Get dialog title/text for logging
var title := dialog.title
var text := dialog.dialog_text
# Accept the dialog (presses OK / confirms)
dialog.get_ok_button().emit_signal("pressed")
push_warning("[MCP] Auto-dismissed editor dialog: '%s'%s" % [title, text])
return # One dialog per check cycle to avoid side effects
for child in node.get_children():
# Only search visible Windows to keep the scan lightweight
if child is Window and not child.visible:
continue
_find_and_dismiss_dialogs(child)
func _cleanup_temp_files() -> void:
var user_dir := OS.get_user_data_dir()
for filename: String in _MCP_TEMP_FILES:
var path := user_dir + "/" + filename
if FileAccess.file_exists(path):
DirAccess.remove_absolute(path)
# Also clean up screenshot image
var screenshot_path := user_dir + "/mcp_screenshot.png"
if FileAccess.file_exists(screenshot_path):
DirAccess.remove_absolute(screenshot_path)

View File

@@ -0,0 +1,271 @@
> **Language:** [English](skills.md) | [日本語](skills.ja.md) | [Português (BR)](skills.pt-br.md) | Español | [Русский](skills.ru.md) | [简体中文](skills.zh.md) | [हिन्दी](skills.hi.md)
# Godot MCP Pro — Skills para Asistentes de IA
> Copia este archivo a `.claude/skills.md` en la raíz de tu proyecto Godot para darle a Claude Code el contexto completo sobre cómo usar Godot MCP Pro de forma efectiva.
## ¿Qué es Godot MCP Pro?
Tienes acceso a 169 herramientas MCP que se conectan directamente al editor de Godot 4. Puedes crear escenas, escribir scripts, simular entrada del jugador, inspeccionar juegos en ejecución y más — todo sin que el usuario salga de esta conversación. Cada cambio pasa por el sistema UndoRedo de Godot, así que el usuario siempre puede hacer Ctrl+Z.
## Flujos de Trabajo Esenciales
### 1. Explorar un Proyecto
Siempre empieza entendiendo el proyecto antes de hacer cambios:
```
get_project_info → nombre del proyecto, versión de Godot, renderizador, tamaño del viewport
get_filesystem_tree → estructura de directorios (usa filter: "*.tscn" o "*.gd")
get_scene_tree → jerarquía de nodos de la escena abierta actualmente
read_script → leer cualquier archivo GDScript
get_project_settings → revisar la configuración del proyecto
```
### 2. Construir una Escena 2D
```
create_scene → crear archivo .tscn con tipo de nodo raíz
add_node → agregar nodos hijos con propiedades
create_script → escribir GDScript para lógica del juego
attach_script → adjuntar script a un nodo
update_property → establecer position, scale, modulate, etc.
save_scene → guardar en disco
```
**Ejemplo — creando un jugador:**
1. `create_scene` con root_type `CharacterBody2D`, path `res://scenes/player.tscn`
2. `add_node` tipo `Sprite2D` con propiedad texture
3. `add_node` tipo `CollisionShape2D`
4. `add_resource` para asignar una shape (ej: `RectangleShape2D`) al CollisionShape2D
5. `create_script` con lógica de movimiento
6. `attach_script` al nodo raíz
7. `save_scene`
### 3. Construir una Escena 3D
```
create_scene → root_type: Node3D
add_mesh_instance → agregar primitivas (box, sphere, cylinder, plane) o importar .glb/.gltf
setup_lighting → agregar DirectionalLight3D, OmniLight3D o SpotLight3D
setup_environment → cielo, luz ambiental, niebla, tonemap
setup_camera_3d → cámara con SpringArm3D opcional para tercera persona
set_material_3d → materiales PBR (albedo, metallic, roughness, emission)
setup_collision → agregar shapes de colisión a cuerpos físicos
setup_physics_body → configurar masa, fricción, gravedad
```
### 4. Escribir y Editar Scripts
```
create_script → crear nuevo archivo .gd (proporciona el contenido completo)
edit_script → modificar scripts existentes
- Usa `replacements: [{search: "old code", replace: "new code"}]` para ediciones específicas
- Usa `content` para reemplazo completo del archivo
- Usa `insert_at_line` + `text` para insertar código
validate_script → verificar errores de sintaxis sin ejecutar
read_script → leer contenido actual antes de editar
```
### 5. Probar y Depurar
```
play_scene → lanzar el juego (mode: "current", "main" o ruta de archivo)
get_game_screenshot → ver cómo luce el juego en este momento
capture_frames → capturar múltiples frames para observar movimiento/animación
get_game_scene_tree → inspeccionar el árbol de escena en tiempo de ejecución
get_game_node_properties → leer valores en runtime (position, health, state, etc.)
set_game_node_property → modificar valores en el juego en ejecución
simulate_key → presionar teclas (WASD, SPACE, etc.) con duración
simulate_mouse_click → hacer clic en coordenadas del viewport
simulate_action → disparar acciones del InputMap (move_left, jump, etc.)
get_editor_errors → revisar errores de ejecución
stop_scene → detener el juego
```
**Ciclo de playtesting:**
1. `play_scene` → iniciar el juego
2. `get_game_screenshot` → ver estado actual
3. `simulate_key` / `simulate_action` → interactuar con el juego
4. `capture_frames` → observar comportamiento a lo largo del tiempo
5. `get_game_node_properties` → verificar valores específicos
6. `stop_scene` → detener cuando termines
7. Corregir problemas en scripts → repetir
### 6. Animaciones
```
# Asegúrate de que exista un nodo AnimationPlayer en la escena
create_animation → nueva animación con duración y modo de loop
add_animation_track → agregar tracks de property/transform/method
set_animation_keyframe → insertar keyframes en tiempos específicos
get_animation_info → inspeccionar animaciones existentes
```
**Ejemplo — sprite rebotando:**
1. `create_animation` name `bounce`, length `1.0`, loop_mode `1` (loop lineal)
2. `add_animation_track` track_path `Sprite2D:position`, track_type `value`
3. `set_animation_keyframe` time `0.0`, value `Vector2(0, 0)`
4. `set_animation_keyframe` time `0.5`, value `Vector2(0, -50)`
5. `set_animation_keyframe` time `1.0`, value `Vector2(0, 0)`
### 7. UI / HUD
```
add_node → Control, Label, Button, TextureRect, etc.
set_anchor_preset → posicionar Controls (full_rect, center, bottom_wide, etc.)
set_theme_color → cambiar font_color, etc.
set_theme_font_size → ajustar tamaño de texto
set_theme_stylebox → fondos, bordes, esquinas redondeadas
connect_signal → conectar pressed del button, value_changed, etc.
```
### 8. TileMap
```
tilemap_get_info → revisar fuentes del tile set y disposición del atlas
tilemap_set_cell → colocar tiles individuales
tilemap_fill_rect → rellenar regiones rectangulares
tilemap_get_used_cells → ver qué ya está colocado
tilemap_clear → limpiar todas las celdas
```
### 9. Audio
```
add_audio_bus → crear buses de audio (SFX, Music, UI)
set_audio_bus → ajustar volumen, solo, mute
add_audio_bus_effect → agregar reverb, delay, compressor, etc.
add_audio_player → agregar nodos AudioStreamPlayer(2D/3D)
```
### 10. Configuración del Proyecto
```
set_project_setting → cambiar tamaño del viewport, configuraciones de física, etc.
set_input_action → definir mapeos de entrada (move_left → KEY_A, etc.)
add_autoload → registrar singletons autoload
set_physics_layers → nombrar capas de colisión (player, enemy, world, etc.)
```
## Reglas Importantes y Trampas
### Valores de Propiedades
Las propiedades se parsean automáticamente desde strings. Usa estos formatos:
- Vector2: `"Vector2(100, 200)"`
- Vector3: `"Vector3(1, 2, 3)"`
- Color: `"Color(1, 0, 0, 1)"` o `"#ff0000"`
- Bool: `"true"` / `"false"`
- Números: `"42"`, `"3.14"`
- Enums: Usa valores enteros (ej: `0` para el primer valor del enum)
### Nunca Edites project.godot Directamente
El editor de Godot sobrescribe `project.godot` constantemente. Siempre usa `set_project_setting` para cambiar configuraciones del proyecto.
### Anotaciones de Tipo en GDScript
Al escribir GDScript con loops `for` sobre arrays sin tipo, usa anotaciones de tipo explícitas:
```gdscript
# MAL — causará errores
for item in some_untyped_array:
var x := item.value # la inferencia de tipos falla
# BIEN
for i in range(some_untyped_array.size()):
var item: Dictionary = some_untyped_array[i]
var x: int = item.value
```
### Los Cambios en Scripts Necesitan Reload
Después de crear o modificar scripts significativamente, usa `reload_project` para asegurar que Godot reconozca los cambios. Esto es especialmente importante después de `create_script`.
### Consejos para simulate_key
- Usa **duraciones cortas** (0.30.5 segundos) para movimiento preciso
- Duraciones largas (1+ segundo) causan overshooting
- Para pruebas de gameplay, prefiere `simulate_action` sobre `simulate_key` cuando haya acciones del InputMap definidas
### simulate_mouse_click
- El valor por defecto `auto_release: true` envía press y release — requerido para botones de UI
- Los botones de UI se activan en release, por lo que ambos eventos son necesarios
### Limitaciones de execute_game_script
- Sin funciones anidadas (`func` dentro de `func`) — causa error de compilación
- Usa `.get("property")` en lugar de `.property` para acceso dinámico
- Los errores de runtime pausan el debugger (se continúa automáticamente, pero evítalo si es posible)
### Colisión y Áreas de Recolección
- Para ítems recolectables, usa Area3D/Area2D con radio >= 1.5
- Radios más pequeños son casi imposibles de activar con entrada simulada
### Guarda Frecuentemente
Llama a `save_scene` después de hacer cambios significativos. Los cambios no guardados pueden perderse si el editor se recarga.
## Herramientas de Análisis y Depuración
Cuando algo sale mal, usa estas herramientas para investigar:
```
get_editor_errors → revisar errores de script y excepciones de runtime
get_output_log → leer salida de print() y advertencias
analyze_scene_complexity → encontrar cuellos de botella de rendimiento
analyze_signal_flow → visualizar conexiones de signals
detect_circular_dependencies → encontrar referencias circulares de script/escena
find_unused_resources → limpiar archivos no utilizados
get_performance_monitors → FPS, memoria, draw calls, estadísticas de física
```
## Pruebas y QA
```
run_test_scenario → definir y ejecutar secuencias de prueba automatizadas
assert_node_state → verificar que las propiedades de nodos coincidan con valores esperados
assert_screen_text → verificar que el texto se muestre en pantalla
compare_screenshots → pruebas de regresión visual (usa rutas de archivo, no base64)
run_stress_test → generar muchos nodos para probar rendimiento
```
## Patrones Avanzados
### Operaciones entre Escenas
```
cross_scene_set_property → modificar nodos en escenas que no están abiertas actualmente
find_node_references → encontrar todos los archivos que referencian un patrón
batch_set_property → establecer una propiedad en todos los nodos de un tipo
```
### Flujo de Trabajo con Shaders
```
create_shader → escribir código shader estilo GLSL
assign_shader_material → aplicar a un nodo
set_shader_param → ajustar uniforms en runtime
get_shader_params → inspeccionar valores actuales
```
### Navegación (3D)
```
setup_navigation_region → definir área transitable
bake_navigation_mesh → generar navmesh
setup_navigation_agent → agregar pathfinding a personajes
```
### AnimationTree y Máquinas de Estado
```
create_animation_tree → configurar AnimationTree con máquina de estado o blend tree
add_state_machine_state → agregar estados (idle, walk, run, jump)
add_state_machine_transition → definir transiciones entre estados
set_tree_parameter → controlar parámetros de blend
```
## Orden de Flujo de Trabajo Recomendado
Al construir un juego nuevo desde cero:
1. **Configuración del proyecto**`get_project_info`, `set_project_setting` (viewport, física)
2. **Mapeo de entrada**`set_input_action` para todos los controles del jugador
3. **Escena principal**`create_scene`, establecer como escena principal
4. **Jugador** — crear escena del jugador con sprite, colisión, script
5. **Nivel/Mundo** — construir el entorno (TileMap, meshes 3D, etc.)
6. **Lógica del juego** — scripts para enemigos, ítems, UI
7. **Audio** — configurar buses, agregar audio players
8. **Playtesting**`play_scene`, probar con entrada simulada, corregir bugs
9. **Pulido** — animaciones, partículas, shaders, temas
10. **Exportación**`list_export_presets`, `export_project`

View File

@@ -0,0 +1,271 @@
> **Language:** [English](skills.md) | [日本語](skills.ja.md) | [Português (BR)](skills.pt-br.md) | [Español](skills.es.md) | [Русский](skills.ru.md) | [简体中文](skills.zh.md) | हिन्दी
# Godot MCP Pro — AI Assistants के लिए Skills
> इस फ़ाइल को अपने Godot प्रोजेक्ट रूट में `.claude/skills.md` पर कॉपी करें ताकि Claude Code को Godot MCP Pro को प्रभावी ढंग से उपयोग करने का पूरा context मिल सके।
## Godot MCP Pro क्या है?
आपके पास 169 MCP tools उपलब्ध हैं जो सीधे Godot 4 editor से कनेक्ट होते हैं। आप scenes बना सकते हैं, scripts लिख सकते हैं, player input simulate कर सकते हैं, running games को inspect कर सकते हैं, और बहुत कुछ — सब कुछ बिना user को इस conversation से बाहर जाए। हर बदलाव Godot के UndoRedo system से होता है, इसलिए user हमेशा Ctrl+Z कर सकता है।
## ज़रूरी Workflows
### 1. प्रोजेक्ट को Explore करें
बदलाव करने से पहले हमेशा प्रोजेक्ट को समझें:
```
get_project_info → प्रोजेक्ट का नाम, Godot version, renderer, viewport size
get_filesystem_tree → directory structure (filter: "*.tscn" या "*.gd" use करें)
get_scene_tree → currently open scene की node hierarchy
read_script → कोई भी GDScript फ़ाइल पढ़ें
get_project_settings → project configuration चेक करें
```
### 2. 2D Scene बनाएं
```
create_scene → root node type के साथ .tscn फ़ाइल बनाएं
add_node → properties के साथ child nodes जोड़ें
create_script → game logic के लिए GDScript लिखें
attach_script → node पर script attach करें
update_property → position, scale, modulate आदि सेट करें
save_scene → disk पर save करें
```
**उदाहरण — player बनाना:**
1. `create_scene` root_type `CharacterBody2D` के साथ, path `res://scenes/player.tscn`
2. `add_node` type `Sprite2D` texture property के साथ
3. `add_node` type `CollisionShape2D`
4. `add_resource` CollisionShape2D को shape assign करने के लिए (जैसे `RectangleShape2D`)
5. `create_script` movement logic के साथ
6. `attach_script` root node पर
7. `save_scene`
### 3. 3D Scene बनाएं
```
create_scene → root_type: Node3D
add_mesh_instance → primitives (box, sphere, cylinder, plane) जोड़ें या .glb/.gltf import करें
setup_lighting → DirectionalLight3D, OmniLight3D, या SpotLight3D जोड़ें
setup_environment → sky, ambient light, fog, tonemap
setup_camera_3d → camera, optional SpringArm3D के साथ third-person के लिए
set_material_3d → PBR materials (albedo, metallic, roughness, emission)
setup_collision → physics bodies में collision shapes जोड़ें
setup_physics_body → mass, friction, gravity configure करें
```
### 4. Scripts लिखें और Edit करें
```
create_script → नई .gd फ़ाइल बनाएं (पूरा content दें)
edit_script → existing scripts modify करें
- `replacements: [{search: "old code", replace: "new code"}]` targeted edits के लिए
- `content` पूरी फ़ाइल replace करने के लिए
- `insert_at_line` + `text` code insert करने के लिए
validate_script → बिना run किए syntax errors चेक करें
read_script → edit करने से पहले current content पढ़ें
```
### 5. Playtest और Debug करें
```
play_scene → game launch करें (mode: "current", "main", या file path)
get_game_screenshot → अभी game कैसा दिख रहा है देखें
capture_frames → motion/animation observe करने के लिए multiple frames capture करें
get_game_scene_tree → runtime पर live scene tree inspect करें
get_game_node_properties → runtime values पढ़ें (position, health, state आदि)
set_game_node_property → running game में values modify करें
simulate_key → keys press करें (WASD, SPACE आदि) duration के साथ
simulate_mouse_click → viewport coordinates पर click करें
simulate_action → InputMap actions trigger करें (move_left, jump आदि)
get_editor_errors → runtime errors चेक करें
stop_scene → game बंद करें
```
**Playtesting loop:**
1. `play_scene` → game शुरू करें
2. `get_game_screenshot` → current state देखें
3. `simulate_key` / `simulate_action` → game के साथ interact करें
4. `capture_frames` → समय के साथ behavior observe करें
5. `get_game_node_properties` → specific values चेक करें
6. `stop_scene` → काम हो जाए तो बंद करें
7. Scripts में issues fix करें → दोहराएं
### 6. Animations
```
# Scene में AnimationPlayer node होना ज़रूरी है
create_animation → length और loop mode के साथ नई animation
add_animation_track → property/transform/method tracks जोड़ें
set_animation_keyframe → specific times पर keyframes insert करें
get_animation_info → existing animations inspect करें
```
**उदाहरण — bouncing sprite:**
1. `create_animation` name `bounce`, length `1.0`, loop_mode `1` (linear loop)
2. `add_animation_track` track_path `Sprite2D:position`, track_type `value`
3. `set_animation_keyframe` time `0.0`, value `Vector2(0, 0)`
4. `set_animation_keyframe` time `0.5`, value `Vector2(0, -50)`
5. `set_animation_keyframe` time `1.0`, value `Vector2(0, 0)`
### 7. UI / HUD
```
add_node → Control, Label, Button, TextureRect आदि
set_anchor_preset → Controls position करें (full_rect, center, bottom_wide आदि)
set_theme_color → font_color आदि बदलें
set_theme_font_size → text size adjust करें
set_theme_stylebox → backgrounds, borders, rounded corners
connect_signal → button pressed, value_changed आदि wire up करें
```
### 8. TileMap
```
tilemap_get_info → tile set sources और atlas layout चेक करें
tilemap_set_cell → individual tiles place करें
tilemap_fill_rect → rectangular regions fill करें
tilemap_get_used_cells → देखें क्या पहले से placed है
tilemap_clear → सभी cells clear करें
```
### 9. Audio
```
add_audio_bus → audio buses बनाएं (SFX, Music, UI)
set_audio_bus → volume, solo, mute adjust करें
add_audio_bus_effect → reverb, delay, compressor आदि जोड़ें
add_audio_player → AudioStreamPlayer(2D/3D) nodes जोड़ें
```
### 10. Project Configuration
```
set_project_setting → viewport size, physics settings आदि बदलें
set_input_action → input mappings define करें (move_left → KEY_A आदि)
add_autoload → autoload singletons register करें
set_physics_layers → collision layers name करें (player, enemy, world आदि)
```
## ज़रूरी Rules और Pitfalls
### Property Values
Properties strings से auto-parse होती हैं। ये formats use करें:
- Vector2: `"Vector2(100, 200)"`
- Vector3: `"Vector3(1, 2, 3)"`
- Color: `"Color(1, 0, 0, 1)"` या `"#ff0000"`
- Bool: `"true"` / `"false"`
- Numbers: `"42"`, `"3.14"`
- Enums: Integer values use करें (जैसे पहले enum value के लिए `0`)
### project.godot को कभी सीधे Edit न करें
Godot editor लगातार `project.godot` को overwrite करता है। Project settings बदलने के लिए हमेशा `set_project_setting` use करें।
### GDScript Type Annotations
Untyped arrays पर `for` loops लिखते समय, explicit type annotations use करें:
```gdscript
# गलत — errors आएंगे
for item in some_untyped_array:
var x := item.value # type inference fail होता है
# सही
for i in range(some_untyped_array.size()):
var item: Dictionary = some_untyped_array[i]
var x: int = item.value
```
### Script Changes के लिए Reload ज़रूरी
Scripts create या significantly modify करने के बाद, `reload_project` use करें ताकि Godot changes को pick up करे। `create_script` के बाद ये खासकर ज़रूरी है।
### simulate_key Tips
- Precise movement के लिए **छोटी duration** (0.30.5 seconds) use करें
- लंबी duration (1+ second) से overshooting होती है
- Gameplay testing के लिए, जब InputMap actions defined हों तो `simulate_key` की जगह `simulate_action` prefer करें
### simulate_mouse_click
- Default `auto_release: true` press और release दोनों भेजता है — UI buttons के लिए ज़रूरी है
- UI buttons release पर fire होते हैं, इसलिए दोनों events चाहिए
### execute_game_script की Limitations
- Nested functions (`func` के अंदर `func`) नहीं चलतीं — compile error आता है
- Dynamic access के लिए `.property` की जगह `.get("property")` use करें
- Runtime errors debugger को pause करती हैं (auto-continue होता है, लेकिन बचना बेहतर)
### Collision और Pickup Areas
- Collectible items के लिए Area3D/Area2D radius >= 1.5 रखें
- छोटे radius को simulated input से trigger करना लगभग impossible है
### बार-बार Save करें
बड़े बदलावों के बाद `save_scene` call करें। Unsaved changes editor reload होने पर खो सकते हैं।
## Analysis और Debugging Tools
कुछ गलत होने पर, इन tools से investigate करें:
```
get_editor_errors → script errors और runtime exceptions चेक करें
get_output_log → print() output और warnings पढ़ें
analyze_scene_complexity → performance bottlenecks खोजें
analyze_signal_flow → signal connections visualize करें
detect_circular_dependencies → circular script/scene references खोजें
find_unused_resources → unused files clean up करें
get_performance_monitors → FPS, memory, draw calls, physics stats
```
## Testing और QA
```
run_test_scenario → automated test sequences define और run करें
assert_node_state → verify करें कि node properties expected values से match करती हैं
assert_screen_text → verify करें कि text screen पर display हो रहा है
compare_screenshots → visual regression testing (file paths use करें, base64 नहीं)
run_stress_test → performance test के लिए बहुत सारे nodes spawn करें
```
## Advanced Patterns
### Cross-Scene Operations
```
cross_scene_set_property → उन scenes के nodes modify करें जो अभी open नहीं हैं
find_node_references → किसी pattern को reference करने वाली सभी files खोजें
batch_set_property → किसी type के सभी nodes पर property set करें
```
### Shader Workflow
```
create_shader → GLSL-like shader code लिखें
assign_shader_material → node पर apply करें
set_shader_param → runtime पर uniforms adjust करें
get_shader_params → current values inspect करें
```
### Navigation (3D)
```
setup_navigation_region → walkable area define करें
bake_navigation_mesh → navmesh generate करें
setup_navigation_agent → characters में pathfinding जोड़ें
```
### AnimationTree और State Machines
```
create_animation_tree → state machine या blend tree के साथ AnimationTree set up करें
add_state_machine_state → states जोड़ें (idle, walk, run, jump)
add_state_machine_transition → states के बीच transitions define करें
set_tree_parameter → blend parameters control करें
```
## Recommended Workflow Order
नया game scratch से बनाते समय:
1. **Project setup**`get_project_info`, `set_project_setting` (viewport, physics)
2. **Input mapping**`set_input_action` सभी player controls के लिए
3. **Main scene**`create_scene`, main scene के रूप में set करें
4. **Player** — sprite, collision, script के साथ player scene बनाएं
5. **Level/World** — environment build करें (TileMap, 3D meshes आदि)
6. **Game logic** — enemies, items, UI के लिए scripts
7. **Audio** — buses set up करें, audio players जोड़ें
8. **Playtest**`play_scene`, simulated input से test करें, bugs fix करें
9. **Polish** — animations, particles, shaders, themes
10. **Export**`list_export_presets`, `export_project`

View File

@@ -0,0 +1,276 @@
> **Language:** [English](skills.md) | 日本語 | [Português (BR)](skills.pt-br.md) | [Español](skills.es.md) | [Русский](skills.ru.md) | [简体中文](skills.zh.md) | [हिन्दी](skills.hi.md)
# Godot MCP Pro — AIアシスタント向けスキル
> このファイルをGodotプロジェクトルートの `.claude/skills.md` にコピーすると、Claude CodeがGodot MCP Proを効果的に活用するためのコンテキストを得られます。
## Godot MCP Proとは
Godot 4エディタに直接接続する169のMCPツールを利用できます。シーンの作成、スクリプトの記述、プレイヤー入力のシミュレーション、実行中のゲームの検査など、ユーザーがこの会話から離れることなく、すべての操作が可能です。すべての変更はGodotのUndoRedoシステムを通じて行われるため、いつでもCtrl+Zで元に戻せます。
## 基本ワークフロー
### 1. プロジェクトの調査
変更を加える前に、まずプロジェクトの全体像を把握しましょう:
```
get_project_info → プロジェクト名、Godotバージョン、レンダラー、ビューポートサイズ
get_filesystem_tree → ディレクトリ構造filter: "*.tscn" や "*.gd" が使えます)
get_scene_tree → 現在開いているシーンのノード階層
read_script → 任意のGDScriptファイルを読む
get_project_settings → プロジェクト設定の確認
```
### 2. 2Dシーンの構築
```
create_scene → .tscnファイルをルートードタイプ指定で作成
add_node → プロパティ付きの子ノードを追加
create_script → ゲームロジック用のGDScriptを作成
attach_script → ノードにスクリプトをアタッチ
update_property → position、scale、modulateなどを設定
save_scene → ディスクに保存
```
**例 — プレイヤーの作成:**
1. `create_scene` でroot_type `CharacterBody2D`、path `res://scenes/player.tscn` を指定
2. `add_node` でtextureプロパティ付きの `Sprite2D` を追加
3. `add_node``CollisionShape2D` を追加
4. `add_resource` でCollisionShape2Dにシェイプ`RectangleShape2D`)を割り当て
5. `create_script` で移動ロジックを記述
6. `attach_script` でルートノードにアタッチ
7. `save_scene`
### 3. 3Dシーンの構築
```
create_scene → root_type: Node3D
add_mesh_instance → プリミティブbox、sphere、cylinder、planeの追加、または.glb/.gltfのインポート
setup_lighting → DirectionalLight3D、OmniLight3D、SpotLight3Dの追加
setup_environment → スカイ、アンビエントライト、フォグ、トーンマップ
setup_camera_3d → カメラオプションでSpringArm3Dによる三人称視点
set_material_3d → PBRマテリアルalbedo、metallic、roughness、emission
setup_collision → 物理ボディにコリジョンシェイプを追加
setup_physics_body → 質量、摩擦、重力の設定
```
### 4. スクリプトの作成と編集
```
create_script → 新規.gdファイルを作成完全な内容を提供
edit_script → 既存スクリプトを編集
- `replacements: [{search: "old code", replace: "new code"}]` で部分的な編集
- `content` でファイル全体を置換
- `insert_at_line` + `text` でコードを挿入
validate_script → 実行せずに構文エラーをチェック
read_script → 編集前に現在の内容を確認
```
### 5. プレイテストとデバッグ
```
play_scene → ゲームを起動mode: "current"、"main"、またはファイルパス)
get_game_screenshot → ゲームの現在の見た目を確認
capture_frames → 複数フレームをキャプチャして動きやアニメーションを観察
get_game_scene_tree → 実行時のシーンツリーを検査
get_game_node_properties → ランタイムの値を読み取りposition、health、stateなど
set_game_node_property → 実行中のゲームの値を変更
simulate_key → キー入力WASD、SPACEなどをduration指定で実行
simulate_mouse_click → ビューポート座標でクリック
simulate_action → InputMapアクションmove_left、jumpなどをトリガー
get_editor_errors → ランタイムエラーの確認
stop_scene → ゲームを停止
```
**プレイテストループ:**
1. `play_scene` → ゲームを開始
2. `get_game_screenshot` → 現在の状態を確認
3. `simulate_key` / `simulate_action` → ゲームを操作
4. `capture_frames` → 時間経過での挙動を観察
5. `get_game_node_properties` → 特定の値を確認
6. `stop_scene` → 完了したら停止
7. スクリプトの問題を修正 → 繰り返し
### 6. アニメーション
```
# シーンにAnimationPlayerードが存在することを確認
create_animation → 長さとループモード付きの新規アニメーション
add_animation_track → property/transform/methodトラックの追加
set_animation_keyframe → 特定時間にキーフレームを挿入
get_animation_info → 既存アニメーションの情報を取得
```
**例 — バウンドするスプライト:**
1. `create_animation` でname `bounce`、length `1.0`、loop_mode `1`(リニアループ)
2. `add_animation_track` でtrack_path `Sprite2D:position`、track_type `value`
3. `set_animation_keyframe` でtime `0.0`、value `Vector2(0, 0)`
4. `set_animation_keyframe` でtime `0.5`、value `Vector2(0, -50)`
5. `set_animation_keyframe` でtime `1.0`、value `Vector2(0, 0)`
### 7. UI / HUD
```
add_node → Control、Label、Button、TextureRectなど
set_anchor_preset → Controlの配置full_rect、center、bottom_wideなど
set_theme_color → font_colorなどの変更
set_theme_font_size → テキストサイズの調整
set_theme_stylebox → 背景、ボーダー、角丸
connect_signal → buttonのpressed、value_changedなどを接続
```
### 8. TileMap
```
tilemap_get_info → タイルセットのソースとアトラスレイアウトを確認
tilemap_set_cell → 個別タイルの配置
tilemap_fill_rect → 矩形領域を塗りつぶし
tilemap_get_used_cells → 配置済みセルの確認
tilemap_clear → 全セルをクリア
```
### 9. オーディオ
```
add_audio_bus → オーディオバスの作成SFX、Music、UI
set_audio_bus → ボリューム、ソロ、ミュートの調整
add_audio_bus_effect → リバーブ、ディレイ、コンプレッサーなどの追加
add_audio_player → AudioStreamPlayer(2D/3D)ノードの追加
```
### 10. プロジェクト設定
```
set_project_setting → ビューポートサイズ、物理設定などの変更
set_input_action → 入力マッピングの定義move_left → KEY_Aなど
add_autoload → Autoloadシングルトンの登録
set_physics_layers → コリジョンレイヤーの命名player、enemy、worldなど
```
## 重要なルールと注意点
### プロパティ値
プロパティは文字列から自動パースされます。以下のフォーマットを使用してください:
- Vector2: `"Vector2(100, 200)"`
- Vector3: `"Vector3(1, 2, 3)"`
- Color: `"Color(1, 0, 0, 1)"` または `"#ff0000"`
- Bool: `"true"` / `"false"`
- 数値: `"42"``"3.14"`
- Enum: 整数値を使用最初のenum値は `0`
### project.godotを直接編集しないこと
Godotエディタは `project.godot` を常に上書きします。プロジェクト設定の変更には必ず `set_project_setting` を使用してください。
### GDScriptの型アテーション
型なし配列に対する `for` ループでは、明示的な型アノテーションを使用してください:
```gdscript
# NG — エラーの原因になる
for item in some_untyped_array:
var x := item.value # 型推論が失敗
# OK
for i in range(some_untyped_array.size()):
var item: Dictionary = some_untyped_array[i]
var x: int = item.value
```
### スクリプト変更にはリロードが必要
スクリプトの作成や大幅な変更の後は、`reload_project` を使用してGodotに変更を反映させましょう。特に `create_script` の後は重要です。
### simulate_keyのコツ
- 精密な移動には**短いduration**0.3〜0.5秒)を使用
- 長いduration1秒以上はオーバーシュートの原因に
- ゲームプレイのテストでは、InputMapアクションが定義されている場合は `simulate_key` より `simulate_action` を推奨
### simulate_mouse_click
- デフォルトの `auto_release: true` はpressとreleaseの両方を送信 — UIボタンに必須
- UIボタンはreleaseで発火するため、両方のイベントが必要
### execute_game_scriptの制限事項
- ネストされた関数(`func` 内の `func`)は不可 — コンパイルエラーになる
- 動的アクセスには `.property` ではなく `.get("property")` を使用
- ランタイムエラーはデバッガーを一時停止させる(自動再開されるが、できれば避けること)
### コリジョンとピックアップエリア
- 収集アイテムにはArea3D/Area2Dで半径1.5以上を使用
- 小さい半径ではシミュレーション入力でのトリガーがほぼ不可能
### こまめに保存する
大きな変更を行った後は `save_scene` を呼んでください。保存していない変更はエディタのリロード時に失われる可能性があります。
## 分析とデバッグツール
問題が発生した場合、以下のツールで調査できます:
```
get_editor_errors → スクリプトエラーとランタイム例外を確認
get_output_log → print()出力と警告を読む
analyze_scene_complexity → パフォーマンスのボトルネックを特定
analyze_signal_flow → シグナル接続を可視化
detect_circular_dependencies → 循環参照するスクリプト/シーンを検出
find_unused_resources → 未使用ファイルのクリーンアップ
get_performance_monitors → FPS、メモリ、ドローコール、物理統計
```
## テストとQA
```
run_test_scenario → 自動テストシーケンスの定義と実行
assert_node_state → ノードプロパティが期待値と一致するか検証
assert_screen_text → 画面にテキストが表示されているか検証
compare_screenshots → ビジュアル回帰テストbase64ではなくファイルパスを使用
run_stress_test → 多数のノードを生成してパフォーマンスをテスト
```
## 高度なパターン
### クロスシーン操作
```
cross_scene_set_property → 現在開いていないシーンのノードを変更
find_node_references → パターンを参照しているすべてのファイルを検索
batch_set_property → 特定タイプの全ノードにプロパティを設定
```
### シェーダーワークフロー
```
create_shader → GLSL風のシェーダーコードを記述
assign_shader_material → ノードに適用
set_shader_param → 実行時にuniformを調整
get_shader_params → 現在の値を取得
```
### ナビゲーション3D
```
setup_navigation_region → 歩行可能エリアの定義
bake_navigation_mesh → ナビメッシュの生成
setup_navigation_agent → キャラクターにパスファインディングを追加
```
### AnimationTreeとステートマシン
```
create_animation_tree → ステートマシンまたはブレンドツリーでAnimationTreeをセットアップ
add_state_machine_state → ステートを追加idle、walk、run、jump
add_state_machine_transition → ステート間のトランジションを定義
set_tree_parameter → ブレンドパラメータを制御
```
## 推奨ワークフロー順序
ゲームをゼロから構築する場合の推奨順序:
1. **プロジェクトセットアップ**`get_project_info``set_project_setting`(ビューポート、物理)
2. **入力マッピング**`set_input_action` で全プレイヤー操作を定義
3. **メインシーン**`create_scene` でメインシーンとして設定
4. **プレイヤー** — スプライト、コリジョン、スクリプト付きのプレイヤーシーンを作成
5. **レベル/ワールド** — 環境を構築TileMap、3Dメッシュなど
6. **ゲームロジック** — 敵、アイテム、UIのスクリプト
7. **オーディオ** — バスのセットアップ、オーディオプレイヤーの追加
8. **プレイテスト**`play_scene` でシミュレーション入力によるテスト、バグ修正
9. **ポリッシュ** — アニメーション、パーティクル、シェーダー、テーマ
10. **エクスポート**`list_export_presets``export_project`

View File

@@ -0,0 +1,295 @@
> **Language:** English | [日本語](skills.ja.md) | [Português (BR)](skills.pt-br.md) | [Español](skills.es.md) | [Русский](skills.ru.md) | [简体中文](skills.zh.md) | [हिन्दी](skills.hi.md)
# Godot MCP Pro — Skills for AI Assistants
> Copy this file to `.claude/skills.md` in your Godot project root to give Claude Code full context on how to use Godot MCP Pro effectively.
## What is Godot MCP Pro?
You have access to 169 MCP tools that connect directly to the Godot 4 editor. You can create scenes, write scripts, simulate player input, inspect running games, and more — all without the user leaving this conversation. Every change goes through Godot's UndoRedo system, so the user can always Ctrl+Z.
## Essential Workflows
### 1. Explore a Project
Always start by understanding the project before making changes:
```
get_project_info → project name, Godot version, renderer, viewport size
get_filesystem_tree → directory structure (use filter: "*.tscn" or "*.gd")
get_scene_tree → node hierarchy of the currently open scene
read_script → read any GDScript file
get_project_settings → check project configuration
```
### 2. Build a 2D Scene
```
create_scene → create .tscn file with root node type
add_node → add child nodes with properties
create_script → write GDScript for game logic
attach_script → attach script to a node
update_property → set position, scale, modulate, etc.
save_scene → save to disk
```
**Example — creating a player:**
1. `create_scene` with root_type `CharacterBody2D`, path `res://scenes/player.tscn`
2. `add_node` type `Sprite2D` with texture property
3. `add_node` type `CollisionShape2D`
4. `add_resource` to assign a shape (e.g., `RectangleShape2D`) to the CollisionShape2D
5. `create_script` with movement logic
6. `attach_script` to the root node
7. `save_scene`
### 3. Build a 3D Scene
```
create_scene → root_type: Node3D
add_mesh_instance → add primitives (box, sphere, cylinder, plane) or import .glb/.gltf
setup_lighting → add DirectionalLight3D, OmniLight3D, or SpotLight3D
setup_environment → sky, ambient light, fog, tonemap
setup_camera_3d → camera with optional SpringArm3D for third-person
set_material_3d → PBR materials (albedo, metallic, roughness, emission)
setup_collision → add collision shapes to physics bodies
setup_physics_body → configure mass, friction, gravity
```
### 4. Write & Edit Scripts
```
create_script → create new .gd file (provide full content)
edit_script → modify existing scripts
- Use `replacements: [{search: "old code", replace: "new code"}]` for targeted edits
- Use `content` for full file replacement
- Use `insert_at_line` + `text` for inserting code
validate_script → check for syntax errors without running
read_script → read current content before editing
```
### 5. Playtest & Debug
```
play_scene → launch the game (mode: "current", "main", or file path)
get_game_screenshot → see what the game looks like right now
capture_frames → capture multiple frames to observe motion/animation
get_game_scene_tree → inspect the live scene tree at runtime
get_game_node_properties → read runtime values (position, health, state, etc.)
set_game_node_property → modify values in the running game
simulate_key → press keys (WASD, SPACE, etc.) with duration
simulate_mouse_click → click at viewport coordinates
simulate_action → trigger InputMap actions (move_left, jump, etc.)
get_editor_errors → check for runtime errors
stop_scene → stop the game
```
**Playtesting loop:**
1. `play_scene` → start the game
2. `get_game_screenshot` → see current state
3. `simulate_key` / `simulate_action` → interact with the game
4. `capture_frames` → observe behavior over time
5. `get_game_node_properties` → check specific values
6. `stop_scene` → stop when done
7. Fix issues in scripts → repeat
### 6. Animations
```
# Ensure an AnimationPlayer node exists in the scene
create_animation → new animation with length and loop mode
add_animation_track → add property/transform/method tracks
set_animation_keyframe → insert keyframes at specific times
get_animation_info → inspect existing animations
```
**Example — bouncing sprite:**
1. `create_animation` name `bounce`, length `1.0`, loop_mode `1` (linear loop)
2. `add_animation_track` track_path `Sprite2D:position`, track_type `value`
3. `set_animation_keyframe` time `0.0`, value `Vector2(0, 0)`
4. `set_animation_keyframe` time `0.5`, value `Vector2(0, -50)`
5. `set_animation_keyframe` time `1.0`, value `Vector2(0, 0)`
### 7. UI / HUD
```
add_node → Control, Label, Button, TextureRect, etc.
set_anchor_preset → position Controls (full_rect, center, bottom_wide, etc.)
set_theme_color → change font_color, etc.
set_theme_font_size → adjust text size
set_theme_stylebox → backgrounds, borders, rounded corners
connect_signal → wire up button pressed, value_changed, etc.
```
### 8. TileMap
```
tilemap_get_info → check tile set sources and atlas layout
tilemap_set_cell → place individual tiles
tilemap_fill_rect → fill rectangular regions
tilemap_get_used_cells → see what's already placed
tilemap_clear → clear all cells
```
### 9. Audio
```
add_audio_bus → create audio buses (SFX, Music, UI)
set_audio_bus → adjust volume, solo, mute
add_audio_bus_effect → add reverb, delay, compressor, etc.
add_audio_player → add AudioStreamPlayer(2D/3D) nodes
```
### 10. Project Configuration
```
set_project_setting → change viewport size, physics settings, etc.
set_input_action → define input mappings (move_left → KEY_A, etc.)
add_autoload → register autoload singletons
set_physics_layers → name collision layers (player, enemy, world, etc.)
```
## Important Rules & Pitfalls
### Prefer Inspector Properties Over Code
When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak by hand. Only write GDScript when the property isn't available in the inspector or needs to be dynamic at runtime.
### Property Values
Properties are auto-parsed from strings. Use these formats:
- Vector2: `"Vector2(100, 200)"`
- Vector3: `"Vector3(1, 2, 3)"`
- Color: `"Color(1, 0, 0, 1)"` or `"#ff0000"`
- Bool: `"true"` / `"false"`
- Numbers: `"42"`, `"3.14"`
- Enums: Use integer values (e.g., `0` for the first enum value)
### Never Edit project.godot Directly
Godot editor constantly overwrites `project.godot`. Always use `set_project_setting` to change project settings.
### GDScript Type Annotations
When writing GDScript with `for` loops over untyped arrays, use explicit type annotations:
```gdscript
# BAD — will cause errors
for item in some_untyped_array:
var x := item.value # type inference fails
# GOOD
for i in range(some_untyped_array.size()):
var item: Dictionary = some_untyped_array[i]
var x: int = item.value
```
### Script Changes Need Reload
After creating or significantly modifying scripts, use `reload_project` to ensure Godot picks up the changes. This is especially important after `create_script`.
### simulate_key Tips
- Use **short durations** (0.30.5 seconds) for precise movement
- Long durations (1+ seconds) cause overshooting
- For gameplay testing, prefer `simulate_action` over `simulate_key` when InputMap actions are defined
### simulate_mouse_click
- Default `auto_release: true` sends both press and release — required for UI buttons
- UI buttons fire on release, so both events are needed
### execute_game_script Limitations
- No nested functions (`func` inside `func`) — causes compile error
- Use `.get("property")` instead of `.property` for dynamic access
- Runtime errors will pause the debugger (auto-continued, but avoid if possible)
### Collision & Pickup Areas
- For collectible items, use Area3D/Area2D with radius ≥ 1.5
- Smaller radii are nearly impossible to trigger with simulated input
### Save Frequently
Call `save_scene` after making significant changes. Unsaved changes can be lost if the editor reloads.
## Analysis & Debugging Tools
When something goes wrong, use these tools to investigate:
```
get_editor_errors → check for script errors and runtime exceptions
get_output_log → read print() output and warnings
analyze_scene_complexity → find performance bottlenecks
analyze_signal_flow → visualize signal connections
detect_circular_dependencies → find circular script/scene references
find_unused_resources → clean up unused files
get_performance_monitors → FPS, memory, draw calls, physics stats
```
## Testing & QA
```
run_test_scenario → define and run automated test sequences
assert_node_state → verify node properties match expected values
assert_screen_text → verify text is displayed on screen
compare_screenshots → visual regression testing (use file paths, not base64)
run_stress_test → spawn many nodes to test performance
```
## Advanced Patterns
### Cross-Scene Operations
```
cross_scene_set_property → modify nodes in scenes that aren't currently open
find_node_references → find all files referencing a pattern
batch_set_property → set a property on all nodes of a type
```
### Shader Workflow
```
create_shader → write GLSL-like shader code
assign_shader_material → apply to a node
set_shader_param → adjust uniforms at runtime
get_shader_params → inspect current values
```
### Navigation (3D)
```
setup_navigation_region → define walkable area
bake_navigation_mesh → generate navmesh
setup_navigation_agent → add pathfinding to characters
```
### AnimationTree & State Machines
```
create_animation_tree → set up AnimationTree with state machine or blend tree
add_state_machine_state → add states (idle, walk, run, jump)
add_state_machine_transition → define transitions between states
set_tree_parameter → control blend parameters
```
### Code-to-Inspector Migration
Move hardcoded visual properties from GDScript to the inspector for easier tweaking:
```
read_script → find hardcoded property assignments
get_node_properties → check current inspector values
update_property → set values as node properties
edit_script → remove hardcoded lines from script
save_scene → persist inspector changes
validate_script → verify script still compiles
```
Example — a script sets `modulate = Color(1, 0, 0, 1)` in `_ready()`:
1. `read_script` to find the line
2. `update_property` with `node_path`, `property: "modulate"`, `value: "Color(1, 0, 0, 1)"`
3. `edit_script` to remove the `modulate = ...` line from `_ready()`
4. `save_scene` + `validate_script`
This applies to: colors, positions, sizes, theme overrides, material properties, visibility, margins, anchors, and any property that doesn't need to change at runtime.
## Recommended Workflow Order
When building a new game from scratch:
1. **Project setup**`get_project_info`, `set_project_setting` (viewport, physics)
2. **Input mapping**`set_input_action` for all player controls
3. **Main scene**`create_scene`, set as main scene
4. **Player** — create player scene with sprite, collision, script
5. **Level/World** — build environment (TileMap, 3D meshes, etc.)
6. **Game logic** — scripts for enemies, items, UI
7. **Audio** — set up buses, add audio players
8. **Playtest**`play_scene`, test with simulated input, fix bugs
9. **Polish** — animations, particles, shaders, themes
10. **Export**`list_export_presets`, `export_project`

View File

@@ -0,0 +1,271 @@
> **Language:** [English](skills.md) | [日本語](skills.ja.md) | Português (BR) | [Español](skills.es.md) | [Русский](skills.ru.md) | [简体中文](skills.zh.md) | [हिन्दी](skills.hi.md)
# Godot MCP Pro — Skills para Assistentes de IA
> Copie este arquivo para `.claude/skills.md` na raiz do seu projeto Godot para dar ao Claude Code o contexto completo de como usar o Godot MCP Pro de forma eficiente.
## O que é o Godot MCP Pro?
Você tem acesso a 169 ferramentas MCP que se conectam diretamente ao editor Godot 4. Você pode criar cenas, escrever scripts, simular entrada do jogador, inspecionar jogos em execução e muito mais — tudo sem que o usuário precise sair desta conversa. Todas as alterações passam pelo sistema UndoRedo do Godot, então o usuário pode sempre usar Ctrl+Z.
## Fluxos de Trabalho Essenciais
### 1. Explorar um Projeto
Sempre comece entendendo o projeto antes de fazer alterações:
```
get_project_info → nome do projeto, versão do Godot, renderizador, tamanho do viewport
get_filesystem_tree → estrutura de diretórios (use filter: "*.tscn" ou "*.gd")
get_scene_tree → hierarquia de nós da cena atualmente aberta
read_script → ler qualquer arquivo GDScript
get_project_settings → verificar configuração do projeto
```
### 2. Construir uma Cena 2D
```
create_scene → criar arquivo .tscn com tipo de nó raiz
add_node → adicionar nós filhos com propriedades
create_script → escrever GDScript para lógica do jogo
attach_script → anexar script a um nó
update_property → definir position, scale, modulate, etc.
save_scene → salvar no disco
```
**Exemplo — criando um jogador:**
1. `create_scene` com root_type `CharacterBody2D`, path `res://scenes/player.tscn`
2. `add_node` tipo `Sprite2D` com propriedade texture
3. `add_node` tipo `CollisionShape2D`
4. `add_resource` para atribuir uma shape (ex: `RectangleShape2D`) ao CollisionShape2D
5. `create_script` com lógica de movimento
6. `attach_script` ao nó raiz
7. `save_scene`
### 3. Construir uma Cena 3D
```
create_scene → root_type: Node3D
add_mesh_instance → adicionar primitivas (box, sphere, cylinder, plane) ou importar .glb/.gltf
setup_lighting → adicionar DirectionalLight3D, OmniLight3D ou SpotLight3D
setup_environment → céu, luz ambiente, neblina, tonemap
setup_camera_3d → câmera com SpringArm3D opcional para terceira pessoa
set_material_3d → materiais PBR (albedo, metallic, roughness, emission)
setup_collision → adicionar shapes de colisão a corpos físicos
setup_physics_body → configurar massa, atrito, gravidade
```
### 4. Escrever e Editar Scripts
```
create_script → criar novo arquivo .gd (forneça o conteúdo completo)
edit_script → modificar scripts existentes
- Use `replacements: [{search: "old code", replace: "new code"}]` para edições pontuais
- Use `content` para substituição completa do arquivo
- Use `insert_at_line` + `text` para inserir código
validate_script → verificar erros de sintaxe sem executar
read_script → ler conteúdo atual antes de editar
```
### 5. Testar e Depurar
```
play_scene → iniciar o jogo (mode: "current", "main" ou caminho do arquivo)
get_game_screenshot → ver como o jogo está neste momento
capture_frames → capturar múltiplos frames para observar movimento/animação
get_game_scene_tree → inspecionar a árvore de cena em tempo de execução
get_game_node_properties → ler valores em runtime (position, health, state, etc.)
set_game_node_property → modificar valores no jogo em execução
simulate_key → pressionar teclas (WASD, SPACE, etc.) com duração
simulate_mouse_click → clicar em coordenadas do viewport
simulate_action → disparar ações do InputMap (move_left, jump, etc.)
get_editor_errors → verificar erros de execução
stop_scene → parar o jogo
```
**Loop de playtesting:**
1. `play_scene` → iniciar o jogo
2. `get_game_screenshot` → ver estado atual
3. `simulate_key` / `simulate_action` → interagir com o jogo
4. `capture_frames` → observar comportamento ao longo do tempo
5. `get_game_node_properties` → verificar valores específicos
6. `stop_scene` → parar quando terminar
7. Corrigir problemas nos scripts → repetir
### 6. Animações
```
# Certifique-se de que existe um nó AnimationPlayer na cena
create_animation → nova animação com duração e modo de loop
add_animation_track → adicionar tracks de property/transform/method
set_animation_keyframe → inserir keyframes em tempos específicos
get_animation_info → inspecionar animações existentes
```
**Exemplo — sprite quicando:**
1. `create_animation` name `bounce`, length `1.0`, loop_mode `1` (loop linear)
2. `add_animation_track` track_path `Sprite2D:position`, track_type `value`
3. `set_animation_keyframe` time `0.0`, value `Vector2(0, 0)`
4. `set_animation_keyframe` time `0.5`, value `Vector2(0, -50)`
5. `set_animation_keyframe` time `1.0`, value `Vector2(0, 0)`
### 7. UI / HUD
```
add_node → Control, Label, Button, TextureRect, etc.
set_anchor_preset → posicionar Controls (full_rect, center, bottom_wide, etc.)
set_theme_color → alterar font_color, etc.
set_theme_font_size → ajustar tamanho do texto
set_theme_stylebox → fundos, bordas, cantos arredondados
connect_signal → conectar pressed do button, value_changed, etc.
```
### 8. TileMap
```
tilemap_get_info → verificar fontes do tile set e layout do atlas
tilemap_set_cell → colocar tiles individuais
tilemap_fill_rect → preencher regiões retangulares
tilemap_get_used_cells → ver o que já está colocado
tilemap_clear → limpar todas as células
```
### 9. Áudio
```
add_audio_bus → criar buses de áudio (SFX, Music, UI)
set_audio_bus → ajustar volume, solo, mute
add_audio_bus_effect → adicionar reverb, delay, compressor, etc.
add_audio_player → adicionar nós AudioStreamPlayer(2D/3D)
```
### 10. Configuração do Projeto
```
set_project_setting → alterar tamanho do viewport, configurações de física, etc.
set_input_action → definir mapeamentos de entrada (move_left → KEY_A, etc.)
add_autoload → registrar singletons autoload
set_physics_layers → nomear camadas de colisão (player, enemy, world, etc.)
```
## Regras Importantes e Armadilhas
### Valores de Propriedade
Propriedades são parseadas automaticamente de strings. Use estes formatos:
- Vector2: `"Vector2(100, 200)"`
- Vector3: `"Vector3(1, 2, 3)"`
- Color: `"Color(1, 0, 0, 1)"` ou `"#ff0000"`
- Bool: `"true"` / `"false"`
- Números: `"42"`, `"3.14"`
- Enums: Use valores inteiros (ex: `0` para o primeiro valor do enum)
### Nunca Edite project.godot Diretamente
O editor Godot sobrescreve `project.godot` constantemente. Sempre use `set_project_setting` para alterar configurações do projeto.
### Anotações de Tipo em GDScript
Ao escrever GDScript com loops `for` sobre arrays sem tipo, use anotações de tipo explícitas:
```gdscript
# RUIM — vai causar erros
for item in some_untyped_array:
var x := item.value # inferência de tipo falha
# BOM
for i in range(some_untyped_array.size()):
var item: Dictionary = some_untyped_array[i]
var x: int = item.value
```
### Alterações em Scripts Precisam de Reload
Após criar ou modificar scripts significativamente, use `reload_project` para garantir que o Godot reconheça as alterações. Isso é especialmente importante após `create_script`.
### Dicas para simulate_key
- Use **durações curtas** (0.30.5 segundos) para movimentos precisos
- Durações longas (1+ segundo) causam overshooting
- Para testes de gameplay, prefira `simulate_action` em vez de `simulate_key` quando ações do InputMap estiverem definidas
### simulate_mouse_click
- O padrão `auto_release: true` envia press e release — necessário para botões de UI
- Botões de UI disparam no release, então ambos os eventos são necessários
### Limitações do execute_game_script
- Sem funções aninhadas (`func` dentro de `func`) — causa erro de compilação
- Use `.get("property")` em vez de `.property` para acesso dinâmico
- Erros de runtime pausam o debugger (continuado automaticamente, mas evite se possível)
### Colisão e Áreas de Coleta
- Para itens coletáveis, use Area3D/Area2D com raio >= 1.5
- Raios menores são quase impossíveis de acionar com entrada simulada
### Salve com Frequência
Chame `save_scene` após fazer alterações significativas. Alterações não salvas podem ser perdidas se o editor recarregar.
## Ferramentas de Análise e Depuração
Quando algo der errado, use estas ferramentas para investigar:
```
get_editor_errors → verificar erros de script e exceções de runtime
get_output_log → ler saída de print() e avisos
analyze_scene_complexity → encontrar gargalos de performance
analyze_signal_flow → visualizar conexões de signals
detect_circular_dependencies → encontrar referências circulares de script/cena
find_unused_resources → limpar arquivos não utilizados
get_performance_monitors → FPS, memória, draw calls, estatísticas de física
```
## Testes e QA
```
run_test_scenario → definir e executar sequências de teste automatizadas
assert_node_state → verificar se propriedades de nós correspondem aos valores esperados
assert_screen_text → verificar se texto está exibido na tela
compare_screenshots → teste de regressão visual (use caminhos de arquivo, não base64)
run_stress_test → gerar muitos nós para testar performance
```
## Padrões Avançados
### Operações entre Cenas
```
cross_scene_set_property → modificar nós em cenas que não estão abertas atualmente
find_node_references → encontrar todos os arquivos que referenciam um padrão
batch_set_property → definir uma propriedade em todos os nós de um tipo
```
### Fluxo de Trabalho com Shaders
```
create_shader → escrever código shader estilo GLSL
assign_shader_material → aplicar a um nó
set_shader_param → ajustar uniforms em runtime
get_shader_params → inspecionar valores atuais
```
### Navegação (3D)
```
setup_navigation_region → definir área caminhável
bake_navigation_mesh → gerar navmesh
setup_navigation_agent → adicionar pathfinding a personagens
```
### AnimationTree e Máquinas de Estado
```
create_animation_tree → configurar AnimationTree com máquina de estado ou blend tree
add_state_machine_state → adicionar estados (idle, walk, run, jump)
add_state_machine_transition → definir transições entre estados
set_tree_parameter → controlar parâmetros de blend
```
## Ordem de Fluxo de Trabalho Recomendada
Ao construir um novo jogo do zero:
1. **Configuração do projeto**`get_project_info`, `set_project_setting` (viewport, física)
2. **Mapeamento de entrada**`set_input_action` para todos os controles do jogador
3. **Cena principal**`create_scene`, definir como cena principal
4. **Jogador** — criar cena do jogador com sprite, colisão, script
5. **Nível/Mundo** — construir o ambiente (TileMap, meshes 3D, etc.)
6. **Lógica do jogo** — scripts para inimigos, itens, UI
7. **Áudio** — configurar buses, adicionar audio players
8. **Playtesting**`play_scene`, testar com entrada simulada, corrigir bugs
9. **Polimento** — animações, partículas, shaders, temas
10. **Exportação**`list_export_presets`, `export_project`

View File

@@ -0,0 +1,271 @@
> **Language:** [English](skills.md) | [日本語](skills.ja.md) | [Português (BR)](skills.pt-br.md) | [Español](skills.es.md) | Русский | [简体中文](skills.zh.md) | [हिन्दी](skills.hi.md)
# Godot MCP Pro — Навыки для ИИ-ассистентов
> Скопируйте этот файл в `.claude/skills.md` в корне вашего проекта Godot, чтобы дать Claude Code полный контекст по эффективному использованию Godot MCP Pro.
## Что такое Godot MCP Pro?
Вам доступны 169 MCP-инструмента, которые напрямую подключаются к редактору Godot 4. Вы можете создавать сцены, писать скрипты, симулировать ввод игрока, инспектировать запущенные игры и многое другое — всё это без выхода пользователя из данного разговора. Все изменения проходят через систему UndoRedo Godot, поэтому пользователь всегда может нажать Ctrl+Z.
## Основные рабочие процессы
### 1. Изучение проекта
Всегда начинайте с понимания проекта перед внесением изменений:
```
get_project_info → название проекта, версия Godot, рендерер, размер viewport
get_filesystem_tree → структура директорий (используйте filter: "*.tscn" или "*.gd")
get_scene_tree → иерархия нод текущей открытой сцены
read_script → прочитать любой файл GDScript
get_project_settings → проверить конфигурацию проекта
```
### 2. Создание 2D-сцены
```
create_scene → создать файл .tscn с указанием типа корневой ноды
add_node → добавить дочерние ноды со свойствами
create_script → написать GDScript для игровой логики
attach_script → прикрепить скрипт к ноде
update_property → установить position, scale, modulate и т.д.
save_scene → сохранить на диск
```
**Пример — создание игрока:**
1. `create_scene` с root_type `CharacterBody2D`, path `res://scenes/player.tscn`
2. `add_node` типа `Sprite2D` со свойством texture
3. `add_node` типа `CollisionShape2D`
4. `add_resource` для назначения формы (например, `RectangleShape2D`) на CollisionShape2D
5. `create_script` с логикой движения
6. `attach_script` на корневую ноду
7. `save_scene`
### 3. Создание 3D-сцены
```
create_scene → root_type: Node3D
add_mesh_instance → добавить примитивы (box, sphere, cylinder, plane) или импортировать .glb/.gltf
setup_lighting → добавить DirectionalLight3D, OmniLight3D или SpotLight3D
setup_environment → небо, окружающий свет, туман, tonemap
setup_camera_3d → камера с опциональным SpringArm3D для вида от третьего лица
set_material_3d → PBR-материалы (albedo, metallic, roughness, emission)
setup_collision → добавить формы столкновений к физическим телам
setup_physics_body → настроить массу, трение, гравитацию
```
### 4. Написание и редактирование скриптов
```
create_script → создать новый файл .gd (укажите полное содержимое)
edit_script → изменить существующие скрипты
- Используйте `replacements: [{search: "old code", replace: "new code"}]` для точечных правок
- Используйте `content` для полной замены файла
- Используйте `insert_at_line` + `text` для вставки кода
validate_script → проверить синтаксические ошибки без запуска
read_script → прочитать текущее содержимое перед редактированием
```
### 5. Тестирование и отладка
```
play_scene → запустить игру (mode: "current", "main" или путь к файлу)
get_game_screenshot → увидеть, как игра выглядит прямо сейчас
capture_frames → захватить несколько кадров для наблюдения за движением/анимацией
get_game_scene_tree → инспектировать дерево сцены в runtime
get_game_node_properties → прочитать значения в runtime (position, health, state и т.д.)
set_game_node_property → изменить значения в запущенной игре
simulate_key → нажать клавиши (WASD, SPACE и т.д.) с указанием длительности
simulate_mouse_click → кликнуть по координатам viewport
simulate_action → вызвать действия InputMap (move_left, jump и т.д.)
get_editor_errors → проверить ошибки выполнения
stop_scene → остановить игру
```
**Цикл плейтестинга:**
1. `play_scene` → запустить игру
2. `get_game_screenshot` → увидеть текущее состояние
3. `simulate_key` / `simulate_action` → взаимодействовать с игрой
4. `capture_frames` → наблюдать поведение во времени
5. `get_game_node_properties` → проверить конкретные значения
6. `stop_scene` → остановить по завершении
7. Исправить проблемы в скриптах → повторить
### 6. Анимации
```
# Убедитесь, что в сцене есть нода AnimationPlayer
create_animation → новая анимация с длительностью и режимом зацикливания
add_animation_track → добавить треки property/transform/method
set_animation_keyframe → вставить ключевые кадры в определённые моменты
get_animation_info → просмотреть существующие анимации
```
**Пример — прыгающий спрайт:**
1. `create_animation` name `bounce`, length `1.0`, loop_mode `1` (линейный цикл)
2. `add_animation_track` track_path `Sprite2D:position`, track_type `value`
3. `set_animation_keyframe` time `0.0`, value `Vector2(0, 0)`
4. `set_animation_keyframe` time `0.5`, value `Vector2(0, -50)`
5. `set_animation_keyframe` time `1.0`, value `Vector2(0, 0)`
### 7. UI / HUD
```
add_node → Control, Label, Button, TextureRect и т.д.
set_anchor_preset → позиционирование Controls (full_rect, center, bottom_wide и т.д.)
set_theme_color → изменить font_color и т.д.
set_theme_font_size → настроить размер текста
set_theme_stylebox → фоны, рамки, скруглённые углы
connect_signal → подключить pressed кнопки, value_changed и т.д.
```
### 8. TileMap
```
tilemap_get_info → проверить источники набора тайлов и раскладку атласа
tilemap_set_cell → разместить отдельные тайлы
tilemap_fill_rect → заполнить прямоугольные области
tilemap_get_used_cells → посмотреть, что уже размещено
tilemap_clear → очистить все ячейки
```
### 9. Аудио
```
add_audio_bus → создать аудио-шины (SFX, Music, UI)
set_audio_bus → настроить громкость, соло, заглушение
add_audio_bus_effect → добавить реверберацию, задержку, компрессор и т.д.
add_audio_player → добавить ноды AudioStreamPlayer(2D/3D)
```
### 10. Конфигурация проекта
```
set_project_setting → изменить размер viewport, настройки физики и т.д.
set_input_action → определить маппинг ввода (move_left → KEY_A и т.д.)
add_autoload → зарегистрировать синглтоны autoload
set_physics_layers → именовать слои столкновений (player, enemy, world и т.д.)
```
## Важные правила и подводные камни
### Значения свойств
Свойства автоматически парсятся из строк. Используйте следующие форматы:
- Vector2: `"Vector2(100, 200)"`
- Vector3: `"Vector3(1, 2, 3)"`
- Color: `"Color(1, 0, 0, 1)"` или `"#ff0000"`
- Bool: `"true"` / `"false"`
- Числа: `"42"`, `"3.14"`
- Enum: Используйте целочисленные значения (например, `0` для первого значения enum)
### Никогда не редактируйте project.godot напрямую
Редактор Godot постоянно перезаписывает `project.godot`. Всегда используйте `set_project_setting` для изменения настроек проекта.
### Аннотации типов в GDScript
При написании GDScript с циклами `for` по нетипизированным массивам используйте явные аннотации типов:
```gdscript
# ПЛОХО — приведёт к ошибкам
for item in some_untyped_array:
var x := item.value # вывод типов не работает
# ХОРОШО
for i in range(some_untyped_array.size()):
var item: Dictionary = some_untyped_array[i]
var x: int = item.value
```
### Изменения скриптов требуют перезагрузки
После создания или значительного изменения скриптов используйте `reload_project`, чтобы Godot подхватил изменения. Особенно важно после `create_script`.
### Советы по simulate_key
- Используйте **короткие длительности** (0.30.5 секунд) для точного перемещения
- Длинные длительности (1+ секунда) приводят к промахам
- Для тестирования геймплея предпочитайте `simulate_action` вместо `simulate_key`, когда определены действия InputMap
### simulate_mouse_click
- По умолчанию `auto_release: true` отправляет press и release — необходимо для UI-кнопок
- UI-кнопки срабатывают на release, поэтому нужны оба события
### Ограничения execute_game_script
- Нельзя использовать вложенные функции (`func` внутри `func`) — вызывает ошибку компиляции
- Используйте `.get("property")` вместо `.property` для динамического доступа
- Ошибки выполнения приостанавливают отладчик (автоматически продолжается, но лучше избегать)
### Коллизии и области подбора
- Для собираемых предметов используйте Area3D/Area2D с радиусом >= 1.5
- Меньшие радиусы почти невозможно активировать симулированным вводом
### Сохраняйте часто
Вызывайте `save_scene` после значительных изменений. Несохранённые изменения могут быть потеряны при перезагрузке редактора.
## Инструменты анализа и отладки
Когда что-то пошло не так, используйте эти инструменты для расследования:
```
get_editor_errors → проверить ошибки скриптов и исключения runtime
get_output_log → прочитать вывод print() и предупреждения
analyze_scene_complexity → найти узкие места производительности
analyze_signal_flow → визуализировать соединения сигналов
detect_circular_dependencies → найти циклические ссылки скриптов/сцен
find_unused_resources → очистить неиспользуемые файлы
get_performance_monitors → FPS, память, draw calls, статистика физики
```
## Тестирование и QA
```
run_test_scenario → определить и запустить автоматизированные тестовые сценарии
assert_node_state → проверить, что свойства нод соответствуют ожидаемым значениям
assert_screen_text → проверить, что текст отображается на экране
compare_screenshots → визуальное регрессионное тестирование (используйте пути к файлам, не base64)
run_stress_test → создать множество нод для тестирования производительности
```
## Продвинутые паттерны
### Операции между сценами
```
cross_scene_set_property → изменить ноды в сценах, которые сейчас не открыты
find_node_references → найти все файлы, ссылающиеся на паттерн
batch_set_property → установить свойство для всех нод определённого типа
```
### Работа с шейдерами
```
create_shader → написать шейдерный код в стиле GLSL
assign_shader_material → применить к ноде
set_shader_param → настроить uniform-параметры в runtime
get_shader_params → просмотреть текущие значения
```
### Навигация (3D)
```
setup_navigation_region → определить проходимую область
bake_navigation_mesh → сгенерировать навигационную сетку
setup_navigation_agent → добавить поиск пути для персонажей
```
### AnimationTree и конечные автоматы
```
create_animation_tree → настроить AnimationTree с конечным автоматом или деревом смешивания
add_state_machine_state → добавить состояния (idle, walk, run, jump)
add_state_machine_transition → определить переходы между состояниями
set_tree_parameter → управлять параметрами смешивания
```
## Рекомендуемый порядок работы
При создании новой игры с нуля:
1. **Настройка проекта**`get_project_info`, `set_project_setting` (viewport, физика)
2. **Маппинг ввода**`set_input_action` для всех управлений игрока
3. **Главная сцена**`create_scene`, установить как главную сцену
4. **Игрок** — создать сцену игрока со спрайтом, коллизией, скриптом
5. **Уровень/Мир** — построить окружение (TileMap, 3D-меши и т.д.)
6. **Игровая логика** — скрипты для врагов, предметов, UI
7. **Аудио** — настроить шины, добавить аудиоплееры
8. **Плейтестинг**`play_scene`, тест с симулированным вводом, исправление багов
9. **Полировка** — анимации, частицы, шейдеры, темы
10. **Экспорт**`list_export_presets`, `export_project`

View File

@@ -0,0 +1,271 @@
> **Language:** [English](skills.md) | [日本語](skills.ja.md) | [Português (BR)](skills.pt-br.md) | [Español](skills.es.md) | [Русский](skills.ru.md) | 简体中文 | [हिन्दी](skills.hi.md)
# Godot MCP Pro — AI 助手技能指南
> 将此文件复制到 Godot 项目根目录的 `.claude/skills.md`,以便 Claude Code 获得如何高效使用 Godot MCP Pro 的完整上下文。
## 什么是 Godot MCP Pro
你可以使用 169 个 MCP 工具直接连接 Godot 4 编辑器。你可以创建场景、编写脚本、模拟玩家输入、检查运行中的游戏等等——所有操作都无需用户离开当前对话。每次更改都通过 Godot 的 UndoRedo 系统进行,因此用户随时可以 Ctrl+Z 撤销。
## 核心工作流
### 1. 探索项目
在进行更改之前,先了解项目全貌:
```
get_project_info → 项目名称、Godot 版本、渲染器、视口大小
get_filesystem_tree → 目录结构(可使用 filter: "*.tscn" 或 "*.gd"
get_scene_tree → 当前打开场景的节点层级
read_script → 读取任意 GDScript 文件
get_project_settings → 检查项目配置
```
### 2. 构建 2D 场景
```
create_scene → 创建 .tscn 文件并指定根节点类型
add_node → 添加带属性的子节点
create_script → 编写游戏逻辑的 GDScript
attach_script → 将脚本附加到节点
update_property → 设置 position、scale、modulate 等
save_scene → 保存到磁盘
```
**示例——创建玩家:**
1. `create_scene` 设置 root_type 为 `CharacterBody2D`path 为 `res://scenes/player.tscn`
2. `add_node` 类型 `Sprite2D` 并设置 texture 属性
3. `add_node` 类型 `CollisionShape2D`
4. `add_resource` 为 CollisionShape2D 分配形状(如 `RectangleShape2D`
5. `create_script` 编写移动逻辑
6. `attach_script` 附加到根节点
7. `save_scene`
### 3. 构建 3D 场景
```
create_scene → root_type: Node3D
add_mesh_instance → 添加基础体box、sphere、cylinder、plane或导入 .glb/.gltf
setup_lighting → 添加 DirectionalLight3D、OmniLight3D 或 SpotLight3D
setup_environment → 天空、环境光、雾、色调映射
setup_camera_3d → 摄像机(可选 SpringArm3D 实现第三人称视角)
set_material_3d → PBR 材质albedo、metallic、roughness、emission
setup_collision → 为物理体添加碰撞形状
setup_physics_body → 配置质量、摩擦力、重力
```
### 4. 编写和编辑脚本
```
create_script → 创建新的 .gd 文件(提供完整内容)
edit_script → 修改现有脚本
- 使用 `replacements: [{search: "old code", replace: "new code"}]` 进行定向编辑
- 使用 `content` 完整替换文件
- 使用 `insert_at_line` + `text` 插入代码
validate_script → 不运行即可检查语法错误
read_script → 编辑前读取当前内容
```
### 5. 测试与调试
```
play_scene → 启动游戏mode: "current"、"main" 或文件路径)
get_game_screenshot → 查看游戏当前画面
capture_frames → 捕获多帧以观察运动/动画
get_game_scene_tree → 检查运行时的场景树
get_game_node_properties → 读取运行时数值position、health、state 等)
set_game_node_property → 修改运行中游戏的数值
simulate_key → 按键WASD、SPACE 等)并指定持续时间
simulate_mouse_click → 在视口坐标处点击
simulate_action → 触发 InputMap 动作move_left、jump 等)
get_editor_errors → 检查运行时错误
stop_scene → 停止游戏
```
**测试循环:**
1. `play_scene` → 启动游戏
2. `get_game_screenshot` → 查看当前状态
3. `simulate_key` / `simulate_action` → 与游戏交互
4. `capture_frames` → 观察一段时间内的行为
5. `get_game_node_properties` → 检查特定数值
6. `stop_scene` → 完成后停止
7. 修复脚本问题 → 重复
### 6. 动画
```
# 确保场景中存在 AnimationPlayer 节点
create_animation → 创建带时长和循环模式的新动画
add_animation_track → 添加 property/transform/method 轨道
set_animation_keyframe → 在特定时间插入关键帧
get_animation_info → 查看现有动画信息
```
**示例——弹跳精灵:**
1. `create_animation` name 为 `bounce`length 为 `1.0`loop_mode 为 `1`(线性循环)
2. `add_animation_track` track_path 为 `Sprite2D:position`track_type 为 `value`
3. `set_animation_keyframe` time 为 `0.0`value 为 `Vector2(0, 0)`
4. `set_animation_keyframe` time 为 `0.5`value 为 `Vector2(0, -50)`
5. `set_animation_keyframe` time 为 `1.0`value 为 `Vector2(0, 0)`
### 7. UI / HUD
```
add_node → Control、Label、Button、TextureRect 等
set_anchor_preset → 定位 Controlfull_rect、center、bottom_wide 等)
set_theme_color → 修改 font_color 等
set_theme_font_size → 调整文字大小
set_theme_stylebox → 背景、边框、圆角
connect_signal → 连接 button 的 pressed、value_changed 等信号
```
### 8. TileMap
```
tilemap_get_info → 检查图块集来源和图集布局
tilemap_set_cell → 放置单个图块
tilemap_fill_rect → 填充矩形区域
tilemap_get_used_cells → 查看已放置的内容
tilemap_clear → 清除所有单元格
```
### 9. 音频
```
add_audio_bus → 创建音频总线SFX、Music、UI
set_audio_bus → 调整音量、独奏、静音
add_audio_bus_effect → 添加混响、延迟、压缩器等
add_audio_player → 添加 AudioStreamPlayer(2D/3D) 节点
```
### 10. 项目配置
```
set_project_setting → 修改视口大小、物理设置等
set_input_action → 定义输入映射move_left → KEY_A 等)
add_autoload → 注册 autoload 单例
set_physics_layers → 命名碰撞层player、enemy、world 等)
```
## 重要规则与注意事项
### 属性值
属性会从字符串自动解析。使用以下格式:
- Vector2: `"Vector2(100, 200)"`
- Vector3: `"Vector3(1, 2, 3)"`
- Color: `"Color(1, 0, 0, 1)"``"#ff0000"`
- Bool: `"true"` / `"false"`
- 数字: `"42"``"3.14"`
- 枚举: 使用整数值(例如第一个枚举值用 `0`
### 不要直接编辑 project.godot
Godot 编辑器会不断覆盖 `project.godot`。修改项目设置请务必使用 `set_project_setting`
### GDScript 类型注解
在对无类型数组使用 `for` 循环时,请使用显式类型注解:
```gdscript
# 错误——会导致报错
for item in some_untyped_array:
var x := item.value # 类型推断失败
# 正确
for i in range(some_untyped_array.size()):
var item: Dictionary = some_untyped_array[i]
var x: int = item.value
```
### 脚本更改需要重新加载
创建或大幅修改脚本后,使用 `reload_project` 确保 Godot 识别更改。在 `create_script` 之后尤其重要。
### simulate_key 技巧
- 精确移动使用**短持续时间**0.3-0.5 秒)
- 长持续时间1 秒以上)会导致过冲
- 游戏测试时,如果已定义 InputMap 动作,优先使用 `simulate_action` 而非 `simulate_key`
### simulate_mouse_click
- 默认 `auto_release: true` 会同时发送按下和释放事件——UI 按钮必须如此
- UI 按钮在释放时触发,因此两个事件都必不可少
### execute_game_script 限制
- 不支持嵌套函数(`func` 中的 `func`)——会导致编译错误
- 动态访问请使用 `.get("property")` 而非 `.property`
- 运行时错误会暂停调试器(会自动继续,但尽量避免)
### 碰撞与拾取区域
- 可收集物品请使用 Area3D/Area2D 并设置半径 >= 1.5
- 较小的半径几乎无法通过模拟输入触发
### 经常保存
进行重大更改后请调用 `save_scene`。未保存的更改可能在编辑器重新加载时丢失。
## 分析与调试工具
出现问题时,使用以下工具进行排查:
```
get_editor_errors → 检查脚本错误和运行时异常
get_output_log → 读取 print() 输出和警告
analyze_scene_complexity → 查找性能瓶颈
analyze_signal_flow → 可视化信号连接
detect_circular_dependencies → 查找脚本/场景的循环引用
find_unused_resources → 清理未使用的文件
get_performance_monitors → FPS、内存、绘制调用、物理统计
```
## 测试与 QA
```
run_test_scenario → 定义并运行自动化测试序列
assert_node_state → 验证节点属性是否匹配预期值
assert_screen_text → 验证文本是否显示在屏幕上
compare_screenshots → 视觉回归测试(使用文件路径,不要用 base64
run_stress_test → 生成大量节点以测试性能
```
## 高级模式
### 跨场景操作
```
cross_scene_set_property → 修改当前未打开场景中的节点
find_node_references → 查找引用某个模式的所有文件
batch_set_property → 为某类型的所有节点设置属性
```
### 着色器工作流
```
create_shader → 编写类 GLSL 的着色器代码
assign_shader_material → 应用到节点
set_shader_param → 在运行时调整 uniform 参数
get_shader_params → 查看当前值
```
### 导航3D
```
setup_navigation_region → 定义可行走区域
bake_navigation_mesh → 生成导航网格
setup_navigation_agent → 为角色添加寻路功能
```
### AnimationTree 与状态机
```
create_animation_tree → 使用状态机或混合树设置 AnimationTree
add_state_machine_state → 添加状态idle、walk、run、jump
add_state_machine_transition → 定义状态之间的过渡
set_tree_parameter → 控制混合参数
```
## 推荐工作流顺序
从零开始构建新游戏时:
1. **项目设置**`get_project_info``set_project_setting`(视口、物理)
2. **输入映射**`set_input_action` 定义所有玩家控制
3. **主场景**`create_scene`,设为主场景
4. **玩家** — 创建包含精灵、碰撞、脚本的玩家场景
5. **关卡/世界** — 构建环境TileMap、3D 网格等)
6. **游戏逻辑** — 敌人、道具、UI 的脚本
7. **音频** — 设置总线、添加音频播放器
8. **测试**`play_scene`,使用模拟输入测试,修复 bug
9. **打磨** — 动画、粒子、着色器、主题
10. **导出**`list_export_presets``export_project`

View File

@@ -0,0 +1,382 @@
@tool
extends VBoxContainer
var websocket_server: Node = null
var command_router: Node = null
const MAX_LOG_ENTRIES := 200
const COLOR_CONNECTED := Color(0.2, 0.9, 0.2)
const COLOR_DISCONNECTED := Color(0.9, 0.2, 0.2)
const COLOR_STALE := Color(1.0, 0.7, 0.2)
const COLOR_SUCCESS := Color(0.6, 1, 0.6)
const COLOR_ERROR := Color(1, 0.6, 0.6)
const COLOR_DIM := Color(0.6, 0.6, 0.6)
const BASE_PORT := 6505
const MAX_PORT := 6509
# Header
var _status_icon: Label
var _status_label: Label
var _client_count_label: Label
# Tabs
var _tab_container: TabContainer
# Activity tab
var _show_details_check: CheckBox
var _log_container: VBoxContainer
var _log_scroll: ScrollContainer
# Clients tab
var _port_labels: Dictionary = {} # port -> {icon: Label, label: Label}
# Tools tab
var _filter_edit: LineEdit
var _tools_container: VBoxContainer
var _tool_checkboxes: Dictionary = {} # method_name -> CheckBox
func _ready() -> void:
_build_ui()
func setup(ws_server: Node, cmd_router: Node = null) -> void:
websocket_server = ws_server
command_router = cmd_router
if websocket_server:
websocket_server.client_connected.connect(_on_client_connected)
websocket_server.client_disconnected.connect(_on_client_disconnected)
if websocket_server.has_signal("command_completed"):
websocket_server.command_completed.connect(_on_command_completed)
else:
websocket_server.command_executed.connect(_on_command_executed)
if command_router:
_populate_tools_list()
func _build_ui() -> void:
# Header bar
var header := HBoxContainer.new()
add_child(header)
_status_icon = Label.new()
_status_icon.text = ""
_status_icon.add_theme_color_override("font_color", COLOR_DISCONNECTED)
header.add_child(_status_icon)
_status_label = Label.new()
_status_label.text = " MCP Pro: Waiting for connection..."
header.add_child(_status_label)
var spacer := Control.new()
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
header.add_child(spacer)
_client_count_label = Label.new()
_client_count_label.text = "Clients: 0"
header.add_child(_client_count_label)
# Separator
var sep := HSeparator.new()
add_child(sep)
# TabContainer
_tab_container = TabContainer.new()
_tab_container.size_flags_vertical = Control.SIZE_EXPAND_FILL
add_child(_tab_container)
_build_activity_tab()
_build_clients_tab()
_build_tools_tab()
func _build_activity_tab() -> void:
var vbox := VBoxContainer.new()
vbox.name = "Activity"
vbox.size_flags_vertical = Control.SIZE_EXPAND_FILL
_tab_container.add_child(vbox)
# Controls row
var controls := HBoxContainer.new()
vbox.add_child(controls)
_show_details_check = CheckBox.new()
_show_details_check.text = "Show Response Details"
_show_details_check.button_pressed = false
_show_details_check.toggled.connect(_on_show_details_toggled)
controls.add_child(_show_details_check)
var ctrl_spacer := Control.new()
ctrl_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
controls.add_child(ctrl_spacer)
var clear_btn := Button.new()
clear_btn.text = "Clear"
clear_btn.pressed.connect(_on_clear_log)
controls.add_child(clear_btn)
# Log scroll
_log_scroll = ScrollContainer.new()
_log_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
_log_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_log_scroll.custom_minimum_size.y = 80
vbox.add_child(_log_scroll)
_log_container = VBoxContainer.new()
_log_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_log_scroll.add_child(_log_container)
func _build_clients_tab() -> void:
var vbox := VBoxContainer.new()
vbox.name = "Clients"
_tab_container.add_child(vbox)
for p in range(BASE_PORT, MAX_PORT + 1):
var row := HBoxContainer.new()
vbox.add_child(row)
var icon := Label.new()
icon.text = ""
icon.add_theme_color_override("font_color", COLOR_DISCONNECTED)
row.add_child(icon)
var lbl := Label.new()
lbl.text = " Port %d — Disconnected" % p
row.add_child(lbl)
_port_labels[p] = {"icon": icon, "label": lbl}
func _build_tools_tab() -> void:
var vbox := VBoxContainer.new()
vbox.name = "Tools"
vbox.size_flags_vertical = Control.SIZE_EXPAND_FILL
_tab_container.add_child(vbox)
# Controls
var controls := HBoxContainer.new()
vbox.add_child(controls)
_filter_edit = LineEdit.new()
_filter_edit.placeholder_text = "Filter tools..."
_filter_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_filter_edit.text_changed.connect(_on_filter_changed)
controls.add_child(_filter_edit)
var enable_all_btn := Button.new()
enable_all_btn.text = "Enable All"
enable_all_btn.pressed.connect(_on_enable_all)
controls.add_child(enable_all_btn)
var disable_all_btn := Button.new()
disable_all_btn.text = "Disable All"
disable_all_btn.pressed.connect(_on_disable_all)
controls.add_child(disable_all_btn)
# Scroll
var scroll := ScrollContainer.new()
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
scroll.custom_minimum_size.y = 80
vbox.add_child(scroll)
_tools_container = VBoxContainer.new()
_tools_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL
scroll.add_child(_tools_container)
func _populate_tools_list() -> void:
if not command_router:
return
# Clear existing
for child in _tools_container.get_children():
child.queue_free()
_tool_checkboxes.clear()
var methods: Array = command_router.get_available_methods()
methods.sort()
for method_name: String in methods:
var cb := CheckBox.new()
cb.text = method_name
cb.button_pressed = not command_router.is_tool_disabled(method_name)
cb.toggled.connect(_on_tool_toggled.bind(method_name))
_tools_container.add_child(cb)
_tool_checkboxes[method_name] = cb
func _process(_delta: float) -> void:
if not websocket_server:
return
var count: int = websocket_server.get_client_count()
_client_count_label.text = "Clients: %d" % count
var any_stale := false
if websocket_server.has_method("is_port_stale"):
for p in range(BASE_PORT, MAX_PORT + 1):
if websocket_server.is_port_stale(p):
any_stale = true
break
if count > 0:
_status_icon.add_theme_color_override("font_color", COLOR_CONNECTED)
_status_label.text = " MCP Pro: Connected"
elif any_stale:
_status_icon.add_theme_color_override("font_color", COLOR_STALE)
_status_label.text = " MCP Pro: ⚠ Reconnecting (stale connection detected)..."
else:
_status_icon.add_theme_color_override("font_color", COLOR_DISCONNECTED)
_status_label.text = " MCP Pro: Waiting for connection..."
# Update clients tab
_update_clients_tab()
func _update_clients_tab() -> void:
var connected_ports: Array[int] = []
if websocket_server.has_method("get_connected_ports"):
connected_ports = websocket_server.get_connected_ports()
for p: int in _port_labels:
var info: Dictionary = _port_labels[p]
var icon: Label = info["icon"]
var lbl: Label = info["label"]
var is_stale := false
if websocket_server.has_method("is_port_stale"):
is_stale = websocket_server.is_port_stale(p)
if p in connected_ports:
var time_str := ""
if websocket_server.has_method("get_port_connect_time"):
var elapsed: float = websocket_server.get_port_connect_time(p)
if elapsed >= 0:
var mins := int(elapsed) / 60
var secs := int(elapsed) % 60
time_str = " (%dm %02ds)" % [mins, secs]
var idle_str := ""
if websocket_server.has_method("get_port_idle_time"):
var idle: float = websocket_server.get_port_idle_time(p)
if idle >= 2.0:
idle_str = " · idle %.0fs" % idle
icon.text = ""
icon.add_theme_color_override("font_color", COLOR_CONNECTED)
lbl.text = " Port %d — Connected%s%s" % [p, time_str, idle_str]
elif is_stale:
icon.text = ""
icon.add_theme_color_override("font_color", COLOR_STALE)
lbl.text = " Port %d — ⚠ Stale (reconnecting)" % p
else:
icon.text = ""
icon.add_theme_color_override("font_color", COLOR_DISCONNECTED)
lbl.text = " Port %d — Disconnected" % p
# --- Activity callbacks ---
func _on_client_connected() -> void:
_add_log("Client connected", COLOR_CONNECTED)
func _on_client_disconnected() -> void:
_add_log("Client disconnected", COLOR_DISCONNECTED)
func _on_command_executed(method: String, ok: bool) -> void:
var color := COLOR_SUCCESS if ok else COLOR_ERROR
var status_icon := "OK" if ok else "ERR"
_add_log("[%s] %s" % [status_icon, method], color)
func _on_command_completed(method: String, ok: bool, response: String, source_port: int) -> void:
var color := COLOR_SUCCESS if ok else COLOR_ERROR
var status_icon := "OK" if ok else "ERR"
_add_log("[%s] %s (port %d)" % [status_icon, method, source_port], color, response)
func _on_clear_log() -> void:
for child in _log_container.get_children():
child.queue_free()
func _on_show_details_toggled(on: bool) -> void:
for entry in _log_container.get_children():
if entry is VBoxContainer and entry.get_child_count() > 1:
entry.get_child(1).visible = on
func _add_log(text: String, color: Color = Color.WHITE, response: String = "") -> void:
if _log_container == null:
return
var entry := VBoxContainer.new()
_log_container.add_child(entry)
var label := Label.new()
var time_str := Time.get_time_string_from_system()
label.text = "[%s] %s" % [time_str, text]
label.add_theme_color_override("font_color", color)
label.add_theme_font_size_override("font_size", 12)
entry.add_child(label)
if not response.is_empty():
var detail := RichTextLabel.new()
var preview := response.substr(0, 500)
if response.length() > 500:
preview += "..."
detail.text = preview
detail.fit_content = true
detail.scroll_active = false
detail.add_theme_color_override("default_color", COLOR_DIM)
detail.add_theme_font_size_override("normal_font_size", 11)
detail.custom_minimum_size.y = 0
detail.visible = _show_details_check.button_pressed if _show_details_check else false
entry.add_child(detail)
# Limit entries
while _log_container.get_child_count() > MAX_LOG_ENTRIES:
var old: Node = _log_container.get_child(0)
_log_container.remove_child(old)
old.queue_free()
# Auto scroll to bottom
_auto_scroll.call_deferred()
func _auto_scroll() -> void:
if _log_scroll:
_log_scroll.scroll_vertical = int(_log_scroll.get_v_scroll_bar().max_value)
# --- Tools callbacks ---
func _on_filter_changed(filter: String) -> void:
for method_name: String in _tool_checkboxes:
var cb: CheckBox = _tool_checkboxes[method_name]
cb.visible = filter.is_empty() or method_name.containsn(filter)
func _on_tool_toggled(enabled: bool, method_name: String) -> void:
if command_router and command_router.has_method("set_tool_disabled"):
command_router.set_tool_disabled(method_name, not enabled)
func _on_enable_all() -> void:
if command_router and command_router.has_method("set_all_tools_disabled"):
command_router.set_all_tools_disabled(false)
for method_name: String in _tool_checkboxes:
_tool_checkboxes[method_name].set_pressed_no_signal(true)
func _on_disable_all() -> void:
if command_router and command_router.has_method("set_all_tools_disabled"):
command_router.set_all_tools_disabled(true)
for method_name: String in _tool_checkboxes:
_tool_checkboxes[method_name].set_pressed_no_signal(false)

View File

@@ -0,0 +1,8 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://addons/godot_mcp/ui/status_panel.gd" id="1"]
[node name="MCPStatusPanel" type="VBoxContainer"]
offset_right = 600.0
offset_bottom = 300.0
script = ExtResource("1")

View File

@@ -0,0 +1,76 @@
@tool
extends RefCounted
## Recursively set owner for all children (needed when adding nodes via code)
static func set_owner_recursive(node: Node, owner: Node) -> void:
for child in node.get_children():
child.owner = owner
set_owner_recursive(child, owner)
## Get a simplified tree structure from a node
static func get_node_tree(node: Node, max_depth: int = -1, current_depth: int = 0) -> Dictionary:
var result := {
"name": node.name,
"type": node.get_class(),
"path": str(node.get_path()),
}
# Add script info
var script: Script = node.get_script()
if script:
result["script"] = script.resource_path
# Add children
if max_depth == -1 or current_depth < max_depth:
var children: Array = []
for child in node.get_children():
children.append(get_node_tree(child, max_depth, current_depth + 1))
if not children.is_empty():
result["children"] = children
return result
## Get all properties of a node as a serializable dictionary
static func get_node_properties_dict(node: Node) -> Dictionary:
var PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd")
var result: Dictionary = {}
var property_list := node.get_property_list()
for prop_info in property_list:
var prop_name: String = prop_info["name"]
var usage: int = prop_info["usage"]
# Only include user-facing properties (PROPERTY_USAGE_EDITOR)
if not (usage & PROPERTY_USAGE_EDITOR):
continue
# Skip internal/meta properties
if prop_name.begins_with("_") or prop_name in ["script"]:
continue
var value: Variant = node.get(prop_name)
result[prop_name] = PropertyParser.serialize_value(value)
return result
## Duplicate a node and all its children, properly setting owners
static func duplicate_node_in_scene(node: Node, new_name: String, root: Node) -> Node:
var dup := node.duplicate()
dup.name = new_name
node.get_parent().add_child(dup)
dup.owner = root
set_owner_recursive(dup, root)
return dup
## Find node by class type in subtree
static func find_nodes_by_type(root: Node, type_name: String) -> Array[Node]:
var result: Array[Node] = []
if root.get_class() == type_name or root.is_class(type_name):
result.append(root)
for child in root.get_children():
result.append_array(find_nodes_by_type(child, type_name))
return result

View File

@@ -0,0 +1,203 @@
@tool
extends RefCounted
## Parse a string value into the appropriate Godot type
static func parse_value(value: Variant, target_type: int = TYPE_NIL) -> Variant:
if value == null:
return null
# If already the correct type, return as-is
if target_type == TYPE_NIL:
return _auto_parse(value)
match target_type:
TYPE_BOOL:
if value is bool: return value
if value is String: return value.to_lower() in ["true", "1", "yes"]
return bool(value)
TYPE_INT:
return int(value)
TYPE_FLOAT:
return float(value)
TYPE_STRING:
return str(value)
TYPE_VECTOR2:
return _parse_vector2(value)
TYPE_VECTOR2I:
return _parse_vector2i(value)
TYPE_VECTOR3:
return _parse_vector3(value)
TYPE_VECTOR3I:
return _parse_vector3i(value)
TYPE_RECT2:
return _parse_rect2(value)
TYPE_COLOR:
return _parse_color(value)
TYPE_NODE_PATH:
return NodePath(str(value))
TYPE_ARRAY:
if value is Array: return value
return [value]
TYPE_DICTIONARY:
if value is Dictionary: return value
return {}
_:
return value
static func _auto_parse(value: Variant) -> Variant:
if not value is String:
return value
var s: String = value
# Boolean
if s == "true": return true
if s == "false": return false
# Integer
if s.is_valid_int(): return s.to_int()
# Float
if s.is_valid_float(): return s.to_float()
# Vector2: "Vector2(x, y)" or "(x, y)" or "x, y"
if s.begins_with("Vector2(") or s.begins_with("Vector2i("):
return _parse_vector2(s)
# Vector3
if s.begins_with("Vector3(") or s.begins_with("Vector3i("):
return _parse_vector3(s)
# Color
if s.begins_with("Color(") or s.begins_with("#"):
return _parse_color(s)
# Rect2
if s.begins_with("Rect2("):
return _parse_rect2(s)
return s
static func _extract_numbers(s: String) -> PackedFloat64Array:
# Remove type prefix and parentheses
var cleaned := s
for prefix in ["Vector3i(", "Vector3(", "Vector2i(", "Vector2(", "Rect2(", "Color(", "("]:
if cleaned.begins_with(prefix):
cleaned = cleaned.substr(prefix.length())
break
cleaned = cleaned.trim_suffix(")")
cleaned = cleaned.strip_edges()
var parts := cleaned.split(",")
var numbers: PackedFloat64Array = []
for part in parts:
numbers.append(part.strip_edges().to_float())
return numbers
static func _parse_vector2(value: Variant) -> Vector2:
if value is Vector2: return value
if value is Dictionary:
return Vector2(float(value.get("x", 0)), float(value.get("y", 0)))
var nums := _extract_numbers(str(value))
if nums.size() >= 2:
return Vector2(nums[0], nums[1])
return Vector2.ZERO
static func _parse_vector2i(value: Variant) -> Vector2i:
var v := _parse_vector2(value)
return Vector2i(int(v.x), int(v.y))
static func _parse_vector3(value: Variant) -> Vector3:
if value is Vector3: return value
if value is Dictionary:
return Vector3(float(value.get("x", 0)), float(value.get("y", 0)), float(value.get("z", 0)))
var nums := _extract_numbers(str(value))
if nums.size() >= 3:
return Vector3(nums[0], nums[1], nums[2])
return Vector3.ZERO
static func _parse_vector3i(value: Variant) -> Vector3i:
var v := _parse_vector3(value)
return Vector3i(int(v.x), int(v.y), int(v.z))
static func _parse_rect2(value: Variant) -> Rect2:
if value is Rect2: return value
if value is Dictionary:
return Rect2(
float(value.get("x", 0)), float(value.get("y", 0)),
float(value.get("w", value.get("width", 0))),
float(value.get("h", value.get("height", 0)))
)
var nums := _extract_numbers(str(value))
if nums.size() >= 4:
return Rect2(nums[0], nums[1], nums[2], nums[3])
return Rect2()
static func _parse_color(value: Variant) -> Color:
if value is Color: return value
var s := str(value)
if s.begins_with("#"):
return Color.html(s)
if s.begins_with("Color("):
var nums := _extract_numbers(s)
match nums.size():
3: return Color(nums[0], nums[1], nums[2])
4: return Color(nums[0], nums[1], nums[2], nums[3])
# Try named color
if Color.html_is_valid(s):
return Color.html(s)
return Color.WHITE
## Serialize a Variant to JSON-safe representation
static func serialize_value(value: Variant) -> Variant:
if value == null:
return null
match typeof(value):
TYPE_VECTOR2:
var v: Vector2 = value
return {"x": v.x, "y": v.y}
TYPE_VECTOR2I:
var v: Vector2i = value
return {"x": v.x, "y": v.y}
TYPE_VECTOR3:
var v: Vector3 = value
return {"x": v.x, "y": v.y, "z": v.z}
TYPE_VECTOR3I:
var v: Vector3i = value
return {"x": v.x, "y": v.y, "z": v.z}
TYPE_RECT2:
var r: Rect2 = value
return {"x": r.position.x, "y": r.position.y, "width": r.size.x, "height": r.size.y}
TYPE_COLOR:
var c: Color = value
return {"r": c.r, "g": c.g, "b": c.b, "a": c.a, "html": "#" + c.to_html()}
TYPE_NODE_PATH:
return str(value)
TYPE_OBJECT:
if value is Resource:
var res: Resource = value
return {"type": res.get_class(), "path": res.resource_path}
return str(value)
TYPE_ARRAY:
var arr: Array = value
var result: Array = []
for item in arr:
result.append(serialize_value(item))
return result
TYPE_DICTIONARY:
var dict: Dictionary = value
var result: Dictionary = {}
for key in dict:
result[str(key)] = serialize_value(dict[key])
return result
_:
return value

View File

@@ -0,0 +1,254 @@
@tool
extends Node
## Multi-connection WebSocket client.
## Connects to multiple Node.js MCP server instances on ports 6505-6514.
## Each Claude Code session gets its own port; Godot talks to all of them.
## Ports 6505-6509: MCP servers (stdio), 6510-6514: CLI tool connections.
signal client_connected()
signal client_disconnected()
signal message_received(text: String)
signal command_executed(method: String, success: bool)
signal command_completed(method: String, success: bool, response: String, source_port: int)
var command_router: Node
const BASE_PORT := 6505
const MAX_PORT := 6514
const RECONNECT_INTERVAL := 3.0
const BUFFER_SIZE := 16 * 1024 * 1024 # 16MB
const PING_INTERVAL := 5.0 # send ping every N seconds while connected
const INACTIVITY_TIMEOUT := 30.0 # force-close if no message received for N seconds
# Per-port connection state
var _peers: Dictionary = {} # port -> WebSocketPeer
var _connected: Dictionary = {} # port -> bool
var _timers: Dictionary = {} # port -> float (reconnect countdown)
var _connect_times: Dictionary = {} # port -> float (elapsed seconds since connect)
var _last_activity: Dictionary = {} # port -> float (seconds since last received message)
var _ping_timers: Dictionary = {} # port -> float (seconds since last sent ping)
var _stale_ports: Dictionary = {} # port -> bool (heartbeat timeout flag, exposed to UI)
var _running: bool = false
func start_server() -> void:
_running = true
for p in range(BASE_PORT, MAX_PORT + 1):
_connected[p] = false
_timers[p] = 0.0
_try_connect(p)
print("[MCP] Connecting to ports %d-%d" % [BASE_PORT, MAX_PORT])
func stop_server() -> void:
_running = false
for p in _peers:
var ws: WebSocketPeer = _peers[p]
if ws:
ws.close(1000, "Plugin shutting down")
_peers.clear()
_connected.clear()
_timers.clear()
_last_activity.clear()
_ping_timers.clear()
_stale_ports.clear()
print("[MCP] WebSocket client stopped")
func get_client_count() -> int:
var count: int = 0
for p in _connected:
if _connected[p]:
count += 1
return count
func get_connected_ports() -> Array[int]:
var ports: Array[int] = []
for p: int in _connected:
if _connected[p]:
ports.append(p)
return ports
func get_port_connect_time(port: int) -> float:
return _connect_times.get(port, -1.0)
func get_port_idle_time(port: int) -> float:
return _last_activity.get(port, -1.0)
func is_port_stale(port: int) -> bool:
return _stale_ports.get(port, false)
func _try_connect(p: int) -> void:
var ws := WebSocketPeer.new()
ws.outbound_buffer_size = BUFFER_SIZE
ws.inbound_buffer_size = BUFFER_SIZE
var err := ws.connect_to_url("ws://127.0.0.1:%d" % p)
if err == OK:
_peers[p] = ws
else:
_peers[p] = null
func _process(delta: float) -> void:
if not _running:
return
for p in range(BASE_PORT, MAX_PORT + 1):
var ws: WebSocketPeer = _peers.get(p)
# No peer - try reconnect on timer
if ws == null:
_timers[p] = _timers.get(p, 0.0) + delta
if _timers[p] >= RECONNECT_INTERVAL:
_timers[p] = 0.0
_try_connect(p)
continue
ws.poll()
var state := ws.get_ready_state()
match state:
WebSocketPeer.STATE_OPEN:
if not _connected.get(p, false):
_connected[p] = true
_connect_times[p] = 0.0
_last_activity[p] = 0.0
_ping_timers[p] = 0.0
_stale_ports[p] = false
_timers[p] = 0.0
print_verbose("[MCP] Connected on port %d" % p)
client_connected.emit()
else:
_connect_times[p] = _connect_times.get(p, 0.0) + delta
_last_activity[p] = _last_activity.get(p, 0.0) + delta
_ping_timers[p] = _ping_timers.get(p, 0.0) + delta
var received_any := false
while ws.get_available_packet_count() > 0:
var packet := ws.get_packet()
var text := packet.get_string_from_utf8()
received_any = true
_dispatch_message(text, p)
if received_any:
_last_activity[p] = 0.0
if _stale_ports.get(p, false):
_stale_ports[p] = false
print("[MCP] Port %d recovered from stale state" % p)
# Force-close if no message received for INACTIVITY_TIMEOUT.
# The MCP server pings every 10s, so 30s of silence means the
# connection is half-open and reconnect is the only way out.
if _last_activity.get(p, 0.0) > INACTIVITY_TIMEOUT:
push_warning("[MCP] Port %d silent for %.1fs — forcing reconnect" % [p, _last_activity[p]])
_stale_ports[p] = true
ws.close(4000, "Heartbeat timeout")
_connected[p] = false
_peers[p] = null
_timers[p] = 0.0
client_disconnected.emit()
continue
# Send periodic ping so the server can detect our death too,
# and so any reply resets our own inactivity timer.
if _ping_timers.get(p, 0.0) >= PING_INTERVAL:
_ping_timers[p] = 0.0
ws.send_text(JSON.stringify({"jsonrpc": "2.0", "method": "ping", "params": {}}))
WebSocketPeer.STATE_CLOSING:
pass
WebSocketPeer.STATE_CLOSED:
if _connected.get(p, false):
_connected[p] = false
print_verbose("[MCP] Disconnected from port %d" % p)
client_disconnected.emit()
_peers[p] = null
_timers[p] = 0.0
_last_activity[p] = 0.0
_ping_timers[p] = 0.0
WebSocketPeer.STATE_CONNECTING:
pass
func _send_to_port(p: int, text: String) -> void:
var ws: WebSocketPeer = _peers.get(p)
if ws and _connected.get(p, false):
ws.send_text(text)
func send_message(text: String) -> void:
# Broadcast to all connected peers
for p in _peers:
_send_to_port(p, text)
## Synchronous dispatch - parse JSON, handle ping/pong, queue command execution
func _dispatch_message(text: String, source_port: int) -> void:
message_received.emit(text)
var json := JSON.new()
var err := json.parse(text)
if err != OK:
_send_response(source_port, null, null, {"code": -32700, "message": "Parse error"})
return
var msg: Variant = json.data
if not msg is Dictionary:
_send_response(source_port, null, null, {"code": -32600, "message": "Invalid request"})
return
var msg_dict: Dictionary = msg
if msg_dict.get("method") == "ping":
_send_to_port(source_port, JSON.stringify({"jsonrpc": "2.0", "method": "pong", "params": {}}))
return
if msg_dict.get("method") == "pong":
return
var id: Variant = msg_dict.get("id")
var method: String = msg_dict.get("method", "")
var params: Dictionary = msg_dict.get("params", {})
if method.is_empty():
_send_response(source_port, id, null, {"code": -32600, "message": "Missing method"})
return
if not command_router:
_send_response(source_port, id, null, {"code": -32603, "message": "No command router"})
return
_execute_command.call_deferred(source_port, id, method, params)
func _execute_command(source_port: int, id: Variant, method: String, params: Dictionary) -> void:
var cmd_result: Dictionary = await command_router.execute(method, params)
if cmd_result.has("error"):
var err_data: Variant = cmd_result["error"]
_send_response(source_port, id, null, err_data)
var response_text := JSON.stringify(err_data)
command_executed.emit(method, false)
command_completed.emit(method, false, response_text, source_port)
else:
var result_data: Variant = cmd_result.get("result", {})
_send_response(source_port, id, result_data, null)
var response_text := JSON.stringify(result_data)
command_executed.emit(method, true)
command_completed.emit(method, true, response_text, source_port)
func _send_response(source_port: int, id: Variant, result: Variant, err: Variant) -> void:
var response: Dictionary = {"jsonrpc": "2.0", "id": id}
if err != null:
response["error"] = err
else:
response["result"] = result if result != null else {}
_send_to_port(source_port, JSON.stringify(response))