@tool class_name McpCliExec extends RefCounted ## Wall-clock-bounded CLI invocation. Every dock shell-out to a per-client ## CLI (`claude mcp list`, `claude mcp add ...`, etc.) goes through here so ## a hung subprocess can't trap the calling thread forever. ## ## Without the timeout, a contended `claude mcp list` has been observed to ## hang for 6+ minutes (issues #238, #239) — wedging the dock's status ## refresh worker, and on the Configure / Remove paths the editor main ## thread itself. ## ## Why poll/kill instead of `OS.execute(..., true)`: GDScript can't ## interrupt a blocking `OS.execute`, so a hung CLI takes its caller's ## thread with it. `OS.execute_with_pipe` returns immediately with a PID; ## we drive the wait ourselves and `OS.kill` the orphan if budget ## expires. CLI registry commands have bounded output (a few hundred ## bytes), so we don't bother draining the pipe during the poll loop — ## the kernel buffer absorbs it. ## ## Returns a Dictionary with: ## exit_code: process exit code (0 = success). -1 on timeout / spawn failure. ## stdout: captured stdout text. May be partial on timeout. ## stderr: captured stderr text. May be partial on timeout. Empty when ## `capture_stderr` is false. ## output: stdout + (newline + stderr if non-empty). Convenience for ## the common case of "show whatever the CLI said when it ## failed" — `claude mcp add` writes its real diagnostics to ## stderr, so callers that only read `stdout` would surface ## a generic "exit code 1" instead. ## timed_out: true if we killed the process at the wall-clock budget. ## spawn_failed: true if `OS.execute_with_pipe` didn't return a usable PID. const DEFAULT_TIMEOUT_MS := 8000 const _POLL_INTERVAL_MS := 50 const _KILL_GRACE_MS := 500 static func run( exe: String, args: Array, timeout_ms: int = DEFAULT_TIMEOUT_MS, capture_stderr: bool = true ) -> Dictionary: if exe.is_empty(): return _spawn_failed_result() if _uses_blocking_legacy_path(): ## Godot 4.3 keeps the old blocking path because execute_with_pipe ## capture/exit semantics differ there. The bounded timeout/kill ## behavior is available on Godot 4.4+ only. return _run_blocking_legacy(exe, args) return _run_piped(exe, args, timeout_ms, capture_stderr) static func _run_piped( exe: String, args: Array, timeout_ms: int, capture_stderr: bool, ) -> Dictionary: var spawn_exe := exe var spawn_args := args if OS.get_name() == "Windows": var lower := exe.to_lower() if lower.ends_with(".cmd") or lower.ends_with(".bat"): ## CreateProcessW can't launch `.cmd` / `.bat` scripts on its ## own — they're cmd.exe input, not PE binaries. Without this ## wrap, the moment `McpCliFinder` resolves a Node-style shim ## (npm's `claude.cmd`, pnpm's wrappers, …) the next ## `OS.execute_with_pipe` surfaces "Could not create child ## process: ..." in Godot's output log (#251). Passing ## `exe` as a separate argv element keeps spaces in the path ## quoted by Godot's standard quoter — no manual escaping. spawn_exe = "cmd.exe" spawn_args = ["/c", exe] spawn_args.append_array(args) var info := OS.execute_with_pipe(spawn_exe, spawn_args) if info.is_empty(): return _spawn_failed_result() var pid: int = int(info.get("pid", -1)) var stdio: Variant = info.get("stdio", null) var stderr_pipe: Variant = info.get("stderr", null) if pid <= 0: _close_pipes(stdio, stderr_pipe) return _spawn_failed_result() var deadline := Time.get_ticks_msec() + maxi(timeout_ms, _POLL_INTERVAL_MS) while OS.is_process_running(pid): if Time.get_ticks_msec() >= deadline: ## Kill before draining: a pipe read can block while the child is ## still alive. Once it exits, drain any buffered partial output. OS.kill(pid) var kill_deadline := Time.get_ticks_msec() + _KILL_GRACE_MS while OS.is_process_running(pid) and Time.get_ticks_msec() < kill_deadline: OS.delay_msec(_POLL_INTERVAL_MS) var partial_stdout := "" var partial_stderr := "" if not OS.is_process_running(pid): partial_stdout = _drain_pipe(stdio) partial_stderr = _drain_pipe(stderr_pipe) if capture_stderr else "" _close_pipes(stdio, stderr_pipe) return { "exit_code": -1, "stdout": partial_stdout, "stderr": partial_stderr, "output": _join_streams(partial_stdout, partial_stderr), "timed_out": true, "spawn_failed": false, } OS.delay_msec(_POLL_INTERVAL_MS) var stdout := _drain_pipe(stdio) var stderr_text := _drain_pipe(stderr_pipe) if capture_stderr else "" _close_pipes(stdio, stderr_pipe) return { "exit_code": OS.get_process_exit_code(pid), "stdout": stdout, "stderr": stderr_text, "output": _join_streams(stdout, stderr_text), "timed_out": false, "spawn_failed": false, } static func _run_blocking_legacy(exe: String, args: Array) -> Dictionary: ## Godot 4.3's OS.execute_with_pipe has capture/exit-code differences ## locked by the 4.3 canary skips in test_cli_exec.gd. Preserve the old ## blocking discovery behavior there so startup-critical probes keep the ## same semantics that worked before the bounded-pipe path landed. var output: Array = [] var exit_code := OS.execute(exe, args, output, true) var lines := PackedStringArray() for line in output: lines.append(str(line)) var stdout := "\n".join(lines) return { "exit_code": exit_code, "stdout": stdout, "stderr": "", "output": stdout, "timed_out": false, "spawn_failed": exit_code == -1, } static func _spawn_failed_result() -> Dictionary: return { "exit_code": -1, "stdout": "", "stderr": "", "output": "", "timed_out": false, "spawn_failed": true, } static func _drain_pipe(pipe: Variant) -> String: if not (pipe is FileAccess): return "" var f := pipe as FileAccess var bytes := PackedByteArray() var max_bytes := 1 << 20 # 1 MiB, far above expected client CLI output. while bytes.size() < max_bytes: var chunk := f.get_buffer(mini(4096, max_bytes - bytes.size())) if chunk.is_empty(): break bytes.append_array(chunk) if f.eof_reached(): break return bytes.get_string_from_utf8() static func _join_streams(stdout: String, stderr_text: String) -> String: ## Most CLIs write their actionable diagnostics to one stream or the ## other, never both — so concatenation gives "the message" without ## the caller having to guess which key to read. Newline-separate so ## callers that grep don't see two lines run together. if stderr_text.is_empty(): return stdout if stdout.is_empty(): return stderr_text return "%s\n%s" % [stdout, stderr_text] static func _close_pipes(stdio: Variant, stderr_pipe: Variant) -> void: if stdio is FileAccess: (stdio as FileAccess).close() if stderr_pipe is FileAccess: (stderr_pipe as FileAccess).close() static func _uses_blocking_legacy_path() -> bool: var version := Engine.get_version_info() var major := int(version.get("major", 4)) var minor := int(version.get("minor", 0)) return major < 4 or (major == 4 and minor < 4)