Files
Brachistochrone/addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd
T
scorpion21211-cmd 72ca74c67f Initial commit
2026-10-03 23:29:41 -04:00

67 lines
4.3 KiB
GDScript

@tool
class_name McpCustomToolSpec
extends RefCounted
## Descriptor for one custom tool registered by an addon. Mirrors the
## clients/_base.gd data-only descriptor pattern: fields are set by the
## caller, NO Callables (handler instance is materialized lazily by the
## dispatcher from script_path + method, so hot-reload can't SEGV a
## worker mid-call — same rationale as McpClient, issue #229).
##
## Construction: instantiate and set fields, then pass to
## McpToolRegistry.register(spec). Do NOT subclass — custom tools are
## dynamic (one addon may register several), not file-system-scanned
## like clients.
# --- identity ---
var name: String = "" ## tool name, e.g. "gdunit_run". Must not shadow a built-in.
var description: String = "" ## shown to the agent by the MCP server
var params_schema: Dictionary = {} ## JSON Schema; advertised to the agent for shaping calls. Params are forwarded UNVALIDATED — the handler must validate its own input.
# --- handler resolution (lazy materialization by dispatcher) ---
var script_path: String = "" ## "res://addons/.../handler.gd"; load()ed on first call
var method: StringName = &"" ## method on the handler; signature: (params: Dictionary, ctx: McpCallContext) -> Dictionary
# --- source identity (https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #8) ---
var source_path: String = "" ## "plugin.cfg" path: "res://addons/gdunit4_mcp/plugin.cfg". Same path = same addon (replace allowed); different path colliding = reject + dock warning. NOTE: self-declared — a collision/ownership policy for cooperating addons, NOT a security boundary (any in-editor code can claim any path).
var source: String = "" ## display: "gdunit4_mcp". If empty, registry reads [plugin] name from source_path.
# --- exposure ---
var promoted: bool = false ## opt-in: ask the server to ALSO register this tool as a first-class MCP tool ("custom_<name>") with params_schema attached, so agents get native schemas/validation instead of the custom_manage indirection. The server caps promoted count; overflow stays reachable via custom_manage.
# --- execution contract ---
var timeout_ms: int = 4500 ## deferred timeout → DEFERRED_TIMEOUT_MS_BY_COMMAND["custom:<name>"]
var deferred: bool = false ## true → handler may return DEFERRED_RESPONSE, push later via ctx.send_deferred(payload)
var requires_writable: bool = false ## https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #1: readiness gate. false → reads run any time; true → blocks during play/import
var undoable: bool = false ## https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #6: must be true to participate in undo=true batch_execute
# --- budgets (https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #5, enforced at register time) ---
const MAX_DESCRIPTION_CHARS := 600
const MAX_SCHEMA_BYTES := 8192
const MAX_TIMEOUT_MS := 120000
const MIN_TIMEOUT_MS := 500
## Returns an array of human-readable validation errors (empty = valid).
## Called by McpToolRegistry.register() and usable standalone for early
## feedback in addon _enter_tree before the registry is even live.
func validate() -> Array[String]:
var errors: Array[String] = []
if name.is_empty():
errors.append("name is empty\n")
if not name.is_valid_ascii_identifier():
errors.append("name '%s' is not a valid identifier\n" % name)
if description.length() > MAX_DESCRIPTION_CHARS:
errors.append("description exceeds %d chars\n" % MAX_DESCRIPTION_CHARS)
if JSON.stringify(params_schema).to_utf8_buffer().size() > MAX_SCHEMA_BYTES:
errors.append("params_schema exceeds %d bytes\n" % MAX_SCHEMA_BYTES)
if script_path.is_empty() or not ResourceLoader.exists(script_path):
errors.append("script_path '%s' does not exist" % script_path)
if method.is_empty():
errors.append("method is empty")
if timeout_ms < MIN_TIMEOUT_MS or timeout_ms > MAX_TIMEOUT_MS:
errors.append("timeout_ms %d out of range [%d, %d]" % [timeout_ms, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS])
if source_path.is_empty() or not source_path.ends_with("plugin.cfg") or not FileAccess.file_exists(source_path):
errors.append("source_path '%s' must be an existing plugin.cfg path" % source_path)
return errors