@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": }[/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()]