Initial commit
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
@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
|
||||
Reference in New Issue
Block a user