216 lines
9.7 KiB
GDScript
Vendored
216 lines
9.7 KiB
GDScript
Vendored
@tool
|
|
class_name McpCliStrategy
|
|
extends RefCounted
|
|
|
|
## Strategy for MCP clients that own their own state via a CLI (e.g.
|
|
## `claude mcp add`). Reads `cli_register_template` / `cli_unregister_template`
|
|
## / `cli_status_args` from the descriptor and substitutes `{name}` / `{url}`
|
|
## tokens. Command-shape descriptors additionally use the whole-element launch
|
|
## tokens `{command}` / `{args...}` (see `format_args`). No descriptor-supplied
|
|
## Callables — see `_base.gd` for why.
|
|
##
|
|
## Every shell-out goes through `McpCliExec.run`, which wraps the call in a
|
|
## wall-clock timeout. A hung CLI (e.g. `claude mcp list` under
|
|
## inter-Claude-Code contention) gets killed at the budget instead of
|
|
## locking up the caller forever — see issues #238 / #239.
|
|
|
|
const _CONFIGURE_TIMEOUT_MS := 10000
|
|
const _REMOVE_TIMEOUT_MS := 10000
|
|
const _STATUS_TIMEOUT_MS := 6000
|
|
|
|
|
|
static func configure(
|
|
client: McpClient,
|
|
server_name: String,
|
|
server_url: String,
|
|
launch: Dictionary = {},
|
|
) -> Dictionary:
|
|
## Fail closed before any subprocess runs: a command-shape client without a
|
|
## verified attach launcher must not register anything (see
|
|
## docs/client-configuration.md — an ERROR beats an entry known to be broken).
|
|
var launch_error := command_launch_error(client, launch)
|
|
if not launch_error.is_empty():
|
|
return {"status": "error", "message": launch_error}
|
|
var cli := _resolve_cli(client)
|
|
if cli.is_empty():
|
|
return {"status": "error", "message": "%s not found" % client.display_name}
|
|
|
|
# Best-effort prior cleanup so re-configure is idempotent. Bounded to
|
|
# the same budget — a hung unregister shouldn't block the configure
|
|
# that follows.
|
|
if not client.cli_unregister_template.is_empty():
|
|
var pre_args := _format_args(client.cli_unregister_template, server_name, server_url)
|
|
McpCliExec.run(cli, pre_args, _REMOVE_TIMEOUT_MS)
|
|
|
|
if client.cli_register_template.is_empty():
|
|
return {"status": "error", "message": "%s descriptor missing cli_register_template" % client.display_name}
|
|
var args := _format_args(client.cli_register_template, server_name, server_url, launch)
|
|
var result := McpCliExec.run(cli, args, _CONFIGURE_TIMEOUT_MS)
|
|
if result.get("timed_out", false):
|
|
return {
|
|
"status": "error",
|
|
"message": "Configure %s timed out after %ds — see 'Run this manually' below to retry by hand" % [
|
|
client.display_name, _CONFIGURE_TIMEOUT_MS / 1000,
|
|
],
|
|
}
|
|
if result.get("spawn_failed", false):
|
|
return {"status": "error", "message": "Failed to spawn %s" % client.display_name}
|
|
if int(result.get("exit_code", -1)) == 0:
|
|
return {"status": "ok", "message": McpClient.configured_message(client, server_url)}
|
|
## `claude mcp add` writes its real failure diagnostics to stderr, so
|
|
## prefer `output` (stdout + stderr) over `stdout` alone — otherwise
|
|
## the user sees "exit code 1" instead of the actual error.
|
|
var combined := str(result.get("output", "")).strip_edges()
|
|
var err := combined if not combined.is_empty() else "exit code %d" % int(result.get("exit_code", -1))
|
|
return {"status": "error", "message": "Failed to configure %s: %s" % [client.display_name, err]}
|
|
|
|
|
|
## Run the descriptor's `cli_status_args`, scan stdout for `server_name` and
|
|
## the expected target. The matching rule is the only sensible one for "list
|
|
## MCP entries" output across CLI clients we currently support: name AND
|
|
## target present → CONFIGURED; name only → MISMATCH; neither →
|
|
## NOT_CONFIGURED. For URL descriptors the target is `server_url`; for
|
|
## command-shape descriptors it is the resolved attach launcher path (the
|
|
## listing prints the registered command line, not a URL). Command-shape CLI
|
|
## clients with a JSON fallback file get exact drift detection via the JSON
|
|
## strategy instead — the configurator prefers that path and only lands here
|
|
## for CLI clients whose state isn't file-readable.
|
|
static func check_status(
|
|
client: McpClient, server_name: String, server_url: String, launch: Dictionary = {}
|
|
) -> McpClient.Status:
|
|
return check_status_with_cli_path(client, server_name, server_url, _resolve_cli(client), launch)
|
|
|
|
|
|
static func check_status_with_cli_path(
|
|
client: McpClient, server_name: String, server_url: String, cli: String, launch: Dictionary = {}
|
|
) -> McpClient.Status:
|
|
return check_status_details(client, server_name, server_url, cli, launch).get("status", McpClient.Status.NOT_CONFIGURED)
|
|
|
|
|
|
## Detailed variant used by the dock's refresh worker so it can surface a
|
|
## "probe timed out" badge on the affected row instead of silently
|
|
## conflating the timeout with NOT_CONFIGURED. Returns
|
|
## `{"status": Status, "error_msg": String}`. The caller plumbs
|
|
## `error_msg` straight into `_apply_row_status`.
|
|
static func check_status_details(
|
|
client: McpClient, server_name: String, server_url: String, cli: String, launch: Dictionary = {}
|
|
) -> Dictionary:
|
|
if cli.is_empty():
|
|
return _status_details(McpClient.Status.NOT_CONFIGURED)
|
|
if client.cli_status_args.is_empty():
|
|
return _status_details(McpClient.Status.NOT_CONFIGURED)
|
|
var expected_target := server_url
|
|
if client.command_shape != McpClient.CommandShape.NONE:
|
|
## Same fail-closed contract as configure: without a verified launcher
|
|
## there is no target to compare against, and guessing would report a
|
|
## broken entry as green.
|
|
var launch_error := command_launch_error(client, launch)
|
|
if not launch_error.is_empty():
|
|
return _status_details(McpClient.Status.ERROR, launch_error)
|
|
expected_target = str(launch.get("command", ""))
|
|
var result := McpCliExec.run(
|
|
cli,
|
|
McpClient._array_from_packed(client.cli_status_args),
|
|
_STATUS_TIMEOUT_MS,
|
|
false
|
|
)
|
|
if result.get("timed_out", false):
|
|
return _status_details(McpClient.Status.ERROR, "probe timed out")
|
|
if result.get("spawn_failed", false):
|
|
return _status_details(McpClient.Status.NOT_CONFIGURED)
|
|
if int(result.get("exit_code", -1)) != 0:
|
|
return _status_details(McpClient.Status.NOT_CONFIGURED)
|
|
var text := str(result.get("stdout", ""))
|
|
if text.find(server_name) < 0:
|
|
return _status_details(McpClient.Status.NOT_CONFIGURED)
|
|
## Server registered, but pointing somewhere else — drift after a
|
|
## port change. Surface as mismatch so the dock offers Reconfigure.
|
|
if text.find(expected_target) < 0:
|
|
return _status_details(McpClient.Status.CONFIGURED_MISMATCH)
|
|
return _status_details(McpClient.Status.CONFIGURED)
|
|
|
|
|
|
## Empty string when this client's launch requirements are satisfied. A
|
|
## command-shape descriptor requires a successfully resolved attach launch;
|
|
## URL descriptors (`CommandShape.NONE`) never require one. Mirrors
|
|
## `McpJsonStrategy.command_launch_error` / the TOML equivalent.
|
|
static func command_launch_error(client: McpClient, launch: Dictionary) -> String:
|
|
if client.command_shape == McpClient.CommandShape.NONE:
|
|
return ""
|
|
if not bool(launch.get("ok", false)):
|
|
return str(launch.get("error", "No compatible attach launcher was found."))
|
|
return ""
|
|
|
|
|
|
static func _status_details(status: McpClient.Status, error_msg: String = "") -> Dictionary:
|
|
return {"status": status, "error_msg": error_msg}
|
|
|
|
|
|
static func remove(client: McpClient, server_name: String) -> Dictionary:
|
|
var cli := _resolve_cli(client)
|
|
if cli.is_empty():
|
|
return {"status": "error", "message": "%s not found" % client.display_name}
|
|
if client.cli_unregister_template.is_empty():
|
|
return {"status": "error", "message": "%s descriptor missing cli_unregister_template" % client.display_name}
|
|
var args := _format_args(client.cli_unregister_template, server_name, "")
|
|
var result := McpCliExec.run(cli, args, _REMOVE_TIMEOUT_MS)
|
|
if result.get("timed_out", false):
|
|
return {
|
|
"status": "error",
|
|
"message": "Remove %s timed out after %ds — see 'Run this manually' below to retry by hand" % [
|
|
client.display_name, _REMOVE_TIMEOUT_MS / 1000,
|
|
],
|
|
}
|
|
if result.get("spawn_failed", false):
|
|
return {"status": "error", "message": "Failed to spawn %s" % client.display_name}
|
|
if int(result.get("exit_code", -1)) == 0:
|
|
return {"status": "ok", "message": "%s configuration removed" % client.display_name}
|
|
## `claude mcp add` writes its real failure diagnostics to stderr, so
|
|
## prefer `output` (stdout + stderr) over `stdout` alone — otherwise
|
|
## the user sees "exit code 1" instead of the actual error.
|
|
var combined := str(result.get("output", "")).strip_edges()
|
|
var err := combined if not combined.is_empty() else "exit code %d" % int(result.get("exit_code", -1))
|
|
return {"status": "error", "message": "Failed to remove %s: %s" % [client.display_name, err]}
|
|
|
|
|
|
## Substitute `{name}` and `{url}` tokens in every template entry.
|
|
## Tokens match verbatim — `{name_suffix}` is NOT touched, so callers don't
|
|
## have to worry about partial-token collisions in their argv.
|
|
##
|
|
## Launch tokens are whole-element only: an element that is exactly
|
|
## `{command}` becomes the resolved attach launcher path, and an element that
|
|
## is exactly `{args...}` is spliced into the argv as one element per launch
|
|
## arg. Whole-element matching keeps a literal brace inside a path or flag
|
|
## from ever triggering an expansion.
|
|
static func format_args(
|
|
template: PackedStringArray, server_name: String, server_url: String, launch: Dictionary = {}
|
|
) -> Array[String]:
|
|
return _format_args(template, server_name, server_url, launch)
|
|
|
|
|
|
static func _format_args(
|
|
template: PackedStringArray, server_name: String, server_url: String, launch: Dictionary = {}
|
|
) -> Array[String]:
|
|
var out: Array[String] = []
|
|
for arg in template:
|
|
var s := String(arg)
|
|
if s == "{command}":
|
|
out.append(str(launch.get("command", "")))
|
|
continue
|
|
if s == "{args...}":
|
|
for launch_arg in launch.get("args", []):
|
|
out.append(str(launch_arg))
|
|
continue
|
|
s = s.replace("{name}", server_name)
|
|
s = s.replace("{url}", server_url)
|
|
out.append(s)
|
|
return out
|
|
|
|
|
|
static func _resolve_cli(client: McpClient) -> String:
|
|
return McpCliFinder.find(McpClient._array_from_packed(client.cli_names))
|
|
|
|
|
|
static func resolve_cli_path(client: McpClient) -> String:
|
|
return _resolve_cli(client)
|