Initial commit

This commit is contained in:
scorpion21211-cmd
2026-10-03 23:29:41 -04:00
commit 965f0d7584
10586 changed files with 1030294 additions and 0 deletions
+372
View File
@@ -0,0 +1,372 @@
@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.
## The descriptor token resolved from `godot_ai/mcp_client_scope` (#872).
## Only Claude Code takes it today; any `config_type = "cli"` descriptor can.
const SCOPE_TOKEN := "{scope}"
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():
## #872: a `{scope}` descriptor sweeps EVERY scope, not just the
## selected one. Flipping the setting user -> project and pressing
## Configure would otherwise leave the old user-scope entry alive —
## exactly the "loaded in every unrelated workspace" problem the
## setting exists to fix. `mcp remove` on an absent entry is a no-op
## and the result is discarded either way, so the extra passes are
## safe; they cost one bounded subprocess per additional scope.
for pre_scope in _cleanup_scopes(client):
var pre_args := _format_args(
client.cli_unregister_template, server_name, server_url, {}, pre_scope
)
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.
##
## `scope_override` forces a specific `{scope}` value instead of the live
## setting; the configure pre-cleanup uses it to sweep the scopes the user is
## NOT currently on. Empty means "resolve from the setting".
static func format_args(
template: PackedStringArray,
server_name: String,
server_url: String,
launch: Dictionary = {},
scope_override: String = "",
) -> Array[String]:
return _format_args(template, server_name, server_url, launch, scope_override)
static func _format_args(
template: PackedStringArray,
server_name: String,
server_url: String,
launch: Dictionary = {},
scope_override: String = "",
) -> Array[String]:
var scope := scope_override if not scope_override.is_empty() else McpSettings.client_scope()
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)
## Resolved per-call rather than baked into the descriptor so the
## setting can change without an editor restart, and so the manual
## command shown in the dock always matches what Configure would run.
s = s.replace(SCOPE_TOKEN, scope)
out.append(s)
return out
## Scope-aware status for descriptors that declare `cli_scope_status_template`.
## Returns the same `{"status", "error_msg"}` shape as check_status_details.
##
## Split from the subprocess call so the verdict is a pure function of what the
## CLI printed — `_scope_probe_verdict` is unit-tested against real recorded
## `claude mcp get` output rather than needing a `claude` binary on the runner.
static func check_scope_status_details(
client: McpClient,
server_name: String,
server_url: String,
cli: String,
launch: Dictionary,
expected_scope: String,
) -> Dictionary:
if cli.is_empty() or client.cli_scope_status_template.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 and the plain status probe.
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 args := _format_args(client.cli_scope_status_template, server_name, server_url)
var result := McpCliExec.run(cli, 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)
return _scope_probe_verdict(
int(result.get("exit_code", -1)),
str(result.get("stdout", "")),
expected_scope,
expected_target,
)
## The `Scope:` label the probe printed, normalised to a CLIENT_SCOPES value,
## or "" when no line was recognisable. Matched on the first word after the
## label so the parenthetical blurb ("(shared via .mcp.json)") can change
## wording between CLI releases without breaking detection.
static func _scope_from_probe_output(text: String) -> String:
for raw_line in text.split("\n"):
var line := String(raw_line).strip_edges()
var marker := line.find("Scope:")
if marker < 0:
continue
var rest := line.substr(marker + 6).strip_edges().to_lower()
for scope in McpSettings.CLIENT_SCOPES:
var name := String(scope)
if rest.begins_with(name):
return name
return ""
## Pure verdict for a scope probe. A non-zero exit is the CLI's "no such
## server" signal. An entry resolved from a scope other than the selected one
## is MISMATCH, not CONFIGURED: the dock offers Reconfigure, and Configure's
## all-scope pre-cleanup is what actually moves it.
##
## A missing or unrecognised `Scope:` line degrades to the target check alone,
## deliberately rather than failing closed. `_verify_post_state` turns any
## non-CONFIGURED status after a successful write into an error, so treating an
## unparsed label as MISMATCH would mean a future CLI release that reworded or
## dropped that line breaks Configure outright — a false error plus a
## permanently amber row whose Reconfigure button cannot clear it. The entry
## has already been matched by name and exact launcher path at that point; the
## only thing in doubt is which scope it came from, and a slightly optimistic
## dot is a much cheaper failure than a configure flow that reports success as
## failure. Today's claude (2.1.241) always prints the line, so this is a
## forward-compatibility hedge, not a live code path.
static func _scope_probe_verdict(
exit_code: int, text: String, expected_scope: String, expected_target: String
) -> Dictionary:
if exit_code != 0:
return _status_details(McpClient.Status.NOT_CONFIGURED)
## `resolved_scope` rides along as structured data so callers can act on it
## — `_verify_post_state`'s path hint needs to know WHICH scope survived to
## name the right file, and parsing it back out of the human-facing message
## would couple that hint to this wording (#879). Parsed BEFORE the target
## check so both mismatch paths carry it: an entry whose launcher drifted
## sends the user to the wrong file just as readily as one whose scope did.
var resolved := _scope_from_probe_output(text)
if not expected_target.is_empty() and text.find(expected_target) < 0:
var drifted := _status_details(McpClient.Status.CONFIGURED_MISMATCH)
if not resolved.is_empty():
drifted["resolved_scope"] = resolved
return drifted
if resolved.is_empty():
return _status_details(McpClient.Status.CONFIGURED)
if resolved != expected_scope:
var details := _status_details(
McpClient.Status.CONFIGURED_MISMATCH,
"registered at %s scope, not %s" % [resolved, expected_scope],
)
details["resolved_scope"] = resolved
return details
return _status_details(McpClient.Status.CONFIGURED)
## True when the descriptor's register template takes `{scope}`, i.e. where
## its entry lands is decided by the `godot_ai/mcp_client_scope` setting rather
## than fixed by the descriptor. The configurator uses this to decide whether
## the JSON-fallback file is still a valid place to read status back from.
static func uses_scope_token(client: McpClient) -> bool:
return client.cli_register_template.has(SCOPE_TOKEN)
## Public view of the pre-cleanup sweep, for the manual-command text: what the
## dock tells the user to run has to match what Configure actually runs (#877).
static func cleanup_scopes(client: McpClient) -> Array[String]:
return _cleanup_scopes(client)
## Scopes the configure pre-cleanup removes from. A descriptor without the
## `{scope}` token has exactly one place its entry can live, so it keeps the
## single pass it always had — "" means "resolve the scope normally".
static func _cleanup_scopes(client: McpClient) -> Array[String]:
if not uses_scope_token(client):
return [""]
var scopes: Array[String] = []
for scope in McpSettings.CLIENT_SCOPES:
scopes.append(String(scope))
return scopes
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)