Skip to content

Commit cff4c3f

Browse files
dsarnoclaude
andauthored
harden(handlers): route every path-taking handler through McpPathValidator (#546)
* harden(handlers): route every path-taking handler through McpPathValidator The strong traversal validator from #347 was wired into only filesystem and script handlers; ~12 other path-taking handlers used a bare begins_with("res://") check (which does not reject "..") or no check at all (relying solely on ResourceLoader.exists/load). This unifies all of them on McpPathValidator and extends the validator with two checks. Validator (utils/path_validator.gd): - Reject embedded null bytes (truncation trap — the path written could differ from the one validated). - New for_write flag: write callers additionally refuse res://project.godot, the res://.godot/ metadata dir, and .import sidecars (overwriting these corrupts the project / import cache). Reads still permit inspecting them. Signature is backward-compatible (for_write defaults to false). Writers now validate with for_write=true (closes the traversal-write primitive): resource_io.save_to_disk (backs resource/environment/texture/curve saves), scene create_scene/save_scene_as, material/theme save helpers, filesystem write_text, script create/patch. Load/read sites now validate (closes the unvalidated-load surface, incl. the @tool-script-execution risk from open_scene/create_node): resource load/assign + nested object-property loads, scene open_scene, script attach_script, node create_node scene_path + set_property resource values, audio set_stream + list root, material assign/shader/list, ui theme + stylebox, autoload path, curve set_points, particle mesh/ material/texture, material_values.load_texture. Path-validation rejections report VALUE_OUT_OF_RANGE (the code these handlers already used for "must start with res://"), keeping the INVALID_PARAMS catch-all count under the audit-v2 #21 ceiling enforced by test_error_code_distribution. The four pre-existing validator sites that already wrapped errors as INVALID_PARAMS (filesystem, script create/read/patch/find, resource_io save, material _validate_material_path) are left unchanged. set_stream and the other load handlers now reject user:// (consistent with the validator's existing test_user_prefix_rejected policy); the audio test fixture moves from user:// to a res:// .tres registered via EditorFileSystem.update_file. Tests (run against live Godot 4.6.3): validator unit tests for null-byte + write blocklist + read-allows; scene traversal/manifest-overwrite rejection. All affected suites pass (path_validator/scene/resource/node/material/theme/ curve/particle/ui/audio/autoload/filesystem/script). test_error_code_distribution passes. Parse check clean. Addresses advisory GHSA-p5x8-v25q-qw69 (GH-1, GH-2, GH-3, GH-4). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * harden(handlers): address review — uid:// loads, case-fold blocklist, shared helper Follow-up to the McpPathValidator unification, fixing the code-review findings. Correctness: - Restore uid:// and user:// support on load handlers. ResourceLoader accepts both (uid:// is an opaque resource id that can't express traversal; user:// is the user data sandbox), but routing every load through the strict res:// validator rejected them — a regression for uid:// refs copied from .tscn/.uid and for user:// runtime assets. New validate_loadable_path() accepts res:// (confined), uid:// (as-is), and user:// (confined under the user root), and load sites now use it: set_property/assign_resource/resource property dicts, open_scene, create_node, attach_script, set_stream, particle mesh/material/ texture, material assign/shader, ui theme/stylebox, curve, material_values. - Case-fold the write blocklist. macOS (APFS) and Windows (NTFS) are case-insensitive by default, so res://Project.godot resolved to the real project.godot and slipped past a case-sensitive compare. - Add override.cfg to the write blocklist (applied over project.godot at startup — same takeover surface as the manifest). - Reads no longer run the write blocklist: _validate_material_path / _validate_res_path / _load_material_from_path / _load_theme_from_params take for_write, passed true only by the create/save callers. get_material and apply_theme (pure reads) no longer return a spurious "Refusing to write". - Stop blocking .import writes — editing import config then reimporting is a legitimate, recoverable workflow; the blocklist is the startup-execution surface (manifest, override.cfg, .godot/) only. Cleanup / altitude: - Single error-code choke point: McpPathValidator.path_error / loadable_error return a ready error dict (MISSING_REQUIRED_PARAM for empty, VALUE_OUT_OF_RANGE for invalid) so every handler reports the same code for the same failure. filesystem/script move off their old INVALID_PARAMS wrapping onto this. - curve set_points validates the load path (the save layer, resource_io.save_to_disk, remains the authoritative write-confinement check) instead of double-running the write validator. - Drop the redundant is_empty() pre-check in material_values.load_texture. - Document the lexical-containment (no symlink resolution) limitation in the validator — GDScript has no realpath; the loopback boundary is the control. Tests (live Godot 4.6.3, all suites 0 failures): uid:// / user:// acceptance, user:// traversal + unknown-scheme rejection, case-insensitive blocklist, override.cfg block, .import now allowed; audio fixture moved back to user:// (no repo/EFS pollution). test_path_traversal_guard counts any McpPathValidator delegation; missing-path tests now assert MISSING_REQUIRED_PARAM. Addresses review findings 1-10 on GHSA-p5x8-v25q-qw69 (path confinement). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * harden(handlers): address Copilot review on path validation - Guard the null-byte check against an empty String.chr(0) sentinel in both validate_resource_path and validate_loadable_path. On builds that normalize embedded nulls away (e.g. 4.3), contains("") would be true and reject every path; a String that can't hold a null can't smuggle one, so skip the check. - Update stale "res:// path" comments in ui_handler (stylebox override) and material_values.load_texture — both now accept uid:// / user:// via validate_loadable_path. - Tighten test_path_traversal_guard: assert attach_script is present and require >=5 McpPathValidator delegations in script_handler, so a regression where attach_script stops validating its path is caught. Live Godot 4.6.3: path_validator/filesystem/script/ui/material/resource/scene/ audio suites all pass; test_path_traversal_guard green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style: wrap long func-name tuple in test_path_traversal_guard (ruff E501) The 5-entry func list exceeded the 100-char line limit after adding attach_script. Pure formatting; the assertion is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(handlers): align remaining res:// messages with uid:// / user:// support Copilot re-review: now that the theme / stylebox / shader load paths accept uid:// and user:// via loadable_error, the surrounding comments, the build_layout docstring, the stylebox fallback error ("expects a res:// path"), and the shader_path missing-arg error still said res:// only. Text-only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent c0e3be7 commit cff4c3f

21 files changed

Lines changed: 405 additions & 108 deletions

plugin/addons/godot_ai/handlers/audio_handler.gd

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ func set_stream(params: Dictionary) -> Dictionary:
101101
if stream_path.is_empty():
102102
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: stream_path")
103103

104+
var stream_path_err = McpPathValidator.loadable_error(stream_path, "stream_path")
105+
if stream_path_err != null:
106+
return stream_path_err
107+
104108
var resolved := _resolve_player(player_path)
105109
if resolved.has("error"):
106110
return resolved
@@ -259,8 +263,9 @@ func list_streams(params: Dictionary) -> Dictionary:
259263
var root: String = params.get("root", "res://")
260264
var include_duration: bool = bool(params.get("include_duration", true))
261265

262-
if not root.begins_with("res://"):
263-
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "root must start with res://")
266+
var root_err = McpPathValidator.path_error(root, "root")
267+
if root_err != null:
268+
return root_err
264269

265270
var efs := EditorInterface.get_resource_filesystem()
266271
if efs == null:

plugin/addons/godot_ai/handlers/autoload_handler.gd

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@ func add_autoload(params: Dictionary) -> Dictionary:
3333
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name")
3434
if path.is_empty():
3535
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
36-
if not path.begins_with("res://"):
37-
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Path must start with res:// (got: %s)" % path)
36+
var path_err = McpPathValidator.path_error(path, "path")
37+
if path_err != null:
38+
return path_err
3839
if not FileAccess.file_exists(path):
3940
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "File not found: %s" % path)
4041

plugin/addons/godot_ai/handlers/curve_handler.gd

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ func set_points(params: Dictionary) -> Dictionary:
3838
var node: Node = null
3939
var curve_created := false
4040
if has_file_target:
41+
var rpath_err = McpPathValidator.loadable_error(resource_path, "resource_path")
42+
if rpath_err != null:
43+
return rpath_err
4144
if not ResourceLoader.exists(resource_path):
4245
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % resource_path)
4346
# ResourceLoader.load() returns Godot's cached Resource. Duplicate

plugin/addons/godot_ai/handlers/filesystem_handler.gd

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
99
func read_file(params: Dictionary) -> Dictionary:
1010
var path: String = params.get("path", "")
1111

12-
var path_err := McpPathValidator.validate_resource_path(path)
13-
if not path_err.is_empty():
14-
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, path_err)
12+
var path_err = McpPathValidator.path_error(path, "path")
13+
if path_err != null:
14+
return path_err
1515

1616
if not FileAccess.file_exists(path):
1717
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "File not found: %s" % path)
@@ -37,9 +37,9 @@ func write_file(params: Dictionary) -> Dictionary:
3737
var path: String = params.get("path", "")
3838
var content: String = params.get("content", "")
3939

40-
var path_err := McpPathValidator.validate_resource_path(path)
41-
if not path_err.is_empty():
42-
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, path_err)
40+
var path_err = McpPathValidator.path_error(path, "path", true)
41+
if path_err != null:
42+
return path_err
4343

4444
# Ensure parent directory exists
4545
var dir_path := path.get_base_dir()

plugin/addons/godot_ai/handlers/material_handler.gd

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ func create_material(params: Dictionary) -> Dictionary:
4040
var shader_path: String = params.get("shader_path", "")
4141
var overwrite: bool = params.get("overwrite", false)
4242

43-
var err := _validate_material_path(path, "path")
43+
var err := _validate_material_path(path, "path", true)
4444
if err != null:
4545
return err
4646

@@ -65,8 +65,11 @@ func create_material(params: Dictionary) -> Dictionary:
6565
if shader_path.is_empty():
6666
return ErrorCodes.make(
6767
ErrorCodes.INVALID_PARAMS,
68-
"ShaderMaterial requires shader_path (res:// path to a .gdshader)"
68+
"ShaderMaterial requires shader_path (res:// / uid:// / user:// path to a .gdshader)"
6969
)
70+
var shader_path_err = McpPathValidator.loadable_error(shader_path, "shader_path")
71+
if shader_path_err != null:
72+
return shader_path_err
7073
if not ResourceLoader.exists(shader_path):
7174
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Shader not found: %s" % shader_path)
7275
var shader_res := ResourceLoader.load(shader_path)
@@ -111,7 +114,7 @@ func create_material(params: Dictionary) -> Dictionary:
111114
# ============================================================================
112115

113116
func set_param(params: Dictionary) -> Dictionary:
114-
var load_result := _load_material_from_path(params.get("path", ""))
117+
var load_result := _load_material_from_path(params.get("path", ""), true)
115118
if load_result.has("error"):
116119
return load_result
117120
var mat: Material = load_result.material
@@ -169,7 +172,7 @@ func set_param(params: Dictionary) -> Dictionary:
169172
# ============================================================================
170173

171174
func set_shader_param(params: Dictionary) -> Dictionary:
172-
var load_result := _load_material_from_path(params.get("path", ""))
175+
var load_result := _load_material_from_path(params.get("path", ""), true)
173176
if load_result.has("error"):
174177
return load_result
175178
var mat: Material = load_result.material
@@ -297,8 +300,9 @@ func list_materials(params: Dictionary) -> Dictionary:
297300
var root: String = params.get("root", "res://")
298301
var type_filter: String = params.get("type", "")
299302

300-
if not root.begins_with("res://"):
301-
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "root must start with res://")
303+
var root_err = McpPathValidator.path_error(root, "root")
304+
if root_err != null:
305+
return root_err
302306

303307
var efs := EditorInterface.get_resource_filesystem()
304308
if efs == null:
@@ -366,6 +370,9 @@ func assign_material(params: Dictionary) -> Dictionary:
366370
var mat: Material = null
367371
var material_created := false
368372
if not resource_path.is_empty():
373+
var rpath_err = McpPathValidator.loadable_error(resource_path, "resource_path")
374+
if rpath_err != null:
375+
return rpath_err
369376
if not ResourceLoader.exists(resource_path):
370377
if create_if_missing:
371378
# We'd need to create a new file here — refuse; callers should
@@ -463,7 +470,7 @@ func apply_to_node(params: Dictionary) -> Dictionary:
463470
var save_to: String = params.get("save_to", "")
464471
var saved := false
465472
if not save_to.is_empty():
466-
var save_err_validation := _validate_material_path(save_to, "save_to")
473+
var save_err_validation := _validate_material_path(save_to, "save_to", true)
467474
if save_err_validation != null:
468475
return save_err_validation
469476
var dir_path := save_to.get_base_dir()
@@ -564,7 +571,7 @@ func apply_preset(params: Dictionary) -> Dictionary:
564571
"Material already exists at %s (pass overwrite=true to replace)" % path
565572
)
566573

567-
var path_err := _validate_material_path(path, "path")
574+
var path_err := _validate_material_path(path, "path", true)
568575
if path_err != null:
569576
return path_err
570577

@@ -665,11 +672,12 @@ static func _reverse_type_map() -> Dictionary:
665672
return out
666673

667674

668-
static func _validate_material_path(path: String, param_name: String) -> Variant:
675+
static func _validate_material_path(path: String, param_name: String, for_write: bool = false) -> Variant:
669676
if path.is_empty():
670677
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % param_name)
671-
if not path.begins_with("res://"):
672-
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "%s must start with res:// (got %s)" % [param_name, path])
678+
var path_err := McpPathValidator.validate_resource_path(path, for_write)
679+
if not path_err.is_empty():
680+
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "%s: %s" % [param_name, path_err])
673681
var has_suffix := false
674682
for s in _SUPPORTED_SUFFIXES:
675683
if path.ends_with(s):
@@ -683,8 +691,8 @@ static func _validate_material_path(path: String, param_name: String) -> Variant
683691
return null
684692

685693

686-
func _load_material_from_path(path: String) -> Dictionary:
687-
var err := _validate_material_path(path, "path")
694+
func _load_material_from_path(path: String, for_write: bool = false) -> Dictionary:
695+
var err := _validate_material_path(path, "path", for_write)
688696
if err != null:
689697
return err
690698
if not ResourceLoader.exists(path):

plugin/addons/godot_ai/handlers/material_values.gd

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,9 +153,10 @@ static func parse_gradient(value: Variant) -> Variant:
153153
return grad
154154

155155

156-
## Load a Texture2D from a res:// path. Returns null on failure.
156+
## Load a Texture2D from a res:// / uid:// / user:// path (validate_loadable_path).
157+
## Returns null on failure (including a path that fails confinement / traversal).
157158
static func load_texture(path: String) -> Texture2D:
158-
if path.is_empty():
159+
if not McpPathValidator.validate_loadable_path(path).is_empty():
159160
return null
160161
if not ResourceLoader.exists(path):
161162
return null

plugin/addons/godot_ai/handlers/node_handler.gd

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ func create_node(params: Dictionary) -> Dictionary:
3939
# scene instance (foldout icon, the .tscn stores a reference instead of
4040
# an exploded subtree). Descendants remain owned by their sub-scene;
4141
# setting their owner to our scene_root would break the instance link.
42-
if not scene_path.begins_with("res://"):
43-
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "scene_path must start with res://")
42+
var scene_path_err = McpPathValidator.loadable_error(scene_path, "scene_path")
43+
if scene_path_err != null:
44+
return scene_path_err
4445
if not ResourceLoader.exists(scene_path):
4546
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Scene not found: %s" % scene_path)
4647
var packed_scene = ResourceLoader.load(scene_path)
@@ -222,6 +223,9 @@ func set_property(params: Dictionary) -> Dictionary:
222223
if value == "":
223224
value = null
224225
else:
226+
var value_path_err = McpPathValidator.loadable_error(value, "value")
227+
if value_path_err != null:
228+
return value_path_err
225229
if not ResourceLoader.exists(value):
226230
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % value)
227231
var loaded := ResourceLoader.load(value)

plugin/addons/godot_ai/handlers/particle_handler.gd

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,9 @@ func _set_draw_pass_gpu_3d(node: GPUParticles3D, node_path: String, pass_idx: in
341341
if int(node.draw_passes) >= pass_idx:
342342
existing_mesh = node.get(property_name) as Mesh
343343
if not mesh_path.is_empty():
344+
var mesh_path_err = McpPathValidator.loadable_error(mesh_path, "mesh_path")
345+
if mesh_path_err != null:
346+
return mesh_path_err
344347
if not ResourceLoader.exists(mesh_path):
345348
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Mesh not found: %s" % mesh_path)
346349
var loaded := ResourceLoader.load(mesh_path)
@@ -357,6 +360,9 @@ func _set_draw_pass_gpu_3d(node: GPUParticles3D, node_path: String, pass_idx: in
357360

358361
var material: Material = null
359362
if not material_path.is_empty():
363+
var material_path_err = McpPathValidator.loadable_error(material_path, "material_path")
364+
if material_path_err != null:
365+
return material_path_err
360366
if not ResourceLoader.exists(material_path):
361367
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Material not found: %s" % material_path)
362368
var loaded_mat := ResourceLoader.load(material_path)
@@ -407,6 +413,9 @@ func _set_draw_pass_cpu_3d(node: CPUParticles3D, node_path: String, mesh_path: S
407413
var mesh: Mesh = node.mesh
408414
var old_mesh: Mesh = mesh
409415
if not mesh_path.is_empty():
416+
var mesh_path_err = McpPathValidator.loadable_error(mesh_path, "mesh_path")
417+
if mesh_path_err != null:
418+
return mesh_path_err
410419
if not ResourceLoader.exists(mesh_path):
411420
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Mesh not found: %s" % mesh_path)
412421
var loaded := ResourceLoader.load(mesh_path)
@@ -417,6 +426,9 @@ func _set_draw_pass_cpu_3d(node: CPUParticles3D, node_path: String, mesh_path: S
417426
var material: Material = null
418427
var old_material: Material = node.material_override
419428
if not material_path.is_empty():
429+
var material_path_err = McpPathValidator.loadable_error(material_path, "material_path")
430+
if material_path_err != null:
431+
return material_path_err
420432
if not ResourceLoader.exists(material_path):
421433
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Material not found: %s" % material_path)
422434
var loaded_mat := ResourceLoader.load(material_path)
@@ -447,6 +459,9 @@ func _set_draw_pass_cpu_3d(node: CPUParticles3D, node_path: String, mesh_path: S
447459
func _set_draw_pass_2d(node: Node, node_path: String, texture_path: String) -> Dictionary:
448460
if texture_path.is_empty():
449461
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "2D particles require texture param")
462+
var texture_path_err = McpPathValidator.loadable_error(texture_path, "texture_path")
463+
if texture_path_err != null:
464+
return texture_path_err
450465
if not ResourceLoader.exists(texture_path):
451466
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Texture not found: %s" % texture_path)
452467
var tex := ResourceLoader.load(texture_path)

plugin/addons/godot_ai/handlers/resource_handler.gd

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,9 @@ func load_resource(params: Dictionary) -> Dictionary:
6464
if path.is_empty():
6565
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
6666

67-
if not path.begins_with("res://"):
68-
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Path must start with res://")
67+
var path_err = McpPathValidator.loadable_error(path, "path")
68+
if path_err != null:
69+
return path_err
6970

7071
if not ResourceLoader.exists(path):
7172
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % path)
@@ -112,6 +113,10 @@ func assign_resource(params: Dictionary) -> Dictionary:
112113
if resource_path.is_empty():
113114
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: resource_path")
114115

116+
var rpath_err = McpPathValidator.loadable_error(resource_path, "resource_path")
117+
if rpath_err != null:
118+
return rpath_err
119+
115120
var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
116121
if _resolved.has("error"):
117122
return _resolved
@@ -248,6 +253,9 @@ static func _apply_resource_properties(res: Resource, properties: Dictionary) ->
248253
if v == "":
249254
v = null
250255
else:
256+
var vpath_err = McpPathValidator.loadable_error(v, "property '%s'" % key)
257+
if vpath_err != null:
258+
return vpath_err
251259
var loaded := ResourceLoader.load(v)
252260
if loaded == null:
253261
return ErrorCodes.make(

plugin/addons/godot_ai/handlers/scene_handler.gd

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,9 @@ func create_scene(params: Dictionary) -> Dictionary:
9090
if path.is_empty():
9191
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
9292

93-
if not path.begins_with("res://"):
94-
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Path must start with res://")
93+
var path_err = McpPathValidator.path_error(path, "path", true)
94+
if path_err != null:
95+
return path_err
9596

9697
if not path.ends_with(".tscn") and not path.ends_with(".scn"):
9798
path += ".tscn"
@@ -148,6 +149,10 @@ func open_scene(params: Dictionary) -> Dictionary:
148149
if path.is_empty():
149150
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
150151

152+
var path_err = McpPathValidator.loadable_error(path, "path")
153+
if path_err != null:
154+
return path_err
155+
151156
if not ResourceLoader.exists(path):
152157
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Scene not found: %s" % path)
153158

@@ -202,8 +207,9 @@ func save_scene_as(params: Dictionary) -> Dictionary:
202207
if path.is_empty():
203208
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
204209

205-
if not path.begins_with("res://"):
206-
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Path must start with res://")
210+
var path_err = McpPathValidator.path_error(path, "path", true)
211+
if path_err != null:
212+
return path_err
207213

208214
if not path.ends_with(".tscn") and not path.ends_with(".scn"):
209215
path += ".tscn"

0 commit comments

Comments
 (0)