Files
2026-08-10 20:39:06 +03:00

124 lines
4.8 KiB
GDScript
Vendored

@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles MCP client configuration commands.
var _connection
var _fallback_launch_context := {}
var _status_workers: Array[Thread] = []
var _status_tearing_down := false
func _init(connection = null, fallback_launch_context = null) -> void:
_connection = connection
# Lazy-loading this handler can reload ClientConfigurator's static script and
# clear its warmed snapshot. Retain the plugin-start capture as a safe
# fallback; the worker still prefers capture_launch_context()'s live snapshot.
if fallback_launch_context is Dictionary:
_fallback_launch_context = fallback_launch_context.duplicate(true)
func configure_client(params: Dictionary) -> Dictionary:
var client_id: String = params.get("client", "")
if not McpClientConfigurator.has_client(client_id):
var valid := ", ".join(McpClientConfigurator.client_ids())
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown client: %s. Use one of: %s" % [client_id, valid])
var result := McpClientConfigurator.configure(client_id)
if result.get("status") == "error":
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
result.get("message", "Configuration failed for '%s'" % client_id))
return {"data": result}
func remove_client(params: Dictionary) -> Dictionary:
var client_id: String = params.get("client", "")
if not McpClientConfigurator.has_client(client_id):
var valid := ", ".join(McpClientConfigurator.client_ids())
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown client: %s. Use one of: %s" % [client_id, valid])
var result := McpClientConfigurator.remove(client_id)
if result.get("status") == "error":
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
result.get("message", "Removal failed for '%s'" % client_id))
return {"data": result}
func check_client_status(params: Dictionary) -> Dictionary:
var request_id: String = params.get("_request_id", "")
if _connection == null or request_id.is_empty():
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Client status requires a deferred request context.",
)
if _status_tearing_down:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Client status handler is shutting down.")
# Match the dock refresh worker's cold-load guard. This is pure-memory and
# performs no launcher discovery, CLI lookup, or config/status probe.
McpClientConfigurator.warm_status_worker_bytecode()
# The aggregate command has a 30-second budget. Its worker resolves the
# shared Claude Desktop/Codex attach launch once, then reuses it for every
# command-shaped client instead of repeating cold launcher discovery.
# Start the worker from the deferred finisher after its first frame. That
# lets the dispatcher register this request before any probe can complete.
_finish_client_status_deferred(
McpClientConfigurator.run_client_status_sweep.bind(_fallback_launch_context),
request_id,
_connection,
)
return McpDispatcher.DEFERRED_RESPONSE
## Called by McpDispatcher.clear() before releasing this lazy handler. Marking
## teardown is deliberately non-blocking: the in-flight coroutine retains this
## handler and its connection across frames, then joins each worker only after
## is_alive() becomes false. New sweeps are rejected immediately.
func prepare_for_teardown() -> void:
_status_tearing_down = true
## This instance coroutine intentionally keeps the lazily-created handler and
## deferred-response connection alive until its worker has been polled and
## joined. The first-frame yield is load-bearing: check_client_status() must
## return the deferred sentinel before the worker can produce a response.
func _finish_client_status_deferred(
worker_callable: Callable, request_id: String, connection
) -> void:
if not is_instance_valid(connection):
return
var tree: SceneTree = connection.get_tree()
if tree == null:
return
await tree.process_frame
if not is_instance_valid(connection) or _status_tearing_down:
return
var worker := Thread.new()
_status_workers.append(worker)
var start_error := worker.start(worker_callable)
if start_error != OK:
_status_workers.erase(worker)
connection.send_deferred_response(request_id, ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Could not start client status worker (error %d)." % start_error,
))
return
while worker.is_alive():
await tree.process_frame
var payload: Variant = worker.wait_to_finish()
_status_workers.erase(worker)
if _status_tearing_down:
return
if not is_instance_valid(connection):
return
if not payload is Dictionary:
payload = ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Client status worker returned an invalid response.",
)
elif payload.has("worker_error"):
payload = ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
str(payload.get("worker_error", "Client status worker failed.")),
)
connection.send_deferred_response(request_id, payload)