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

256 lines
12 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
class_name MCPToolkitError
extends RefCounted
## Shared MCP error contract — canonical error codes and failure envelope.
##
## Every command that fails returns a uniform dictionary built by [method fail]:
## [code]{"success": false, "error": <message>, "code": <code>}[/code], plus an
## optional [code]"hint"[/code] with recovery guidance. [constant CODES] is the
## authoritative vocabulary of [code]code[/code] values; [method require] is the
## shared "required parameters present" check; and [method guard_response_size]
## keeps an oversized response from exceeding the transport buffer. Mirrors
## [MCPToolkitSuccess], which builds the success envelope.[br]
## [br]
## Example:
## [codeblock]
## if node == null:
## return MCPToolkitError.fail("NODE_NOT_FOUND", "no node at " + path)
## [/codeblock]
## The authoritative set of error [code]code[/code] values a response may carry.
## [method fail] asserts (in debug builds) that the code it is given appears here,
## so an undeclared code is caught as drift during development.
const CODES: Array[String] = [
"ALREADY_EXISTS",
"ALREADY_PLAYING",
"BUSY",
"CLASS_MISMATCH",
"COMPILATION_FAILED",
"CONNECT_FAILED",
"CREATE_DIR_FAILED",
"DELETE_FAILED",
"DIR_NOT_EMPTY",
"DISCONNECTED", # Reserved — transport/peer drop; not currently emitted.
"EDITED_SCENE",
"EDITOR_VIEWPORT_UNAVAILABLE",
"EMPTY_CONTENT",
"EXECUTE_FAILED",
"EXTERNAL_EDITOR_ACTIVE",
"FAILED",
"FILE_TOO_LARGE",
"FILESYSTEM_NOT_READY",
"FOLDER_PROTECTED",
"GAME_NOT_RUNNING",
"HEADLESS_UNSUPPORTED",
"INTERNAL",
"INVALID_CLASS",
"INVALID_METHOD",
"INVALID_PARAMS",
"INVALID_PATH",
"INVALID_STATE",
"INVALID_VALUE",
"LOAD_FAILED",
"LOG_BUSY",
"LOG_UNAVAILABLE",
"NO_SCENE",
"NODE_NOT_FOUND",
"NOT_A_RESOURCE",
"NOT_BREAKED",
"NOT_FOUND",
"NOT_UNIQUE",
"PACK_FAILED",
"PARENT_NOT_FOUND",
"PARSE_ERROR",
"PATH_DENIED",
"PATH_IN_USE",
"PROPERTY_NOT_FOUND",
"READ_FAILED",
"RESPONSE_TOO_LARGE",
"RUNTIME_WINDOW_MINIMIZED",
"SAVE_DELETE_FAILED",
"SAVE_FAILED",
"SAVE_READ_FAILED",
"SAVE_WRITE_FAILED",
"SET_FAILED",
"TIMEOUT",
"UNKNOWN_CLASS",
"UNSUPPORTED",
"UNSUPPORTED_FILE_TYPE",
"WRITE_FAILED",
]
## Recovery hint suggesting how to find a valid node path, for a call site that
## received an unresolvable one. Pass it as the [param hint] of [method fail].
const HINT_NODE_PATH := "Use scene.get_tree to list valid node paths. Root node is always path '.'."
## Recovery hint suggesting how to find a valid file path, for a call site that
## received an unresolvable one. Pass it as the [param hint] of [method fail].
const HINT_FILE_PATH := "Use asset.list to search for files. Paths must start with res://"
## Recovery hint suggesting how to find a valid class name, for a call site that
## received an unknown one. Pass it as the [param hint] of [method fail].
const HINT_CLASS_NAME := "Use classdb.search to find valid class names."
## Per-code default recovery hints, keyed by error code. When [method fail] is
## given an empty hint but the code has an entry here, that hint is auto-attached —
## so codes whose recovery advice never depends on call-site context carry it
## automatically. An explicit [param hint] passed to [method fail] wins over this.
##
## [code]LOG_BUSY[/code] / [code]LOG_UNAVAILABLE[/code] are deliberately absent: their
## recovery advice is version-gated ([code]source="buffer"[/code] is a real fallback only
## on Godot 4.5+), so every emit site passes [method log_busy_hint] /
## [method log_unavailable_hint] explicitly rather than relying on a default here.
const DEFAULT_HINTS := {
"TIMEOUT": "The editor may be busy. Try editor.wait_for_idle before retrying.",
"UNSUPPORTED": "Check COMPATIBILITY.md in the godot-mcp-toolkit repository for version requirements.",
"PATH_DENIED": "Paths must use res:// format. Example: res://scenes/main.tscn",
"PARENT_NOT_FOUND": "Parent directory does not exist. Use folder.create to create it first.",
"COMPILATION_FAILED": "The game failed to start due to script errors. Fix the errors shown above, then call game_start again. If no errors are shown, call editor_refresh to retrigger them, then editor_get_console for the full log.",
"GAME_NOT_RUNNING": "No running game detected. Use game.start first. If the MCP Runtime autoload is missing, re-enable the plugin in Project Settings.",
"RESPONSE_TOO_LARGE": "The response exceeded the WebSocket transport buffer and could not be delivered. Narrow the query (filter, fewer items, a smaller range) or paginate. If large responses are expected, raise mcp_toolkit/limits/ws_buffer_kb in Project Settings and reconnect.",
}
## Validates that every key listed in [param required] is present in
## [param parameters] as a non-empty string. Returns [code]null[/code] when all
## required keys are satisfied, or an [code]INVALID_PARAMS[/code] error dict (built
## by [method fail], with a path/class recovery hint for known keys) for the first
## missing one. Call it at the top of a handler and return its result if non-null.
static func require(parameters: Dictionary, required: Array) -> Variant:
for key in required:
var val = parameters.get(key, "")
if typeof(val) == TYPE_STRING and val.is_empty():
var hint := ""
match key:
"file_path":
hint = HINT_FILE_PATH
"node_path":
hint = HINT_NODE_PATH
"path":
hint = "Provide a res:// folder path. Example: res://scenes/"
"class_name":
hint = HINT_CLASS_NAME
return fail("INVALID_PARAMS", "%s is required" % key, hint)
return null
## Builds a failure response. [param code] is the machine-readable error code
## (must be one of [constant CODES]); [param message] is the human-readable
## explanation. Returns
## [code]{"success": false, "error": message, "code": code}[/code]. When
## [param hint] is non-empty it is attached as [code]"hint"[/code]; otherwise, if
## [param code] has a [constant DEFAULT_HINTS] entry, that default hint is attached.
## This is the canonical way every handler reports an error.[br]
## [br]
## Example:
## [codeblock]
## return MCPToolkitError.fail("INVALID_PATH", "path must start with res://")
## [/codeblock]
static func fail(code: String, message: String, hint: String = "") -> Dictionary:
# Debug-only vocabulary guard: an emitted code absent from CODES is drift.
# Stripped from release builds, so it never affects the wire payload.
assert(code in CODES, "error code '%s' is not declared in MCPToolkitError.CODES" % code)
var result := {"success": false, "error": message, "code": code}
if hint != "":
result["hint"] = hint
elif DEFAULT_HINTS.has(code):
result["hint"] = DEFAULT_HINTS[code]
return result
## Recovery hint for a [code]LOG_BUSY[/code] failure — the log file exists but a read
## open failed.
##
## Pass the caller's [code]LogBuffer.uses_logger_api()[/code] as [param can_use_buffer]
## ([code]true[/code] on Godot 4.5+): only there is [code]source="buffer"[/code] a real
## file-independent fallback, so it is steered to only then; on 4.2–4.4 the buffer tails
## the same log and would fail identically, so the hint stays stop-game / retry.
## On Windows the dominant cause is a running game: the editor enables safe-save, so its
## READ open requests [code]_SH_DENYWR[/code] (deny-write), which the OS refuses while the
## play process holds the log open for writing — so the file source clears on game exit,
## while [code]source="buffer"[/code] (4.5+) and the in-game runtime reader are unaffected.
## With no game running, the rarer cause is an external holder (antivirus, file-sync,
## backup) briefly locking the file. Unreachable on POSIX (no mandatory share locking).
## Returns the string to pass as [method fail]'s [param hint].
static func log_busy_hint(can_use_buffer: bool) -> String:
if can_use_buffer:
return "Use source=\"buffer\" (in-memory, no file I/O — reads even while a game runs). For the file source: on Windows a running game holds the log open, so game_stop then retry; with no game running, a backup/AV/sync tool may briefly hold it — retry shortly."
return "On Windows a running game holds the log file open — game_stop then read (source=\"buffer\" tails the same file here, so it won't bypass the lock). With no game running, retry shortly (a backup/AV/sync tool may briefly hold it)."
## Recovery hint for a [code]LOG_UNAVAILABLE[/code] failure — the log file could not be
## found or read.
##
## Pass the caller's [code]LogBuffer.uses_logger_api()[/code] as [param can_use_buffer]
## ([code]true[/code] on Godot 4.5+): only there does [code]source="buffer"[/code] work
## independently of file logging, so it is offered only then; on 4.2–4.4 the buffer reads
## the same file and depends on the same setting. Returns the string to pass as
## [method fail]'s [param hint].
static func log_unavailable_hint(can_use_buffer: bool) -> String:
var hint := "log file could not be read — enable file logging in ProjectSettings → Debug → File Logging → Enable File Logging (debug/file_logging/enable_file_logging), then restart the editor"
if can_use_buffer:
return hint + ". Or use source=\"buffer\" (in-memory, real-time — no file needed)."
return hint + "."
## Tailored RESPONSE_TOO_LARGE hint for an oversized screenshot payload. A capture
## that overflows the buffer has a mode-specific escape hatch the generic hint lacks:
## re-request with [code]image_response_mode:"disk"[/code] to persist the PNG and
## receive only its path, or ask for a smaller size — so the guard swaps this in when
## the over-budget result carries an [code]image_base64[/code].
const _IMAGE_RESPONSE_TOO_LARGE_HINT := "The captured image exceeded the WebSocket transport buffer. Retry with image_response_mode:\"disk\" to save the PNG to disk and receive only its file path, or request a smaller size. Raising mcp_toolkit/limits/ws_buffer_kb in Project Settings also works but requires a reconnect."
## Headroom reserved below max_bytes when guarding a response, in bytes.
##
## The native WS send rejects a frame WHOLESALE — never chunks — when
## `already-queued bytes + this payload > outbound_buffer_size`
## (godotengine/godot wsl_peer.cpp, ERR_OUT_OF_MEMORY; stable 4.2–4.5). The
## checked size is the raw UTF-8 payload, but the comparison also includes
## whatever a prior send has queued-but-not-yet-flushed to a slow peer, plus
## wslay's per-message framing bookkeeping. This margin absorbs that unseen
## in-flight slack so a response we judge "safe" still clears the buffer.
const _SIZE_GUARD_MARGIN := 4096
## Returns the UTF-8 byte length of [param dict] once stringified to JSON — the
## unit the WebSocket send path measures (not character count). Pure; safe to call
## in both the editor and the runtime.
static func response_byte_size(dict: Dictionary) -> int:
return JSON.stringify(dict).to_utf8_buffer().size()
## Size-guards a fully-built JSON-RPC response against the peer's send buffer.
##
## Returns [param response] unchanged when it fits, or — when its UTF-8 byte
## length would exceed [param max_bytes] minus the framing margin — a size-safe
## replacement that preserves [code]jsonrpc[/code] + [code]id[/code] and swaps
## [code]result[/code] for a compact [code]RESPONSE_TOO_LARGE[/code] failure
## (carrying the recovery hint). The replacement is tiny by construction, so the
## caller can send it without re-checking.[br]
## [br]
## [param max_bytes] is the peer's outbound_buffer_size (captured at accept), so
## the guard works identically for the editor server
## ([code]mcp_toolkit/limits/ws_buffer_kb[/code]) and the runtime server
## (fixed 1 MB) with no ProjectSetting re-read here.
static func guard_response_size(response: Dictionary, max_bytes: int) -> Dictionary:
if max_bytes <= 0:
return response # No buffer cap configured — nothing to guard against.
if response_byte_size(response) <= max_bytes - _SIZE_GUARD_MARGIN:
return response
var safe := {}
if response.has("jsonrpc"):
safe["jsonrpc"] = response["jsonrpc"]
safe["id"] = response.get("id", null)
# An oversized screenshot payload gets the mode-specific escape hatch (persist to
# disk / smaller size); any other over-budget result keeps the generic narrow/
# paginate advice.
var hint := DEFAULT_HINTS["RESPONSE_TOO_LARGE"]
var result_payload = response.get("result")
if typeof(result_payload) == TYPE_DICTIONARY and (result_payload as Dictionary).has("image_base64"):
hint = _IMAGE_RESPONSE_TOO_LARGE_HINT
safe["result"] = fail("RESPONSE_TOO_LARGE",
"response too large for the transport buffer (limit ~%d KB)" % int(max_bytes / 1024),
hint)
return safe