256 lines
12 KiB
GDScript
256 lines
12 KiB
GDScript
@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
|