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

257 lines
13 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
## Shared screenshot response builder — mode parse, [code]image_detail[/code] sizing,
## save-path guard, disk persist, and inline/disk/both payload shaping for the three
## screenshot handlers.
##
## The [code]image_response_mode[/code] param selects how a capture is returned:
## [code]"inline"[/code] (default) embeds the base64 PNG in the response;
## [code]"disk"[/code] persists the PNG and returns only its file path (for very
## large captures or to conserve context tokens); [code]"both"[/code] does both.
## A [code]save_path[/code] names the destination (validated against the caller's
## allowlist, [code].png[/code] required); when omitted in disk/both an auto-named
## file lands under [code]user://screenshots/[/code]. The editor and runtime
## handlers share this one builder so the wire shape stays identical across all
## three call sites.[br]
## [br]
## The [code]image_detail[/code] param ([code]"full"[/code]/[code]"mid"[/code]/
## [code]"low"[/code]) caps the [b]inline[/b] image's long edge; this file owns the
## pure [method image_detail_dims] calculator (the capture handler applies the
## [Image] resize — this stays a pure shaper) and echoes the applied level plus the
## returned [code]"WxH"[/code] so a size reduction is never silent. A disk/both
## response also carries a hint that the saved file is full resolution.[br]
## [br]
## Runtime-clean by construction — it preloads only [code]security/file_guard.gd[/code]
## and names value types plus [MCPToolkitError] (a runtime-safe global class),
## [FileAccess], [DirAccess], [ProjectSettings], [Marshalls], [Image] (a core class),
## and [Time], never an editor class. An editor symbol anywhere in this static graph
## would parse-fail the runtime autoload in an unstripped export
## (godotengine/godot#91713), and the runtime server preloads this file; keep it
## editor-free.
const FileGuard := preload("res://addons/godot_mcp_toolkit/security/file_guard.gd")
## Directory auto-named captures are written to when disk/both mode omits a save_path.
const _AUTO_NAME_DIR := "user://screenshots/"
## Long-edge cap (px) per image_detail level for the inline image. Provisional
## targets: "mid" keeps HUD text readable, "low" preserves gross layout/motion only;
## "full" is absent here (never caps). Tuned values pending a live fidelity check.
const IMAGE_DETAIL_CAPS := {"mid": 1024, "low": 512}
## Returns the requested response mode: [code]"inline"[/code], [code]"disk"[/code],
## or [code]"both"[/code].
##
## Absent [code]image_response_mode[/code] defaults to [code]"inline"[/code]. A
## present-but-unrecognized value returns [code]""[/code] so the caller can reject
## it as INVALID_PARAMS. [param parameters] is the raw JSON-RPC parameter dict.
static func mode_of(parameters: Dictionary) -> String:
if not parameters.has("image_response_mode"):
return "inline"
var mode := str(parameters.get("image_response_mode", ""))
if mode == "inline" or mode == "disk" or mode == "both":
return mode
return ""
## Returns the requested inline-resolution level: [code]"full"[/code],
## [code]"mid"[/code], or [code]"low"[/code].
##
## Absent [code]image_detail[/code] defaults to [code]"full"[/code] (native, no cap).
## A present-but-unrecognized value returns [code]""[/code] so each capture handler
## can reject it as INVALID_PARAMS rather than silently falling through to full-res.
## [param parameters] is the raw JSON-RPC parameter dict.
static func detail_of(parameters: Dictionary) -> String:
if not parameters.has("image_detail"):
return "full"
var detail := str(parameters.get("image_detail", ""))
if detail == "full" or detail == "mid" or detail == "low":
return detail
return ""
## Target inline dimensions for [param detail] applied to a native
## [param width]×[param height] frame.
##
## Proportional, aspect-preserving, and shrink-only: returns the native dims
## unchanged for [code]"full"[/code], for an unknown value, or when the long edge is
## already within the level's cap (never upscales). The reduced levels cap the long
## edge to [constant IMAGE_DETAIL_CAPS] and scale the short edge to match. Pure and
## deterministic — the sizing decision lives here so it is unit-testable without an
## editor, while the capture handler owns the [method Image.resize] call.
static func image_detail_dims(width: int, height: int, detail: String) -> Vector2i:
if not IMAGE_DETAIL_CAPS.has(detail):
return Vector2i(width, height)
var cap: int = IMAGE_DETAIL_CAPS[detail]
var long_edge := maxi(width, height)
if long_edge <= cap:
return Vector2i(width, height)
var scale := float(cap) / float(long_edge)
return Vector2i(maxi(1, roundi(width * scale)), maxi(1, roundi(height * scale)))
## Shapes the screenshot response for the requested mode, persisting the PNG when
## disk/both, and returns the data Dictionary the handler wraps in
## [method MCPToolkitSuccess.ok].
##
## [param parameters] is the raw parameter dict (read for [code]image_response_mode[/code],
## [code]image_detail[/code], and [code]save_path[/code]); [param png_bytes] is the
## [b]full-res[/b] encoded PNG and [param width] / [param height] its native
## dimensions — always what a disk copy persists; [param allowed_save_prefixes] is
## the save-path allowlist for this context (editor: [code]res://[/code] +
## [code]user://screenshots/[/code]; runtime: [code]user://screenshots/[/code] only).[br]
## [br]
## The [b]inline[/b] image is separable so [code]image_detail[/code] can downscale it
## while disk stays full-res: pass a pre-downscaled [param inline_png_bytes] +
## [param inline_width] / [param inline_height] and the inline base64 uses those
## instead of the full-res buffer. Left at the defaults (empty / [code]-1[/code]), the
## inline reuses the full-res buffer — the [code]"full"[/code] path, byte-identical to
## a pre-[code]image_detail[/code] capture. The caller (not this pure shaper) owns the
## [method Image.resize]. Every payload echoes the applied [code]image_detail[/code]
## plus [code]returned[/code] ([code]"WxH"[/code] of the returned inline, or of the
## full-res file in disk mode) so a size reduction is never silent.[br]
## [br]
## A [code]save_path[/code] is validated whenever present, in ANY mode: it must pass
## [method FileGuard.resolve_safe] against [param allowed_save_prefixes] and end in
## [code].png[/code]. In inline mode it is validated but NOT persisted (the response
## stays byte-identical to a no-save capture). In disk/both mode the target is the
## given [code]save_path[/code], else an auto-named file under
## [code]user://screenshots/[/code]; the directory is created and the full-res PNG
## written, and a [code]hint[/code] discloses that the saved file is full resolution.[br]
## [br]
## Returns the shaped payload on success:
## [code]{image_base64, mime_type, width, height, bytes, image_detail, returned}[/code]
## for inline; [code]{path, width, height, bytes, mime_type, image_detail, returned,
## hint}[/code] (no base64) for disk; the inline shape plus [code]path[/code] and
## [code]hint[/code] for both. A returned [code]path[/code] is the globalized absolute
## file path. On failure it returns a [method MCPToolkitError.fail] dict directly
## (identified by [code]"success": false[/code]): INVALID_PARAMS (bad mode value or
## non-[code].png[/code] save_path), PATH_DENIED (FileGuard rejection), or INTERNAL
## (mkdir / write failure).
static func build(parameters: Dictionary, png_bytes: PackedByteArray, width: int, height: int,
allowed_save_prefixes: Array, inline_png_bytes: PackedByteArray = PackedByteArray(),
inline_width: int = -1, inline_height: int = -1) -> Dictionary:
var mode := mode_of(parameters)
if mode.is_empty():
return MCPToolkitError.fail("INVALID_PARAMS",
"image_response_mode must be one of [inline, disk, both] (got %s)"
% str(parameters.get("image_response_mode", "")))
var save_path := str(parameters.get("save_path", ""))
# The applied inline-resolution level, echoed on every shape. The handler has
# already validated the enum; absent → "full" (native). Disk mode returns no inline
# image, so its "returned" reports the full-res dims regardless of this value.
var detail := str(parameters.get("image_detail", "full"))
# The inline image is the pre-downscaled buffer when the caller supplied one, else
# the full-res buffer (the "full" path). Disk mode ignores these — no inline.
var has_inline_override := inline_png_bytes.size() > 0 and inline_width >= 0 and inline_height >= 0
var inline_bytes := inline_png_bytes if has_inline_override else png_bytes
var inline_w := inline_width if has_inline_override else width
var inline_h := inline_height if has_inline_override else height
# Validate a supplied save_path in every mode, so inline callers get the same
# deterministic rejection they would in disk/both rather than a silent accept.
if not save_path.is_empty():
var validation := _validate_save_path(save_path, allowed_save_prefixes)
if validation.get("success") == false:
return validation
if mode == "inline":
return _inline_payload(inline_bytes, inline_w, inline_h, detail)
# disk / both: resolve the destination, ensure the directory, write the full-res PNG.
var target := save_path if not save_path.is_empty() else _auto_name()
var persisted := _persist_png(target, png_bytes)
if persisted.get("success") == false:
return persisted
var globalized := str(persisted["path"])
if mode == "both":
var payload := _inline_payload(inline_bytes, inline_w, inline_h, detail)
payload["path"] = globalized
payload["hint"] = _full_res_hint(globalized)
return payload
# Disk-only: the returned artifact is the full-res file, so "returned" = full-res dims.
return _disk_payload(globalized, png_bytes, width, height, detail)
## Validates [param save_path] against [param allowed_prefixes] via FileGuard plus a
## [code].png[/code]-suffix check. Returns [code]{}[/code] when valid, else the
## PATH_DENIED / INVALID_PARAMS [method MCPToolkitError.fail] dict.
static func _validate_save_path(save_path: String, allowed_prefixes: Array) -> Dictionary:
var guard := FileGuard.resolve_safe(save_path, allowed_prefixes)
if guard["error"] != null:
return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"]))
if not save_path.ends_with(".png"):
return MCPToolkitError.fail("INVALID_PARAMS",
"save_path must end with .png: %s" % save_path)
return {}
## Writes [param png_bytes] to [param target] (a validated res:// or user:// path),
## creating the parent directory. Returns [code]{"path": <globalized absolute path>}[/code]
## on success, else an INTERNAL [method MCPToolkitError.fail] dict.
static func _persist_png(target: String, png_bytes: PackedByteArray) -> Dictionary:
var directory_path := target.get_base_dir()
if not directory_path.is_empty():
var mkdir_error := DirAccess.make_dir_recursive_absolute(directory_path)
if mkdir_error != OK and mkdir_error != ERR_ALREADY_EXISTS:
return MCPToolkitError.fail("INTERNAL",
"could not create %s (err %d)" % [directory_path, mkdir_error])
var file := FileAccess.open(target, FileAccess.WRITE)
if file == null:
return MCPToolkitError.fail("INTERNAL",
"could not open %s for writing (err %d)" % [target, FileAccess.get_open_error()])
file.store_buffer(png_bytes)
file.close()
return {"path": ProjectSettings.globalize_path(target)}
## The inline payload: the base64 PNG plus its metadata and the applied-detail
## disclosure, with the historical keys first and the two disclosure keys appended.
static func _inline_payload(png_bytes: PackedByteArray, width: int, height: int,
detail: String) -> Dictionary:
return {
"image_base64": Marshalls.raw_to_base64(png_bytes),
"mime_type": "image/png",
"width": width,
"height": height,
"bytes": png_bytes.size(),
"image_detail": detail,
"returned": "%dx%d" % [width, height],
}
## The lean disk payload: the file path plus metadata and the applied-detail
## disclosure, with NO embedded base64. [param width] / [param height] are the
## full-res dims of the saved file (disk mode never downscales).
static func _disk_payload(globalized_path: String, png_bytes: PackedByteArray,
width: int, height: int, detail: String) -> Dictionary:
return {
"path": globalized_path,
"width": width,
"height": height,
"bytes": png_bytes.size(),
"mime_type": "image/png",
"image_detail": detail,
"returned": "%dx%d" % [width, height],
"hint": _full_res_hint(globalized_path),
}
## The disk-persist disclosure hint: the saved file is always full resolution
## regardless of [code]image_detail[/code], so an agent can read it for pixel detail
## instead of re-capturing. [param globalized_path] is the saved file's absolute path.
static func _full_res_hint(globalized_path: String) -> String:
return "Saved full-res → %s. Read it for pixel detail without re-capturing." % globalized_path
## A unique auto-name under [code]user://screenshots/[/code] for a save-less
## disk/both capture. Timestamp for human ordering plus a microsecond tick so two
## captures in the same second never collide.
static func _auto_name() -> String:
var stamp := Time.get_datetime_string_from_system(false, false).replace(":", "-")
return "%sscreenshot_%s_%d.png" % [_AUTO_NAME_DIR, stamp, Time.get_ticks_usec()]