2026-10-09 12:49:07 +02:00

177 lines
7.9 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

@tool
extends RefCounted
## save.* command handlers — user:// file operations.
const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd")
const FileGuard = Modules.FileGuard
const Untrusted = Modules.Untrusted
const Scrubber = Modules.Scrubber
static func register(registry: MCPToolkitCommandRegistry, _server: Node) -> void:
registry.add("save.read", func(parameters: Dictionary) -> Dictionary:
return _cmd_save_read(parameters)
, MCPToolkitCommandOptions.new().mark_read_only().mark_scene_independent())
registry.add("save.write", func(parameters: Dictionary) -> Dictionary:
return _cmd_save_write(parameters)
, MCPToolkitCommandOptions.new().mark_scene_independent())
registry.add("save.delete", func(parameters: Dictionary) -> Dictionary:
return _cmd_save_delete(parameters)
, MCPToolkitCommandOptions.new().mark_scene_independent())
registry.add("save.list", func(parameters: Dictionary) -> Dictionary:
return _cmd_save_list(parameters)
, MCPToolkitCommandOptions.new().mark_read_only().mark_scene_independent())
# -- Commands -----------------------------------------------------------------
static func _cmd_save_write(parameters: Dictionary) -> Dictionary:
var path := str(parameters.get("path", ""))
var content := str(parameters.get("content", ""))
if path.is_empty():
return MCPToolkitError.fail("INVALID_PARAMS", "missing path")
var guard := FileGuard.resolve_safe_user(path)
if not guard["ok"]:
return MCPToolkitError.fail(str(guard["error_code"]), str(guard["error_message"]))
var abs_path: String = guard["absolute_path"]
DirAccess.make_dir_recursive_absolute(abs_path.get_base_dir())
var f := FileAccess.open(abs_path, FileAccess.WRITE)
if f == null:
return MCPToolkitError.fail("SAVE_WRITE_FAILED",
"FileAccess.open for write failed (error=%d, path=%s)" % [FileAccess.get_open_error(), path])
f.store_string(content)
f.close()
return MCPToolkitSuccess.ok({"path": path, "bytes_written": content.length()})
static func _cmd_save_read(parameters: Dictionary) -> Dictionary:
var path := str(parameters.get("path", ""))
if path.is_empty():
return MCPToolkitError.fail("INVALID_PARAMS", "missing path")
# Cap is configurable (mcp_toolkit/limits/save_read_cap_kb, default 256, min
# 64) — mirrors script_read_cap_kb. Default 256 KB ⇒ 262144 ⇒ the former
# hardcoded ceiling, so the default behaviour is unchanged.
var cap_kb: int = ProjectSettings.get_setting("mcp_toolkit/limits/save_read_cap_kb", 256)
var cap_bytes := maxi(64, cap_kb) * 1024
var max_bytes := int(parameters.get("max_bytes", 65536))
if max_bytes <= 0 or max_bytes > cap_bytes:
return MCPToolkitError.fail("INVALID_PARAMS",
"max_bytes must be 1..%d (got %d); raise mcp_toolkit/limits/save_read_cap_kb to read a larger window"
% [cap_bytes, max_bytes])
# Byte offset for pagination (default 0) — parity with script.read's line
# range. Lets a large file be read in successive ≤max_bytes windows under the
# WebSocket frame ceiling, which is the only way the engine ships big files.
var offset := int(parameters.get("offset", 0))
if offset < 0:
return MCPToolkitError.fail("INVALID_PARAMS",
"offset must be >= 0 (got %d)" % offset)
var guard := FileGuard.resolve_safe_user(path)
if not guard["ok"]:
return MCPToolkitError.fail(str(guard["error_code"]), str(guard["error_message"]))
var abs_path: String = guard["absolute_path"]
var f := FileAccess.open(abs_path, FileAccess.READ)
if f == null:
return MCPToolkitError.fail("SAVE_READ_FAILED",
"FileAccess.open for read failed (error=%d, path=%s)" % [FileAccess.get_open_error(), path])
var total_bytes := int(f.get_length())
# Bytes remaining after the offset. An offset at or past EOF yields 0 bytes
# (not an error) so a paging caller can detect completion by next_offset.
var remaining := maxi(0, total_bytes - offset)
var bytes_to_read := mini(remaining, max_bytes)
# Frame-size guard: the engine drops any WebSocket frame larger than the
# per-peer outbound buffer wholesale (send_text's result is unchecked → silent
# drop). Reject BEFORE reading so an oversized window never reaches the
# transport. Sizing basis is the base64 worst case (1.33×), since the binary
# fallback path inflates the payload by ~33%. At defaults this never fires
# (256 KB × 1.33 ≈ 340 KB < 1 MB); it guards the footgun of raising
# save_read_cap_kb above ws_buffer_kb.
var ws_buffer_kb: int = ProjectSettings.get_setting("mcp_toolkit/limits/ws_buffer_kb", 1024)
var projected_bytes := int(ceil(bytes_to_read * 1.33))
if projected_bytes > ws_buffer_kb * 1024:
f.close()
var size_err := MCPToolkitError.fail("FILE_TOO_LARGE",
"projected response (~%d KB base64) exceeds the %d KB WebSocket buffer"
% [projected_bytes / 1024, ws_buffer_kb])
size_err["total_bytes"] = total_bytes
size_err["hint"] = "narrow the window: use offset + a smaller max_bytes"
return size_err
if offset > 0:
f.seek(offset)
var buffer := f.get_buffer(bytes_to_read)
f.close()
var next_offset := offset + buffer.size()
# has_more means there is more file beyond what this window returned.
var has_more := next_offset < total_bytes
# Uniform pagination contract: on a read with more remaining, hand the LLM a prose
# loop instruction — re-call with offset = next_offset until has_more is false.
# Present ONLY when has_more; the field is absent on a complete read.
var page_hint := ""
if has_more:
page_hint = "more bytes remain — re-call save.read with offset = next_offset (%d) until has_more is false" % next_offset
# Binary-safe: try UTF-8 decode; fall back to base64.
var text := buffer.get_string_from_utf8()
if text.is_empty() and buffer.size() > 0:
return MCPToolkitSuccess.ok(Modules.Pagination.byte_page(
{
"path": path,
"content_base64": Marshalls.raw_to_base64(buffer),
"encoding": "base64",
"offset": offset,
},
offset, buffer.size(), total_bytes, page_hint))
var scrubbed := Scrubber.scrub(text, "save.read")
var wrapped := Untrusted.wrap("user-file", path, scrubbed["text"])
return MCPToolkitSuccess.ok(Modules.Pagination.byte_page(
{
"path": path,
"content": wrapped,
"offset": offset,
},
offset, buffer.size(), total_bytes, page_hint))
static func _cmd_save_delete(parameters: Dictionary) -> Dictionary:
var path := str(parameters.get("path", ""))
if path.is_empty():
return MCPToolkitError.fail("INVALID_PARAMS", "missing path")
var guard := FileGuard.resolve_safe_user(path)
if not guard["ok"]:
return MCPToolkitError.fail(str(guard["error_code"]), str(guard["error_message"]))
var abs_path: String = guard["absolute_path"]
if not FileAccess.file_exists(abs_path):
return MCPToolkitError.fail("NOT_FOUND", "no file at %s" % path)
var err := DirAccess.remove_absolute(abs_path)
if err != OK:
return MCPToolkitError.fail("SAVE_DELETE_FAILED",
"DirAccess.remove_absolute returned %d (path=%s)" % [err, path])
return MCPToolkitSuccess.ok({"path": path})
static func _cmd_save_list(parameters: Dictionary) -> Dictionary:
var path := str(parameters.get("path", ""))
if path.is_empty():
return MCPToolkitError.fail("INVALID_PARAMS", "missing path")
if not path.ends_with("/"):
return MCPToolkitError.fail("INVALID_PATH",
"save.list requires a directory path ending with / (got %s); use save.read for a single file" % path)
var guard := FileGuard.resolve_safe_user(path)
if not guard["ok"]:
return MCPToolkitError.fail(str(guard["error_code"]), str(guard["error_message"]))
var abs_path: String = guard["absolute_path"]
if not DirAccess.dir_exists_absolute(abs_path):
return MCPToolkitError.fail("NOT_FOUND", "no directory at %s" % path)
var d := DirAccess.open(abs_path)
if d == null:
return MCPToolkitError.fail("SAVE_READ_FAILED",
"DirAccess.open failed (path=%s)" % path)
var files := Array(d.get_files())
var dirs := Array(d.get_directories())
return MCPToolkitSuccess.ok({
"path": path,
"files": files,
"directories": dirs,
"file_count": files.size(),
"directory_count": dirs.size(),
})