Randoloot/addons/godot_mcp_toolkit/contract/mcp_toolkit_command_options.gd
2026-10-09 12:49:07 +02:00

247 lines
10 KiB
GDScript3

@tool
class_name MCPToolkitCommandOptions
extends RefCounted
## Fluent builder for the options a command declares when registered.
##
## Every chainable method returns [code]self[/code], so a registration reads as a
## single chain whose terminal [method to_dict] produces the metadata dictionary
## [method MCPToolkitCommandRegistry.add] consumes. Each setter records one facet
## of the command's contract: its hints ([method mark_read_only],
## [method mark_destructive], [method mark_idempotent]), its dispatch behaviour
## ([method mark_exclusive_execution], [method mark_scene_independent],
## [method with_timeout_ms]), its grouping ([method with_group]), its version range
## ([method with_min_godot_version] / [method with_max_godot_version]), and its
## path guards ([method guard_project_path] / [method guard_user_path]).[br]
## [br]
## For built-in tools, only [method mark_read_only] is load-bearing
## (routing/serialization); the client-visible readOnly/destructive/idempotent hints
## are server-authoritative (server [code]src/catalogue.ts[/code]).
## [method mark_destructive] / [method mark_idempotent] apply only to EXTENSION
## tools, whose annotations the bridge reads via
## [method MCPToolkitCommandRegistry.get_command_metadata]. Extension tools should
## use [MCPToolkitExtensionOptions], a subclass that requires a description at
## construction time (it errors on an empty one but still constructs).[br]
## [br]
## Example — register a read-only physics tool:
## [codeblock]
## registry.add("physics_list_bodies", _on_list_bodies,
## MCPToolkitCommandOptions.new() \
## .mark_read_only() \
## .with_timeout_ms(60000) \
## .with_group("physics_tools", "Physics inspection", ["physics", "force"]))
## [/codeblock]
var _description: String = ""
var _input_schema: Dictionary = {}
var _is_read_only: bool = false
var _is_destructive: bool = false
var _is_idempotent: bool = false
var _is_cancellable: bool = false
var _timeout_ms: int = 0 # 0 = use registry default (30000)
var _group_name: String = ""
var _group_description: String = ""
var _group_keywords: Array = []
var _is_scene_independent: bool = false # inverted: true means NOT required
var _exclusive_execution: bool = false
var _min_godot_version: String = ""
var _max_godot_version: String = ""
var _success_hint: String = ""
var _path_guards: Dictionary = {} # param_name -> "project" | "user"
## Sets the human-readable [param description] the bridge advertises for an
## extension tool (built-in descriptions are server-authoritative).
## Returns [code]self[/code] for chaining. Prefer [MCPToolkitExtensionOptions],
## which takes the description in its constructor.
func with_description(description: String) -> MCPToolkitCommandOptions:
_description = description
return self
## Sets the JSON-Schema [param schema] describing the tool's parameters, used by
## the bridge to validate and advertise the tool's inputs.
## Returns [code]self[/code] for chaining.
func with_input_schema(schema: Dictionary) -> MCPToolkitCommandOptions:
_input_schema = schema
return self
## Sets the per-command watchdog deadline to [param timeout] milliseconds.
## A non-positive value (the default) means "use the registry default" (30 s);
## the registry floors a positive value at 1 s and caps it at 300 s.
## Returns [code]self[/code] for chaining.
func with_timeout_ms(timeout: int) -> MCPToolkitCommandOptions:
_timeout_ms = timeout
return self
## Assigns the command to the on-demand group [param name], so it loads only when
## that group is requested rather than at startup. [param description] and
## [param keywords] help the bridge present and surface the group. Pass an empty
## [param name] to leave the command ungrouped (eager).
## Returns [code]self[/code] for chaining.
func with_group(name: String, description: String = "",
keywords: Array = []) -> MCPToolkitCommandOptions:
_group_name = name
_group_description = description
_group_keywords = keywords
return self
## Marks the command read-only: it does not mutate editor or project state.
## This is load-bearing for every command — the dispatcher uses it to decide
## whether the call must be serialized against mutations (read-only calls bypass
## the mutation queue) — and for extension tools it also becomes the client-visible
## [code]readOnlyHint[/code]. Mutually exclusive with [method mark_destructive].
## Returns [code]self[/code] for chaining.
func mark_read_only() -> MCPToolkitCommandOptions:
_is_read_only = true
return self
## Marks the command destructive (it may delete or overwrite data), surfacing the
## client-visible [code]destructiveHint[/code] for an extension tool. Applies to
## EXTENSION tools only — built-in hints are server-authoritative. The registry
## treats this as mutually exclusive with [method mark_read_only].
## Returns [code]self[/code] for chaining.
func mark_destructive() -> MCPToolkitCommandOptions:
_is_destructive = true
return self
## Marks the command idempotent (repeating it with the same arguments has the same
## effect as calling it once), surfacing the client-visible
## [code]idempotentHint[/code] for an extension tool. Applies to EXTENSION tools
## only — built-in hints are server-authoritative.
## Returns [code]self[/code] for chaining.
func mark_idempotent() -> MCPToolkitCommandOptions:
_is_idempotent = true
return self
## Marks the command cancellable, so the dispatcher passes a
## [MCPToolkitToolContext] the handler can poll to abort cooperatively.
## Returns [code]self[/code] for chaining.
func mark_cancellable() -> MCPToolkitCommandOptions:
_is_cancellable = true
return self
## Marks the command as not requiring an open edited scene, so the dispatcher
## does not reject it when no scene is active. By default a command IS treated as
## requiring the active scene; call this for scene-independent tools (e.g. ones
## that only read project files).
## Returns [code]self[/code] for chaining.
func mark_scene_independent() -> MCPToolkitCommandOptions:
_is_scene_independent = true
return self
## Marks the command for exclusive execution, so the dispatcher serializes it
## against all other mutations even when it is also read-only (use for commands
## whose work must not overlap any other dispatch).
## Returns [code]self[/code] for chaining.
func mark_exclusive_execution() -> MCPToolkitCommandOptions:
_exclusive_execution = true
return self
## Restricts the command to a minimum engine version: when the running engine is
## older than [param version], the registry skips registration entirely so the
## command never appears. [param version] is [code]"major.minor"[/code] or
## [code]"major.minor.patch"[/code] (e.g. [code]"4.5"[/code]); an invalid format
## warns and is otherwise ignored. See [method with_max_godot_version] for the
## upper bound. Returns [code]self[/code] for chaining.
func with_min_godot_version(version: String) -> MCPToolkitCommandOptions:
if not _is_valid_version(version):
push_warning("[MCPToolkit] Invalid min_godot_version format: '%s' (expected 'major.minor', e.g. '4.5')" % version)
_min_godot_version = version
return self
## Restricts the command to a maximum engine version: when the running engine is
## newer than [param version], the registry skips registration. [param version]
## uses the same format as [method with_min_godot_version]; an invalid format
## warns and is ignored. Returns [code]self[/code] for chaining.
func with_max_godot_version(version: String) -> MCPToolkitCommandOptions:
if not _is_valid_version(version):
push_warning("[MCPToolkit] Invalid max_godot_version format: '%s' (expected 'major.minor', e.g. '4.6')" % version)
_max_godot_version = version
return self
## Sets the [param hint] auto-attached to a successful response when the handler
## did not set one itself, giving the caller follow-up guidance.
## Returns [code]self[/code] for chaining.
func with_success_hint(hint: String) -> MCPToolkitCommandOptions:
_success_hint = hint
return self
## Declares that the parameter named [param param] carries a [code]res://[/code]
## project path the dispatcher must validate (rejecting traversal/escape with
## [code]PATH_DENIED[/code]) BEFORE the handler runs. Use this for any LLM-supplied
## path an extension command reads or writes; built-in tools self-guard, so this is
## the declarative equivalent for extension commands. An absent or empty value at
## call time is left for the handler (it is not a rejection). See
## [method guard_user_path] for the [code]user://[/code] variant.
## Returns [code]self[/code] for chaining.
func guard_project_path(param: String) -> MCPToolkitCommandOptions:
_path_guards[param] = "project"
return self
## Like [method guard_project_path], but declares that the parameter named
## [param param] carries a [code]user://[/code] path. The dispatcher rejects
## traversal, non-[code]user://[/code] prefixes, and the plugin's own internal
## paths with a path-denied error before the handler runs.
## Returns [code]self[/code] for chaining.
func guard_user_path(param: String) -> MCPToolkitCommandOptions:
_path_guards[param] = "user"
return self
static func _is_valid_version(v: String) -> bool:
var parts := v.split(".")
if parts.size() < 2:
return false
return parts[0].is_valid_int() and parts[1].is_valid_int()
## Builds the options dictionary the registry stores for the command, collapsing
## every facet set on this builder into one metadata map. Keys with no meaningful
## value are omitted; [code]is_active_scene_required[/code] is the inverse of
## [method mark_scene_independent]. [method MCPToolkitCommandRegistry.add] calls
## this — you rarely call it directly. Returns the metadata dictionary.
func to_dict() -> Dictionary:
var d := {
"description": _description,
"is_read_only": _is_read_only,
"is_destructive": _is_destructive,
"is_idempotent": _is_idempotent,
"is_cancellable": _is_cancellable,
"is_active_scene_required": not _is_scene_independent,
}
if _exclusive_execution:
d["exclusive_execution"] = true
if not _input_schema.is_empty():
d["input_schema"] = _input_schema
if _timeout_ms > 0:
d["timeout_ms"] = _timeout_ms
if _group_name != "":
var group := {"name": _group_name}
if _group_description != "":
group["description"] = _group_description
if not _group_keywords.is_empty():
group["keywords"] = _group_keywords
d["group"] = group
if _min_godot_version != "":
d["min_godot_version"] = _min_godot_version
if _max_godot_version != "":
d["max_godot_version"] = _max_godot_version
if _success_hint != "":
d["success_hint"] = _success_hint
if not _path_guards.is_empty():
d["path_guards"] = _path_guards
return d