@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, })