Replace dasher-pack with unified animation-pack using original Blender bone names
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
## Builds stable, JSON-safe metadata for any class registered in ClassDB.
|
||||
|
||||
const VariantSerializer := preload("res://addons/godot_ai/utils/variant_serializer.gd")
|
||||
|
||||
const DEFAULT_SECTIONS := ["properties", "methods", "signals", "enums", "constants"]
|
||||
const KNOWN_SECTIONS := ["properties", "methods", "signals", "enums", "constants", "inheritors"]
|
||||
const MAX_DEFAULT_ITEMS := 100
|
||||
|
||||
|
||||
static func build(type_name: String, options: Dictionary = {}) -> Dictionary:
|
||||
var sections := _sections(options.get("sections", DEFAULT_SECTIONS))
|
||||
var include_inherited := bool(options.get("include_inherited", false))
|
||||
var include_inheritors := bool(options.get("include_inheritors", false))
|
||||
var offset := max(0, int(options.get("offset", 0)))
|
||||
var limit := int(options.get("limit", MAX_DEFAULT_ITEMS))
|
||||
if limit < 0:
|
||||
limit = MAX_DEFAULT_ITEMS
|
||||
var can_instantiate := ClassDB.can_instantiate(type_name)
|
||||
|
||||
var data := {
|
||||
"class_name": type_name,
|
||||
"engine_version": Engine.get_version_info().get("string", ""),
|
||||
"parent_class": str(ClassDB.get_parent_class(type_name)),
|
||||
"inheritance_chain": _inheritance_chain(type_name),
|
||||
"can_instantiate": can_instantiate,
|
||||
"is_singleton": Engine.has_singleton(type_name),
|
||||
"include_inherited": include_inherited,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
}
|
||||
if include_inheritors or sections.has("inheritors"):
|
||||
_add_paged(data, "inheritor", "inheritors", _inheritors(type_name, false), offset, limit)
|
||||
_add_paged(
|
||||
data,
|
||||
"concrete_inheritor",
|
||||
"concrete_inheritors",
|
||||
_inheritors(type_name, true),
|
||||
offset,
|
||||
limit
|
||||
)
|
||||
if sections.has("properties"):
|
||||
_add_paged(data, "property", "properties", _properties(type_name, include_inherited), offset, limit)
|
||||
if sections.has("methods"):
|
||||
_add_paged(data, "method", "methods", _methods(type_name, include_inherited), offset, limit)
|
||||
if sections.has("signals"):
|
||||
_add_paged(data, "signal", "signals", _signals(type_name, include_inherited), offset, limit)
|
||||
if sections.has("enums"):
|
||||
_add_paged(data, "enum", "enums", _enums(type_name, include_inherited), offset, limit)
|
||||
if sections.has("constants"):
|
||||
_add_paged(
|
||||
data,
|
||||
"constant",
|
||||
"constants",
|
||||
_unscoped_constants(type_name, include_inherited),
|
||||
offset,
|
||||
limit
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
static func validate_sections(raw_sections: Variant) -> Dictionary:
|
||||
var sections := _sections(raw_sections)
|
||||
var invalid: Array[String] = []
|
||||
for section in sections:
|
||||
if not KNOWN_SECTIONS.has(section):
|
||||
invalid.append(section)
|
||||
return {"sections": sections, "invalid": invalid}
|
||||
|
||||
|
||||
static func _inheritance_chain(type_name: String) -> Array[String]:
|
||||
var chain: Array[String] = []
|
||||
var current := type_name
|
||||
while not current.is_empty():
|
||||
chain.append(current)
|
||||
current = str(ClassDB.get_parent_class(current))
|
||||
return chain
|
||||
|
||||
|
||||
static func _sections(raw_sections: Variant) -> Array[String]:
|
||||
var result: Array[String] = []
|
||||
var values: Array = []
|
||||
if raw_sections is String:
|
||||
values = raw_sections.split(",", false)
|
||||
elif raw_sections is Array:
|
||||
values = raw_sections
|
||||
else:
|
||||
values = DEFAULT_SECTIONS
|
||||
for raw_section in values:
|
||||
var section := str(raw_section).strip_edges().to_lower()
|
||||
if not section.is_empty() and not result.has(section):
|
||||
result.append(section)
|
||||
if result.is_empty():
|
||||
result.assign(DEFAULT_SECTIONS)
|
||||
return result
|
||||
|
||||
|
||||
static func _add_paged(
|
||||
data: Dictionary,
|
||||
singular: String,
|
||||
key: String,
|
||||
items: Array,
|
||||
offset: int,
|
||||
limit: int
|
||||
) -> void:
|
||||
var end := items.size() if limit == 0 else min(items.size(), offset + limit)
|
||||
var page: Array = []
|
||||
if offset < items.size():
|
||||
page = items.slice(offset, end)
|
||||
data[key] = page
|
||||
data["%s_count" % singular] = items.size()
|
||||
data["%s_returned_count" % singular] = page.size()
|
||||
|
||||
|
||||
static func _inheritors(type_name: String, concrete_only: bool) -> Array[String]:
|
||||
var result: Array[String] = []
|
||||
for inheritor in ClassDB.get_inheriters_from_class(type_name):
|
||||
var inheritor_name := str(inheritor)
|
||||
if concrete_only and not ClassDB.can_instantiate(inheritor_name):
|
||||
continue
|
||||
result.append(inheritor_name)
|
||||
result.sort()
|
||||
return result
|
||||
|
||||
|
||||
static func _properties(type_name: String, include_inherited: bool) -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for raw_prop in ClassDB.class_get_property_list(type_name, not include_inherited):
|
||||
var prop: Dictionary = raw_prop
|
||||
var usage := int(prop.get("usage", 0))
|
||||
if not (usage & PROPERTY_USAGE_EDITOR):
|
||||
continue
|
||||
var prop_name := str(prop.get("name", ""))
|
||||
result.append({
|
||||
"name": prop_name,
|
||||
"type": type_string(int(prop.get("type", TYPE_NIL))),
|
||||
"class_name": str(prop.get("class_name", "")),
|
||||
"hint": int(prop.get("hint", PROPERTY_HINT_NONE)),
|
||||
"hint_string": str(prop.get("hint_string", "")),
|
||||
"usage": usage,
|
||||
"default": VariantSerializer.serialize(
|
||||
ClassDB.class_get_property_default_value(type_name, prop_name)
|
||||
),
|
||||
})
|
||||
result.sort_custom(func(a, b): return a.name < b.name)
|
||||
return result
|
||||
|
||||
|
||||
static func _methods(type_name: String, include_inherited: bool) -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for raw_method in ClassDB.class_get_method_list(type_name, not include_inherited):
|
||||
var method: Dictionary = raw_method
|
||||
var args: Array[Dictionary] = []
|
||||
for raw_arg in method.get("args", []):
|
||||
args.append(_argument_info(raw_arg))
|
||||
var defaults: Array = []
|
||||
for value in method.get("default_args", []):
|
||||
defaults.append(VariantSerializer.serialize(value))
|
||||
result.append({
|
||||
"name": str(method.get("name", "")),
|
||||
"arguments": args,
|
||||
"default_arguments": defaults,
|
||||
"return": _argument_info(method.get("return", {})),
|
||||
"flags": int(method.get("flags", 0)),
|
||||
})
|
||||
result.sort_custom(func(a, b): return a.name < b.name)
|
||||
return result
|
||||
|
||||
|
||||
static func _signals(type_name: String, include_inherited: bool) -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for raw_signal in ClassDB.class_get_signal_list(type_name, not include_inherited):
|
||||
var signal_info: Dictionary = raw_signal
|
||||
var args: Array[Dictionary] = []
|
||||
for raw_arg in signal_info.get("args", []):
|
||||
args.append(_argument_info(raw_arg))
|
||||
var defaults: Array = []
|
||||
for value in signal_info.get("default_args", []):
|
||||
defaults.append(VariantSerializer.serialize(value))
|
||||
result.append({
|
||||
"name": str(signal_info.get("name", "")),
|
||||
"arguments": args,
|
||||
"default_arguments": defaults,
|
||||
"flags": int(signal_info.get("flags", 0)),
|
||||
})
|
||||
result.sort_custom(func(a, b): return a.name < b.name)
|
||||
return result
|
||||
|
||||
|
||||
static func _argument_info(raw_info: Variant) -> Dictionary:
|
||||
var info: Dictionary = raw_info if raw_info is Dictionary else {}
|
||||
return {
|
||||
"name": str(info.get("name", "")),
|
||||
"type": type_string(int(info.get("type", TYPE_NIL))),
|
||||
"class_name": str(info.get("class_name", "")),
|
||||
"hint": int(info.get("hint", PROPERTY_HINT_NONE)),
|
||||
"hint_string": str(info.get("hint_string", "")),
|
||||
"usage": int(info.get("usage", 0)),
|
||||
}
|
||||
|
||||
|
||||
static func _enums(type_name: String, include_inherited: bool) -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
var enum_names: Array[String] = []
|
||||
for enum_name in ClassDB.class_get_enum_list(type_name, not include_inherited):
|
||||
enum_names.append(str(enum_name))
|
||||
enum_names.sort()
|
||||
for enum_name in enum_names:
|
||||
var values: Array[Dictionary] = []
|
||||
for constant_name in ClassDB.class_get_enum_constants(type_name, enum_name, not include_inherited):
|
||||
values.append({
|
||||
"name": str(constant_name),
|
||||
"value": ClassDB.class_get_integer_constant(type_name, constant_name),
|
||||
})
|
||||
values.sort_custom(func(a, b): return a.name < b.name)
|
||||
result.append({
|
||||
"name": enum_name,
|
||||
"is_bitfield": ClassDB.is_class_enum_bitfield(type_name, enum_name, not include_inherited),
|
||||
"values": values,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
static func _unscoped_constants(type_name: String, include_inherited: bool) -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for constant_name in ClassDB.class_get_integer_constant_list(type_name, not include_inherited):
|
||||
var enum_name := str(
|
||||
ClassDB.class_get_integer_constant_enum(type_name, constant_name, not include_inherited)
|
||||
)
|
||||
if not enum_name.is_empty():
|
||||
continue
|
||||
result.append({
|
||||
"name": str(constant_name),
|
||||
"value": ClassDB.class_get_integer_constant(type_name, constant_name),
|
||||
})
|
||||
result.sort_custom(func(a, b): return a.name < b.name)
|
||||
return result
|
||||
@@ -0,0 +1 @@
|
||||
uid://caedbsmsl6fk4
|
||||
@@ -0,0 +1,104 @@
|
||||
@tool
|
||||
class_name McpEditorLogBuffer
|
||||
extends McpStructuredLogRing
|
||||
|
||||
## Ring buffer for editor-process script errors and warnings (parse errors,
|
||||
## @tool runtime errors, EditorPlugin errors, push_error/push_warning) captured
|
||||
## by editor_logger.gd's Logger subclass.
|
||||
##
|
||||
## Smaller cap than McpGameLogBuffer (500 vs 2000) — the editor only emits errors,
|
||||
## not the full println firehose a game can produce. No run_id rotation: editor
|
||||
## errors persist across project_run cycles (they're about *editing* state, not
|
||||
## about the playing game).
|
||||
##
|
||||
## Mutex-protected because Logger virtuals can fire from any thread (e.g.
|
||||
## async script-loader threads emitting parse errors), and the buffer is
|
||||
## read on the main thread by EditorHandler.get_logs. Each public method
|
||||
## wraps the base ring's lockless helpers in `_mutex.lock()/unlock()` —
|
||||
## the base stays lockless so McpGameLogBuffer's hot path doesn't pay an
|
||||
## unused mutex cost.
|
||||
##
|
||||
## Entry shape: {source: "editor", level: "info"|"warn"|"error",
|
||||
## text, path, line, function} — `path/line/function` may be empty/zero
|
||||
## when the source location wasn't recoverable (e.g. printerr from a
|
||||
## thread without a script context).
|
||||
|
||||
const MAX_LINES := 500
|
||||
|
||||
var _mutex := Mutex.new()
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
super._init(MAX_LINES)
|
||||
|
||||
|
||||
func append(level: String, text: String, path: String = "", line: int = 0, function: String = "", details: Dictionary = {}) -> void:
|
||||
var entry := {
|
||||
"source": "editor",
|
||||
"level": _coerce_level(level),
|
||||
"text": text,
|
||||
"path": path,
|
||||
"line": line,
|
||||
"function": function,
|
||||
}
|
||||
if not details.is_empty():
|
||||
entry["details"] = details.duplicate(true)
|
||||
_mutex.lock()
|
||||
_append_entry(entry)
|
||||
_mutex.unlock()
|
||||
|
||||
|
||||
func get_range(offset: int, count: int) -> Array[Dictionary]:
|
||||
_mutex.lock()
|
||||
var out := _get_range_unlocked(offset, count)
|
||||
_mutex.unlock()
|
||||
return out
|
||||
|
||||
|
||||
func get_recent(count: int) -> Array[Dictionary]:
|
||||
## Single-lock so the size we compute `start` from can't race against
|
||||
## a concurrent append between the size read and the slice copy.
|
||||
_mutex.lock()
|
||||
var size := _total_count_unlocked()
|
||||
var start := maxi(0, size - count)
|
||||
var out := _get_range_unlocked(start, size - start)
|
||||
_mutex.unlock()
|
||||
return out
|
||||
|
||||
|
||||
func get_since(since_seq: int, limit: int = -1) -> Dictionary:
|
||||
## Single-lock so the cursor snapshot and slice copy can't race against a
|
||||
## Logger-thread append.
|
||||
_mutex.lock()
|
||||
var out := _get_since_unlocked(since_seq, limit)
|
||||
_mutex.unlock()
|
||||
return out
|
||||
|
||||
|
||||
func total_count() -> int:
|
||||
_mutex.lock()
|
||||
var n := _total_count_unlocked()
|
||||
_mutex.unlock()
|
||||
return n
|
||||
|
||||
|
||||
func dropped_count() -> int:
|
||||
_mutex.lock()
|
||||
var n := _dropped_count_unlocked()
|
||||
_mutex.unlock()
|
||||
return n
|
||||
|
||||
|
||||
func appended_total() -> int:
|
||||
_mutex.lock()
|
||||
var n := _appended_total_unlocked()
|
||||
_mutex.unlock()
|
||||
return n
|
||||
|
||||
|
||||
func clear() -> int:
|
||||
_mutex.lock()
|
||||
var n := _total_count_unlocked()
|
||||
_clear_storage()
|
||||
_mutex.unlock()
|
||||
return n
|
||||
@@ -0,0 +1 @@
|
||||
uid://b6ynms0856hhq
|
||||
@@ -0,0 +1,84 @@
|
||||
@tool
|
||||
class_name McpErrorCodes
|
||||
extends RefCounted
|
||||
|
||||
## Error code constants shared across handlers. Mirrors protocol/errors.py.
|
||||
##
|
||||
## This `class_name` shipped in v2.3.2 and earlier and must stay reachable
|
||||
## through self-update. v2.4.1 dropped it and triggered a "Could not resolve
|
||||
## script" cascade for every user upgrading from any earlier version; v2.4.2
|
||||
## restored it as a hot-fix. The cascade fires because Godot keeps stale
|
||||
## registry entries during the disable -> extract -> enable window when a
|
||||
## previously-registered class_name disappears, and that failure mode is
|
||||
## independent of the runner's install ordering. See CLAUDE.md's
|
||||
## never-delete-published-class_name policy for the shape-aware shim path
|
||||
## that retirement (if ever needed) must follow.
|
||||
##
|
||||
## All consumers use the preload-alias pattern
|
||||
## (`const ErrorCodes := preload(...)`) introduced in #412. The alias is
|
||||
## stylistic; both `McpErrorCodes.X` and `ErrorCodes.X` resolve through the
|
||||
## same Script object cache, so the alias is not a parse-safety boundary
|
||||
## under the single-phase runner.
|
||||
|
||||
const INVALID_PARAMS := "INVALID_PARAMS"
|
||||
const EDITED_SCENE_MISMATCH := "EDITED_SCENE_MISMATCH"
|
||||
const EDITOR_NOT_READY := "EDITOR_NOT_READY"
|
||||
const UNKNOWN_COMMAND := "UNKNOWN_COMMAND"
|
||||
const INTERNAL_ERROR := "INTERNAL_ERROR"
|
||||
const DEFERRED_TIMEOUT := "DEFERRED_TIMEOUT"
|
||||
# game_eval failure codes (#490) — keep in sync with protocol/errors.py
|
||||
const EVAL_COMPILE_ERROR := "EVAL_COMPILE_ERROR"
|
||||
const EVAL_RUNTIME_ERROR := "EVAL_RUNTIME_ERROR"
|
||||
## #518: the play session is up (EditorInterface.is_playing_scene() is true, so
|
||||
## editor_handler's EDITOR_NOT_READY "game is not running" gate already passed)
|
||||
## but the game-side _mcp_game_helper autoload never registered its debugger
|
||||
## capture within EVAL_READY_WAIT_SEC. Carved out of INTERNAL_ERROR so this
|
||||
## boot-window / missing-autoload race stops masquerading as the opaque "eval
|
||||
## hung" 10s timeout in telemetry — the same split #490 made for compile/runtime
|
||||
## errors. NOT a hang: it fires fast (~3s) and is caller-actionable (let the game
|
||||
## finish booting and retry, or check the autoload is enabled).
|
||||
const EVAL_GAME_NOT_READY := "EVAL_GAME_NOT_READY"
|
||||
## audit-v2 #21 (issue #365): finer-grained codes carved out of the 471
|
||||
## INVALID_PARAMS sites so agents can distinguish recoverable input
|
||||
## errors from structural ones. INVALID_PARAMS stays for genuinely
|
||||
## catch-all input errors that don't fit any of the buckets below.
|
||||
##
|
||||
## - NODE_NOT_FOUND: scene-tree/autoload node lookup failed (path didn't
|
||||
## resolve to a Node).
|
||||
## - RESOURCE_NOT_FOUND: a `res://` path lookup failed (file/.tres/
|
||||
## .gdshader/.tscn etc. doesn't exist or couldn't load). Distinct from
|
||||
## NODE_NOT_FOUND because the recovery path differs — agents need to
|
||||
## know whether to fix a node path vs. create/import a resource.
|
||||
## - PROPERTY_NOT_ON_CLASS: property/signal/method/uniform/slot lookup
|
||||
## failed on a known instance (path resolved, but the requested
|
||||
## member doesn't exist on that class).
|
||||
## - VALUE_OUT_OF_RANGE: numeric/index bound violation OR enum value
|
||||
## not in the allowed set.
|
||||
## - WRONG_TYPE: input was a value (or a loaded resource) of the wrong
|
||||
## type — the param was provided, but `typeof` or `is X` failed.
|
||||
## - MISSING_REQUIRED_PARAM: required input field was absent or empty.
|
||||
const NODE_NOT_FOUND := "NODE_NOT_FOUND"
|
||||
const RESOURCE_NOT_FOUND := "RESOURCE_NOT_FOUND"
|
||||
const PROPERTY_NOT_ON_CLASS := "PROPERTY_NOT_ON_CLASS"
|
||||
const VALUE_OUT_OF_RANGE := "VALUE_OUT_OF_RANGE"
|
||||
const WRONG_TYPE := "WRONG_TYPE"
|
||||
const MISSING_REQUIRED_PARAM := "MISSING_REQUIRED_PARAM"
|
||||
|
||||
|
||||
## Build a standard error response dictionary.
|
||||
static func make(code: String, message: String) -> Dictionary:
|
||||
return {"status": "error", "error": {"code": code, "message": message}}
|
||||
|
||||
|
||||
## Return a NEW error dict with the original code and a prefixed message.
|
||||
## Prefer this over mutating `err["error"]["message"]` in place — callers
|
||||
## that want to add context ("Property '%s': …") shouldn't need to know
|
||||
## the internal shape of the dict returned by `make`. Empty `prefix`
|
||||
## returns `err` unchanged so callers don't need their own guard.
|
||||
static func prefix_message(err: Dictionary, prefix: String) -> Dictionary:
|
||||
if prefix.is_empty():
|
||||
return err
|
||||
var inner: Dictionary = err.get("error", {})
|
||||
var code: String = inner.get("code", INTERNAL_ERROR)
|
||||
var message: String = inner.get("message", "")
|
||||
return make(code, "%s: %s" % [prefix, message])
|
||||
@@ -0,0 +1 @@
|
||||
uid://d2klnglf5p861
|
||||
@@ -0,0 +1,39 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
## Shared fuzzy ranking for typo suggestions.
|
||||
|
||||
|
||||
static func rank(
|
||||
needle: String,
|
||||
candidates: Array,
|
||||
limit: int = 5,
|
||||
threshold: float = 0.4,
|
||||
substring_bonus: float = 0.5,
|
||||
prefix_bonus: float = 1.0
|
||||
) -> Array[String]:
|
||||
if needle.is_empty() or candidates.is_empty():
|
||||
return []
|
||||
var needle_lower := needle.to_lower()
|
||||
var scored: Array = []
|
||||
for raw_candidate in candidates:
|
||||
var candidate := str(raw_candidate)
|
||||
var candidate_lower := candidate.to_lower()
|
||||
var score := needle.similarity(candidate)
|
||||
if prefix_bonus != 0.0 and candidate_lower.begins_with(needle_lower):
|
||||
score += prefix_bonus
|
||||
elif substring_bonus != 0.0 and (
|
||||
candidate_lower.contains(needle_lower) or needle_lower.contains(candidate_lower)
|
||||
):
|
||||
score += substring_bonus
|
||||
if score >= threshold:
|
||||
scored.append([score, candidate])
|
||||
scored.sort_custom(func(a, b):
|
||||
if a[0] == b[0]:
|
||||
return a[1] < b[1]
|
||||
return a[0] > b[0]
|
||||
)
|
||||
var result: Array[String] = []
|
||||
for index in range(min(limit, scored.size())):
|
||||
result.append(scored[index][1])
|
||||
return result
|
||||
@@ -0,0 +1 @@
|
||||
uid://bxwaws6w0xw60
|
||||
@@ -0,0 +1,50 @@
|
||||
@tool
|
||||
class_name McpGameLogBuffer
|
||||
extends McpStructuredLogRing
|
||||
|
||||
## Ring buffer for game-process log lines (print, push_warning, push_error)
|
||||
## ferried back from the playing game over the EngineDebugger channel.
|
||||
##
|
||||
## Larger cap than McpEditorLogBuffer because games can be noisy. `run_id`
|
||||
## rotates each time clear_for_new_run() fires (called on the game's
|
||||
## mcp:hello boot beacon), giving agents a stable cursor for "lines since
|
||||
## this play started".
|
||||
##
|
||||
## Single-threaded — game_helper.gd drains its logger from `_process` and
|
||||
## calls `append` from the main thread, so this subclass can use the base
|
||||
## ring's lockless reads/writes directly.
|
||||
|
||||
const MAX_LINES := 2000
|
||||
|
||||
var _run_id := ""
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
super._init(MAX_LINES)
|
||||
|
||||
|
||||
func append(level: String, text: String, details: Dictionary = {}) -> void:
|
||||
var entry := {"source": "game", "level": _coerce_level(level), "text": text}
|
||||
if not details.is_empty():
|
||||
entry["details"] = details.duplicate(true)
|
||||
_append_entry(entry)
|
||||
|
||||
|
||||
## Rotate the run identifier and drop all buffered entries. Called when the
|
||||
## game-side autoload sends its mcp:hello beacon, marking a fresh play cycle.
|
||||
## Returns the new run_id.
|
||||
func clear_for_new_run() -> String:
|
||||
_clear_storage()
|
||||
_run_id = _generate_run_id()
|
||||
return _run_id
|
||||
|
||||
|
||||
func run_id() -> String:
|
||||
return _run_id
|
||||
|
||||
|
||||
static func _generate_run_id() -> String:
|
||||
## Opaque to agents — they only check equality. Time-based is plenty
|
||||
## unique within a single editor session and avoids the RNG-seed
|
||||
## reproducibility footgun.
|
||||
return "r%d" % Time.get_ticks_msec()
|
||||
@@ -0,0 +1 @@
|
||||
uid://biojw0xl64haw
|
||||
@@ -0,0 +1,113 @@
|
||||
@tool
|
||||
class_name McpLogBacktrace
|
||||
extends RefCounted
|
||||
|
||||
## Helpers for interpreting Godot's `_log_error` virtual arguments.
|
||||
## (Named `McpLogBacktrace`, not `ScriptBacktrace`: Godot ships a built-in
|
||||
## `ScriptBacktrace` class — the type of `script_backtraces[i]` entries
|
||||
## — so class_name'ing ours the same would collide. Verified against
|
||||
## the engine's `--doctool` output in 4.6.)
|
||||
##
|
||||
## Both `editor_logger.gd` and `game_logger.gd` need to:
|
||||
## - Map `error_type` (0=ERROR, 1=WARNING, 2=SCRIPT, 3=SHADER) to a
|
||||
## two-bucket "error" / "warn" string so callers can filter without
|
||||
## consulting the enum.
|
||||
## - Fall back to `code` when `rationale` is empty — single-arg
|
||||
## `push_error("msg")` leaves rationale empty and stuffs the user's
|
||||
## string into `code`; without the fallback the user message is
|
||||
## silently lost. The two-arg form `push_error(code, rationale)`
|
||||
## populates both and rationale wins.
|
||||
## - Remap the source location to the first frame of `script_backtraces[0]`
|
||||
## when present. `push_error` / `push_warning` always report
|
||||
## `file=core/variant/variant_utility.cpp`; the actual user GDScript
|
||||
## caller is in the backtrace.
|
||||
##
|
||||
## Centralising the rules keeps the next push_error semantics shift
|
||||
## (already happened once between 4.5 and 4.6, see PR #78) a one-place
|
||||
## fix instead of a two-place hunt.
|
||||
|
||||
|
||||
## Coalesce the per-virtual-arg shape Godot hands `_log_error` into a
|
||||
## flat record. Always walks `script_backtraces` for the first non-empty
|
||||
## frame; loggers that need to filter by source path call this first and
|
||||
## then check the resolved `path` field.
|
||||
##
|
||||
## Returns: `{level, message, path, line, function, details}`
|
||||
## - `level`: "error" or "warn" (warn iff `error_type == 1`).
|
||||
## - `message`: `rationale` when non-empty, else `code`.
|
||||
## - `path` / `line` / `function`: first backtrace frame when one is
|
||||
## available; otherwise the original `file` / `line` / `function`.
|
||||
## - `details`: original `_log_error` fields plus the first non-empty
|
||||
## backtrace as frames, mirroring the debugger Errors tab context.
|
||||
const ERROR_TYPE_NAMES := {
|
||||
0: "error",
|
||||
1: "warning",
|
||||
2: "script",
|
||||
3: "shader",
|
||||
}
|
||||
|
||||
|
||||
static func resolve_error(
|
||||
function: String,
|
||||
file: String,
|
||||
line: int,
|
||||
code: String,
|
||||
rationale: String,
|
||||
error_type: int,
|
||||
script_backtraces: Array,
|
||||
) -> Dictionary:
|
||||
var src_file := file
|
||||
var src_line := line
|
||||
var src_function := function
|
||||
var frames: Array[Dictionary] = []
|
||||
## First non-empty frame wins, not just `script_backtraces[0]` —
|
||||
## chained errors can leave the leading entry empty with the actual
|
||||
## user frame in `script_backtraces[1]`.
|
||||
for bt in script_backtraces:
|
||||
if bt != null and bt.get_frame_count() > 0:
|
||||
frames = _frames_from_backtrace(bt)
|
||||
src_file = str(frames[0].get("path", ""))
|
||||
src_line = int(frames[0].get("line", 0))
|
||||
src_function = str(frames[0].get("function", ""))
|
||||
break
|
||||
var message := rationale if not rationale.is_empty() else code
|
||||
return {
|
||||
"level": "warn" if error_type == 1 else "error",
|
||||
"message": message,
|
||||
"path": src_file,
|
||||
"line": src_line,
|
||||
"function": src_function,
|
||||
"details": {
|
||||
"message": message,
|
||||
"code": code,
|
||||
"rationale": rationale,
|
||||
"error_type": error_type,
|
||||
"error_type_name": _error_type_name(error_type),
|
||||
"source": {
|
||||
"path": file,
|
||||
"line": line,
|
||||
"function": function,
|
||||
},
|
||||
"resolved": {
|
||||
"path": src_file,
|
||||
"line": src_line,
|
||||
"function": src_function,
|
||||
},
|
||||
"frames": frames,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
static func _frames_from_backtrace(bt) -> Array[Dictionary]:
|
||||
var frames: Array[Dictionary] = []
|
||||
for i in bt.get_frame_count():
|
||||
frames.append({
|
||||
"path": bt.get_frame_file(i),
|
||||
"line": bt.get_frame_line(i),
|
||||
"function": bt.get_frame_function(i),
|
||||
})
|
||||
return frames
|
||||
|
||||
|
||||
static func _error_type_name(error_type: int) -> String:
|
||||
return str(ERROR_TYPE_NAMES.get(error_type, "unknown"))
|
||||
@@ -0,0 +1 @@
|
||||
uid://b8t9kznr2pqxa
|
||||
@@ -0,0 +1,63 @@
|
||||
@tool
|
||||
class_name McpLogBuffer
|
||||
extends RefCounted
|
||||
|
||||
## Ring buffer for MCP log lines. Also prints to Godot console.
|
||||
|
||||
const MAX_LINES := 500
|
||||
|
||||
## When false, `log()` still records into the ring buffer but does not echo the
|
||||
## line to the Godot console. The test runner flips this off for the duration
|
||||
## of a run so negative-path suites (which intentionally drive a 500-line ring
|
||||
## fill and malformed-result error logging) don't bury an all-green run in
|
||||
## console noise. Ring *contents* — what tests assert on via `get_recent()` /
|
||||
## `total_logged()` — are unaffected. Engine-level C++ errors raised by
|
||||
## negative-path tests are not routed through here and still surface.
|
||||
static var console_echo := true
|
||||
|
||||
var _lines: Array[String] = []
|
||||
## Monotonic count of every line ever passed to `log()` since the last
|
||||
## `clear()`. Distinct from `_lines.size()`, which is bounded at MAX_LINES.
|
||||
## Consumers that need to detect "new lines arrived" (e.g. `LogViewer.tick`)
|
||||
## must track this rather than the bounded size — once the ring fills, the
|
||||
## size stays at MAX_LINES on every subsequent append, so a size-based
|
||||
## cursor would freeze and the consumer would stop seeing new entries.
|
||||
var _total_logged: int = 0
|
||||
var enabled := true
|
||||
|
||||
|
||||
func log(msg: String) -> void:
|
||||
var line := "MCP | %s" % msg
|
||||
if enabled and console_echo:
|
||||
print(line)
|
||||
_lines.append(line)
|
||||
if _lines.size() > MAX_LINES:
|
||||
_lines = _lines.slice(-MAX_LINES)
|
||||
_total_logged += 1
|
||||
|
||||
|
||||
func get_recent(count: int = 50) -> Array[String]:
|
||||
var start := maxi(0, _lines.size() - count)
|
||||
var result: Array[String] = []
|
||||
result.assign(_lines.slice(start))
|
||||
return result
|
||||
|
||||
|
||||
func clear() -> void:
|
||||
_lines.clear()
|
||||
## Reset the monotonic counter so a viewer's `seq < _last_seq` shrink
|
||||
## detection still recognizes the clear. Callers that want a cumulative
|
||||
## ever-produced count across clears can wrap their own counter.
|
||||
_total_logged = 0
|
||||
|
||||
|
||||
func total_count() -> int:
|
||||
return _lines.size()
|
||||
|
||||
|
||||
## Monotonic sequence — number of lines ever appended via `log()` since
|
||||
## the last `clear()`. Strictly increases per append, even once the ring
|
||||
## has filled and `total_count()` is pinned at MAX_LINES. See `_total_logged`
|
||||
## for rationale.
|
||||
func total_logged() -> int:
|
||||
return _total_logged
|
||||
@@ -0,0 +1 @@
|
||||
uid://ddkslse7511e6
|
||||
@@ -0,0 +1,23 @@
|
||||
@tool
|
||||
class_name McpAdoptionLabel
|
||||
extends RefCounted
|
||||
|
||||
## Outcome flag for `McpServerLifecycleManager.adopt_compatible_server`.
|
||||
## Distinguishes a same-version managed adoption (we own the PID, can
|
||||
## restart it) from an external compatible adoption (some other plugin
|
||||
## instance / dev server owns the process; we just rendezvoused with it).
|
||||
##
|
||||
## Was a free-form string in PR 5; promoted to constants here because
|
||||
## the seam now spans `server_lifecycle.gd`, `plugin.gd`'s log helper,
|
||||
## the dock's restart-button gating, and the test suite. Stable strings
|
||||
## keep log scrapes and characterization fixtures unaffected.
|
||||
|
||||
## We have a PID we spawned (or re-acquired by reading the managed
|
||||
## record + verifying liveness). `force_restart_server` and
|
||||
## `prepare_for_update_reload` may target this PID.
|
||||
const MANAGED := "managed"
|
||||
|
||||
## A compatible godot-ai server is on the port but we don't own its
|
||||
## PID — likely another plugin instance's spawn, or a developer-run
|
||||
## `godot-ai --reload` server. We reuse it but won't kill it on stop.
|
||||
const EXTERNAL := "external"
|
||||
@@ -0,0 +1 @@
|
||||
uid://klhsu1cuhcue
|
||||
@@ -0,0 +1,98 @@
|
||||
@tool
|
||||
class_name McpClientRefreshState
|
||||
extends RefCounted
|
||||
|
||||
## State machine for the dock's client-status refresh sweep. Single
|
||||
## source of truth — supersedes the seven booleans + deadline previously
|
||||
## scattered across `mcp_dock.gd` (`_client_status_refresh_in_flight`,
|
||||
## `_client_status_refresh_pending`, `_client_status_refresh_pending_force`,
|
||||
## `_client_status_refresh_timed_out`, `_client_status_refresh_started_msec`,
|
||||
## `_client_status_refresh_deferred_until_filesystem_ready`,
|
||||
## `_client_status_refresh_deferred_force`,
|
||||
## `_client_status_refresh_deferred_initial`,
|
||||
## `_client_status_refresh_shutdown_requested`).
|
||||
##
|
||||
## The ints are stable for tests; reordering is a breaking change.
|
||||
|
||||
## No worker running, no pending request. Default state.
|
||||
const IDLE := 0
|
||||
## A refresh request landed but the editor filesystem is busy
|
||||
## (`EditorInterface.get_resource_filesystem().is_scanning()` is true);
|
||||
## the dock parks the request and retries on the next `_process` after
|
||||
## the scan settles. Held alongside two flags (force / initial) for
|
||||
## what kind of refresh to retry; those live next to the state, not
|
||||
## inside it, because they're requests not state.
|
||||
const DEFERRED_FOR_FILESYSTEM := 1
|
||||
## Worker thread is alive and probing client status off-main. The
|
||||
## dock paints "(checking...)" in the clients summary and accepts
|
||||
## additional requests as `pending`.
|
||||
const RUNNING := 2
|
||||
## Worker has been alive past CLIENT_STATUS_REFRESH_TIMEOUT_MSEC. The
|
||||
## dock paints "(client probe still running)" and a forced refresh is
|
||||
## allowed to abandon the worker into the orphan list and start a new
|
||||
## sweep. The state stays RUNNING after a forced abandon-and-restart.
|
||||
const RUNNING_TIMED_OUT := 3
|
||||
## `_exit_tree` / `_install_update` is draining workers. New refresh
|
||||
## requests are rejected outright. Set once and not cleared (the dock
|
||||
## instance is being torn down).
|
||||
const SHUTTING_DOWN := 4
|
||||
|
||||
const _NAMES := {
|
||||
IDLE: "idle",
|
||||
DEFERRED_FOR_FILESYSTEM: "deferred_for_filesystem",
|
||||
RUNNING: "running",
|
||||
RUNNING_TIMED_OUT: "running_timed_out",
|
||||
SHUTTING_DOWN: "shutting_down",
|
||||
}
|
||||
|
||||
|
||||
static func name_of(state: int) -> String:
|
||||
return _NAMES.get(state, "unknown(%d)" % state)
|
||||
|
||||
|
||||
## True when a worker thread should be alive in this state. Combined
|
||||
## state — RUNNING or RUNNING_TIMED_OUT both have a worker running, but
|
||||
## the timed-out flavor allows a force-refresh to abandon it.
|
||||
static func has_worker_alive(state: int) -> bool:
|
||||
return state == RUNNING or state == RUNNING_TIMED_OUT
|
||||
|
||||
|
||||
## True when the dock should reject new refresh spawns. Used by the
|
||||
## focus-in / manual button / cooldown-timer entrypoints.
|
||||
static func is_blocked_for_spawn(state: int) -> bool:
|
||||
return state == SHUTTING_DOWN
|
||||
|
||||
|
||||
## True when the summary label should show the in-flight badge.
|
||||
static func should_show_checking_badge(state: int) -> bool:
|
||||
return state == RUNNING or state == RUNNING_TIMED_OUT
|
||||
|
||||
|
||||
## Transition table. Same shape as McpServerState — illegal transitions
|
||||
## return false; callers `push_warning` and no-op.
|
||||
static func can_transition(from: int, to: int) -> bool:
|
||||
if from == to:
|
||||
return true
|
||||
## Shutdown is sticky.
|
||||
if from == SHUTTING_DOWN:
|
||||
return false
|
||||
## Anything → SHUTTING_DOWN is legal (drain on _exit_tree / install).
|
||||
if to == SHUTTING_DOWN:
|
||||
return true
|
||||
match from:
|
||||
IDLE:
|
||||
return to == RUNNING or to == DEFERRED_FOR_FILESYSTEM
|
||||
DEFERRED_FOR_FILESYSTEM:
|
||||
## When the filesystem scan settles we either spawn a worker
|
||||
## (RUNNING) or roll back to IDLE if no rows need probing.
|
||||
return to == RUNNING or to == IDLE
|
||||
RUNNING:
|
||||
## Worker finishes -> IDLE. Worker outlives budget ->
|
||||
## RUNNING_TIMED_OUT. Forced respawn after orphan abandon
|
||||
## stays in RUNNING (covered by from == to above).
|
||||
return to == IDLE or to == RUNNING_TIMED_OUT
|
||||
RUNNING_TIMED_OUT:
|
||||
## Late-arriving worker result drops back to IDLE; forced
|
||||
## abandon-and-respawn drops back to RUNNING.
|
||||
return to == IDLE or to == RUNNING
|
||||
return false
|
||||
@@ -0,0 +1 @@
|
||||
uid://dv4tukg6eioww
|
||||
@@ -0,0 +1,189 @@
|
||||
@tool
|
||||
class_name McpServerState
|
||||
extends RefCounted
|
||||
|
||||
## State machine for the plugin's server-spawn / adopt / version-verify
|
||||
## lifecycle. Single source of truth — supersedes the boolean-flag thicket
|
||||
## (`_server_started_this_session`, `_awaiting_server_version`,
|
||||
## `_server_version_deadline_ms`, `_connection_blocked`,
|
||||
## `_can_recover_incompatible`, `_refresh_retried`,
|
||||
## `_adoption_watch_deadline_ms`) and the older terminal-only
|
||||
## McpSpawnState string union.
|
||||
##
|
||||
## The integer values matter — they're what `get_server_status()`
|
||||
## surfaces, what the dock pattern-matches on, and what the test suites
|
||||
## assert against. Reordering the enum is a breaking change.
|
||||
##
|
||||
## The transitions are documented in `can_transition()`. The lifecycle
|
||||
## manager calls `set_state()` which:
|
||||
## 1. Validates the transition (logs a warning + no-ops on illegal).
|
||||
## 2. Preserves first-writer-wins among terminal diagnoses so a late
|
||||
## CRASHED from the watch loop can't clobber an earlier
|
||||
## PORT_EXCLUDED from the proactive Windows reservation check.
|
||||
|
||||
## Fresh plugin instance, `_start_server` has not run yet. Default state.
|
||||
const UNINITIALIZED := 0
|
||||
## Process spawned via OS.create_process; watch loop is observing the
|
||||
## SPAWN_GRACE_MS window. Transitions directly to READY (handshake_ack
|
||||
## verifies a compatible version), CRASHED (process died early), or
|
||||
## INCOMPATIBLE (handshake reported a mismatch).
|
||||
const SPAWNING := 1
|
||||
## (slot 2 reserved — keep wire-compat for clients pattern-matching
|
||||
## numeric `editor_state.state` values; do not reuse.)
|
||||
## Server is healthy and version-verified. Happy path. Includes both
|
||||
## "spawned fresh" and "adopted compatible existing server" flavors —
|
||||
## adoption flavor is recorded separately via `McpAdoptionLabel`.
|
||||
const READY := 3
|
||||
## Live server on the HTTP port returned a version that doesn't match
|
||||
## what this plugin expects, OR returned no `handshake_ack` inside the
|
||||
## timeout. Connection is blocked; recovery requires a kill+respawn
|
||||
## click via `recover_incompatible_server`.
|
||||
const INCOMPATIBLE := 4
|
||||
## Spawned process exited inside the SPAWN_GRACE_MS window. Python
|
||||
## traceback went to Godot's output log. Terminal — reload the plugin
|
||||
## or restart the editor to retry.
|
||||
const CRASHED := 5
|
||||
## No server command resolved: no `.venv` Python, no `uvx` on PATH, no
|
||||
## system `godot-ai`. Terminal — install guidance shown in dock.
|
||||
const NO_COMMAND := 6
|
||||
## Windows reserved the HTTP port via Hyper-V / WSL2 / Docker exclusion
|
||||
## range. Caught proactively before bind. Terminal — port picker shown.
|
||||
const PORT_EXCLUDED := 7
|
||||
## HTTP port held by a process we didn't spawn (no matching managed
|
||||
## record). Plugin armed an adoption-confirmation watcher; if the foreign
|
||||
## occupant turns out to be a compatible godot-ai server,
|
||||
## `handle_server_version_verified` transitions to READY. If the
|
||||
## adoption deadline expires without a connection, the watcher self-
|
||||
## disarms but the state stays at FOREIGN_PORT — the dock keeps showing
|
||||
## "port held by another process" until the user reloads. The version-
|
||||
## check seam (separate from the adoption deadline) is what fires
|
||||
## INCOMPATIBLE on a positive-but-mismatched handshake.
|
||||
const FOREIGN_PORT := 8
|
||||
## Static re-entrancy guard fired (`_server_started_this_session` was
|
||||
## already true). The plugin is being re-enabled within the same editor
|
||||
## session; the previous instance still owns the spawn. Terminal — does
|
||||
## NOT block READY paths, just records that this enable cycle no-op'd.
|
||||
const GUARDED := 9
|
||||
## stop_server / prepare_for_update_reload in progress. Transitional —
|
||||
## next state is STOPPED.
|
||||
const STOPPING := 10
|
||||
## stop_server completed; `_server_pid` reset to -1, port may or may
|
||||
## not be free. From here a fresh `start_server` call moves back through
|
||||
## SPAWNING / READY.
|
||||
const STOPPED := 11
|
||||
|
||||
const _NAMES := {
|
||||
UNINITIALIZED: "uninitialized",
|
||||
SPAWNING: "spawning",
|
||||
READY: "ready",
|
||||
INCOMPATIBLE: "incompatible",
|
||||
CRASHED: "crashed",
|
||||
NO_COMMAND: "no_command",
|
||||
PORT_EXCLUDED: "port_excluded",
|
||||
FOREIGN_PORT: "foreign_port",
|
||||
GUARDED: "guarded",
|
||||
STOPPING: "stopping",
|
||||
STOPPED: "stopped",
|
||||
}
|
||||
|
||||
|
||||
## Human-readable label. Used in startup-trace logs and transition
|
||||
## warnings. Falls back to `unknown(<int>)` for unrecognised values so
|
||||
## a future enum addition won't crash the formatter.
|
||||
static func name_of(state: int) -> String:
|
||||
return _NAMES.get(state, "unknown(%d)" % state)
|
||||
|
||||
|
||||
## True for any state the dock should render as a non-OK diagnostic
|
||||
## panel. Used as the "should we hide the spawn-failure panel?" gate.
|
||||
static func is_terminal_diagnosis(state: int) -> bool:
|
||||
return (
|
||||
state == CRASHED
|
||||
or state == NO_COMMAND
|
||||
or state == PORT_EXCLUDED
|
||||
or state == INCOMPATIBLE
|
||||
or state == FOREIGN_PORT
|
||||
)
|
||||
|
||||
|
||||
## True only for READY. Other "ok-ish" states (SPAWNING) are still in
|
||||
## flight; READY is the only state where the plugin can treat the server
|
||||
## as fully healthy.
|
||||
static func is_healthy(state: int) -> bool:
|
||||
return state == READY
|
||||
|
||||
|
||||
## True when the dock should consider the server unsuitable for client
|
||||
## health checks (incompatible tool surface). Currently just INCOMPATIBLE
|
||||
## — FOREIGN_PORT is transitional and may resolve to READY if the
|
||||
## foreign occupant turns out to speak our handshake.
|
||||
static func blocks_client_health(state: int) -> bool:
|
||||
return state == INCOMPATIBLE
|
||||
|
||||
|
||||
## Transition validation table. Returns true when `from -> to` is a
|
||||
## legal transition the lifecycle manager should accept. Illegal
|
||||
## transitions are silently no-op'd at the call site (with a
|
||||
## `push_warning` log) — this preserves the first-writer-wins contract
|
||||
## that prevents a late CRASHED from the watch loop overwriting an
|
||||
## earlier PORT_EXCLUDED diagnosis.
|
||||
static func can_transition(from: int, to: int) -> bool:
|
||||
if from == to:
|
||||
return true
|
||||
## Stop is always legal — teardown / install reload short-circuits
|
||||
## any in-flight state.
|
||||
if to == STOPPING:
|
||||
return true
|
||||
if to == STOPPED and from == STOPPING:
|
||||
return true
|
||||
## STOPPED can also be reached directly when `_server_pid <= 0` and
|
||||
## stop_server early-returns; treat it as legal from any state to
|
||||
## keep the teardown path forgiving.
|
||||
if to == STOPPED:
|
||||
return true
|
||||
## STOPPED -> any (re-arm via restart paths).
|
||||
if from == STOPPED:
|
||||
return true
|
||||
## GUARDED is sticky for the rest of this enable cycle; only stop is
|
||||
## legal out of it. Already covered by the stop checks above.
|
||||
if from == GUARDED:
|
||||
return false
|
||||
## Terminal diagnoses freeze further forward transitions. Recovery
|
||||
## goes through STOPPING (covered above), so any other target is
|
||||
## rejected — this is the first-writer-wins contract.
|
||||
if (
|
||||
from == CRASHED
|
||||
or from == NO_COMMAND
|
||||
or from == PORT_EXCLUDED
|
||||
or from == INCOMPATIBLE
|
||||
):
|
||||
return false
|
||||
## UNINITIALIZED is the boot state — any target except STOPPING is
|
||||
## reachable directly (start_server's early branches set
|
||||
## terminal states without going through SPAWNING).
|
||||
if from == UNINITIALIZED:
|
||||
return true
|
||||
## In-flight forward transitions.
|
||||
match from:
|
||||
SPAWNING:
|
||||
return (
|
||||
to == READY
|
||||
or to == CRASHED
|
||||
or to == FOREIGN_PORT
|
||||
or to == INCOMPATIBLE
|
||||
)
|
||||
FOREIGN_PORT:
|
||||
return to == READY or to == INCOMPATIBLE
|
||||
READY:
|
||||
## Late incompatibility detection (e.g. version verifier
|
||||
## re-arms after a foreign-port reconnect that turns out
|
||||
## to be incompatible after all).
|
||||
return to == INCOMPATIBLE or to == CRASHED
|
||||
STOPPING:
|
||||
## Recovery rollback: kill-then-respawn paths that fail to
|
||||
## free the port re-latch INCOMPATIBLE (so the dock keeps
|
||||
## the diagnostic UI) or fall back to UNINITIALIZED (clean
|
||||
## baseline for a follow-up `_set_incompatible_server`).
|
||||
## STOPPING -> STOPPED is handled by the early checks above.
|
||||
return to == INCOMPATIBLE or to == UNINITIALIZED
|
||||
return false
|
||||
@@ -0,0 +1 @@
|
||||
uid://d3ial4erjonlq
|
||||
@@ -0,0 +1,34 @@
|
||||
@tool
|
||||
class_name McpStartupPath
|
||||
extends RefCounted
|
||||
|
||||
## Branch-tag enum for `McpServerLifecycleManager.start_server`. Records
|
||||
## which arm of the spawn / adopt / drift / recover decision tree the
|
||||
## current `_enter_tree` walked. Surfaced via the startup trace log so
|
||||
## a Windows port-reservation issue or a stale-record kill can be
|
||||
## reconstructed from the editor output.
|
||||
##
|
||||
## Single-file constants, not an int enum, because the values land in
|
||||
## startup-trace text and the strings are stable across releases (the
|
||||
## CLAUDE.md "tool surface" entry references them by name).
|
||||
|
||||
const UNSET := ""
|
||||
## Re-entrancy guard fired; this enable cycle did not spawn or adopt.
|
||||
const GUARDED := "guarded"
|
||||
## Adopted a compatible existing server (managed or external).
|
||||
const ADOPTED := "adopted"
|
||||
## Spawned a fresh server process.
|
||||
const SPAWNED := "spawned"
|
||||
## OS.create_process returned -1 or proactive Windows reservation
|
||||
## detected. Either way the spawn never produced a live process.
|
||||
const CRASHED := "crashed"
|
||||
## Windows port-exclusion check fired — port is blocked at the OS layer.
|
||||
const RESERVED := "reserved"
|
||||
## Server-command discovery returned an empty list — no .venv, no uvx,
|
||||
## no system godot-ai.
|
||||
const NO_COMMAND := "no_command"
|
||||
## Drift-recovery kill fell through; we set INCOMPATIBLE and stayed.
|
||||
const INCOMPATIBLE := "incompatible"
|
||||
## Port was free at start; this is the prelude to SPAWNED but kept as
|
||||
## a distinct path so adopt-vs-spawn is unambiguous in the trace.
|
||||
const FREE := "free"
|
||||
@@ -0,0 +1 @@
|
||||
uid://cikdvq2x4vs4x
|
||||
@@ -0,0 +1,171 @@
|
||||
@tool
|
||||
class_name McpPathValidator
|
||||
extends RefCounted
|
||||
|
||||
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
||||
|
||||
## Validates `res://`-rooted paths against directory-traversal escape.
|
||||
##
|
||||
## Issue #347 (audit-v2 #3): handlers were accepting `res://../etc/passwd.gd`
|
||||
## because the only check was `path.begins_with("res://")`. LLM-driven path
|
||||
## generation (prompt injection, agent typos, untrusted issue/PR text in
|
||||
## context) can produce traversal payloads for the write tools that produce
|
||||
## arbitrary disk content (`script_create`, `filesystem_write_text`,
|
||||
## `patch_script`) and for the matching reads (info disclosure surface).
|
||||
##
|
||||
## Two entry points:
|
||||
## * `validate_resource_path` — for paths that name a `res://` disk file the
|
||||
## plugin will read or (with `for_write`) write. This is the strict one.
|
||||
## * `validate_loadable_path` — for paths handed to `ResourceLoader`, which
|
||||
## also accepts `uid://` (an opaque resource-DB id that cannot express
|
||||
## traversal) and `user://` (the per-project user data sandbox). Load
|
||||
## handlers must use this so `uid://` references copied out of `.tscn`
|
||||
## ExtResource / `.uid` sidecars and `user://` runtime assets keep loading.
|
||||
##
|
||||
## Error wrapping: callers should use `path_error` / `loadable_error`, which
|
||||
## return a ready `ErrorCodes.make(VALUE_OUT_OF_RANGE, …)` dict (or null). A
|
||||
## bad path is a value-domain error, and funneling every site through one
|
||||
## wrapper keeps the error code consistent across all handlers.
|
||||
##
|
||||
## Known limitation: containment is lexical (`globalize_path` + `simplify_path`
|
||||
## prefix match). It does NOT resolve symlinks — GDScript exposes no realpath.
|
||||
## A symlink *inside* the project that points outside it can therefore defeat
|
||||
## the under-root check. This matches the engine's own `res://` resolution and
|
||||
## is accepted; the loopback trust boundary is the primary control.
|
||||
|
||||
|
||||
# Cached project / user roots. `globalize_path` is stable across the editor's
|
||||
# lifetime — caching avoids redundant resolution on every call. Matters most
|
||||
# for `reimport`, which loops the validator over each path in a batch.
|
||||
# Lazy-init on first call so static-load timing can't see a half-initialised
|
||||
# ProjectSettings.
|
||||
static var _cached_res_root: String = ""
|
||||
static var _cached_user_root: String = ""
|
||||
|
||||
|
||||
static func _res_root() -> String:
|
||||
if _cached_res_root.is_empty():
|
||||
_cached_res_root = ProjectSettings.globalize_path("res://").simplify_path()
|
||||
return _cached_res_root
|
||||
|
||||
|
||||
static func _user_root() -> String:
|
||||
if _cached_user_root.is_empty():
|
||||
_cached_user_root = ProjectSettings.globalize_path("user://").simplify_path()
|
||||
return _cached_user_root
|
||||
|
||||
|
||||
## Returns "" when the path is a safe `res://`-rooted reference inside the
|
||||
## project root. Returns a human-readable error message otherwise.
|
||||
## Prefer `path_error` over calling this directly — it wraps the message in the
|
||||
## canonical error code.
|
||||
##
|
||||
## Pass `for_write = true` for any handler that creates/overwrites the file
|
||||
## (write_file, create_script, patch_script, ResourceSaver-backed saves,
|
||||
## scene saves). Write callers additionally refuse the project manifest and
|
||||
## startup override, plus the `.godot/` metadata dir. Reads default to
|
||||
## `for_write = false`, which permits inspecting those files.
|
||||
static func validate_resource_path(path: String, for_write: bool = false) -> String:
|
||||
if path.is_empty():
|
||||
return "Missing required param: path"
|
||||
## Guard the sentinel: on builds where String.chr(0) yields "" (some engines
|
||||
## normalize embedded nulls away, e.g. 4.3), contains("") would be true and
|
||||
## reject every path. A String that can't hold a null can't smuggle one.
|
||||
var nul := String.chr(0)
|
||||
if not nul.is_empty() and path.contains(nul):
|
||||
return "Path must not contain null bytes"
|
||||
if not path.begins_with("res://"):
|
||||
return "Path must start with res://"
|
||||
var confine_err := _confine_under(path, _res_root(), "res://")
|
||||
if not confine_err.is_empty():
|
||||
return confine_err
|
||||
if for_write:
|
||||
return _reject_sensitive_write(path)
|
||||
return ""
|
||||
|
||||
|
||||
## Returns "" when `path` is safe to hand to `ResourceLoader.load` / `.exists`.
|
||||
## Accepts, in addition to confined `res://` paths:
|
||||
## * `uid://<id>` — an opaque 64-bit resource id; it cannot express a path
|
||||
## and the engine only ever resolves it to a resource already in the
|
||||
## project, so there is nothing to confine.
|
||||
## * `user://…` — the per-project user data dir, confined under its root the
|
||||
## same way `res://` is (so `user://../…` can't escape the sandbox).
|
||||
static func validate_loadable_path(path: String) -> String:
|
||||
if path.is_empty():
|
||||
return "Missing required param: path"
|
||||
## Guard the sentinel: on builds where String.chr(0) yields "" (some engines
|
||||
## normalize embedded nulls away, e.g. 4.3), contains("") would be true and
|
||||
## reject every path. A String that can't hold a null can't smuggle one.
|
||||
var nul := String.chr(0)
|
||||
if not nul.is_empty() and path.contains(nul):
|
||||
return "Path must not contain null bytes"
|
||||
if path.begins_with("uid://"):
|
||||
return ""
|
||||
if path.begins_with("user://"):
|
||||
return _confine_under(path, _user_root(), "user://")
|
||||
if path.begins_with("res://"):
|
||||
return _confine_under(path, _res_root(), "res://")
|
||||
return "Path must start with res://, uid://, or user://"
|
||||
|
||||
|
||||
## Shared traversal + under-root containment. `root` must already be simplified.
|
||||
static func _confine_under(path: String, root: String, label: String) -> String:
|
||||
if ".." in path:
|
||||
return "Path must not contain '..' (path traversal not allowed)"
|
||||
var globalized := ProjectSettings.globalize_path(path).simplify_path()
|
||||
# Append a separator so `/proj_evil/...` can't pretend to be inside `/proj`
|
||||
# via prefix match. `globalized == root` covers the bare `res://` / `user://`.
|
||||
if globalized != root and not globalized.begins_with(root + "/"):
|
||||
return "Path must resolve under %s root" % label
|
||||
return ""
|
||||
|
||||
|
||||
## Refuse writes that would clobber project-critical files. The path is already
|
||||
## confirmed `res://`-rooted and traversal-free by the caller.
|
||||
##
|
||||
## Comparisons are case-folded: macOS (APFS) and Windows (NTFS) are
|
||||
## case-insensitive by default, so `res://Project.godot` resolves to the real
|
||||
## `project.godot` and must be refused too.
|
||||
##
|
||||
## `.import` sidecars are deliberately NOT blocked — editing an asset's import
|
||||
## options then re-importing is a legitimate, recoverable workflow (the file is
|
||||
## source-controlled). The blocked set is the startup-execution surface only:
|
||||
## the manifest, its `override.cfg` shadow, and the `.godot/` cache dir.
|
||||
static func _reject_sensitive_write(path: String) -> String:
|
||||
var file_lower := path.get_file().to_lower()
|
||||
if file_lower == "project.godot":
|
||||
return "Refusing to write res://project.godot (project manifest)"
|
||||
if file_lower == "override.cfg":
|
||||
return "Refusing to write res://override.cfg (startup config override)"
|
||||
# Reject the `.godot/` editor-metadata dir at any depth. Split drops empty
|
||||
# segments so a trailing slash can't hide a segment from the check.
|
||||
for segment in path.trim_prefix("res://").split("/", false):
|
||||
if segment.to_lower() == ".godot":
|
||||
return "Refusing to write under res://.godot/ (editor metadata)"
|
||||
return ""
|
||||
|
||||
|
||||
## Validate a write/read `res://` path and return a ready error dict, or null
|
||||
## when the path is fine. The single wrapper every handler should use so the
|
||||
## error code (VALUE_OUT_OF_RANGE — a bad path is a value-domain error) stays
|
||||
## consistent. `param_name` is prefixed onto the message for context.
|
||||
static func path_error(path: String, param_name: String = "path", for_write: bool = false) -> Variant:
|
||||
if path.is_empty():
|
||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % param_name)
|
||||
var err := validate_resource_path(path, for_write)
|
||||
if err.is_empty():
|
||||
return null
|
||||
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "%s: %s" % [param_name, err])
|
||||
|
||||
|
||||
## Same as `path_error` but for paths handed to `ResourceLoader` (allows
|
||||
## `uid://` / `user://`). Returns a ready error dict or null. An empty path is
|
||||
## reported as MISSING_REQUIRED_PARAM rather than a value error.
|
||||
static func loadable_error(path: String, param_name: String = "path") -> Variant:
|
||||
if path.is_empty():
|
||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % param_name)
|
||||
var err := validate_loadable_path(path)
|
||||
if err.is_empty():
|
||||
return null
|
||||
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "%s: %s" % [param_name, err])
|
||||
@@ -0,0 +1 @@
|
||||
uid://blxntmd65ljyu
|
||||
@@ -0,0 +1,315 @@
|
||||
@tool
|
||||
class_name McpPortResolver
|
||||
extends RefCounted
|
||||
|
||||
## Pure-static port discovery / OS-specific scrapers. No instance state,
|
||||
## no editor dependencies. plugin.gd has thin instance shims that wrap
|
||||
## these and increment the cold-start trace counters.
|
||||
|
||||
## Canonical pid-file path. plugin.gd::SERVER_PID_FILE re-exports this so
|
||||
## external readers and tests can use either name.
|
||||
const SERVER_PID_FILE := "user://godot_ai_server.pid"
|
||||
const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd")
|
||||
|
||||
|
||||
static func can_bind_local_port(port: int) -> bool:
|
||||
var server := TCPServer.new()
|
||||
var err := server.listen(port, "127.0.0.1")
|
||||
if err == OK:
|
||||
server.stop()
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
## True when `port` is bound on 127.0.0.1. Probes via TCPServer first,
|
||||
## falls back to OS scraping. Callers that want to bracket the slow
|
||||
## scrape with a trace counter should call `is_port_in_use_via_scrape`
|
||||
## after their own `can_bind_local_port` probe.
|
||||
static func is_port_in_use(port: int) -> bool:
|
||||
if can_bind_local_port(port):
|
||||
## On POSIX, an IPv6 wildcard listener can coexist with a
|
||||
## successful 127.0.0.1 bind probe. Confirm with lsof so startup
|
||||
## sees the same listener set that shutdown/recovery would see.
|
||||
if OS.get_name() != "Windows":
|
||||
return is_port_in_use_via_scrape(port)
|
||||
return false
|
||||
return is_port_in_use_via_scrape(port)
|
||||
|
||||
|
||||
static func is_port_in_use_via_scrape(port: int) -> bool:
|
||||
var output: Array = []
|
||||
if OS.get_name() == "Windows":
|
||||
var exit_code := OS.execute("netstat", ["-ano"], output, true)
|
||||
if exit_code == 0 and output.size() > 0:
|
||||
return parse_windows_netstat_listening(str(output[0]), port)
|
||||
return false
|
||||
var exit_code := OS.execute("lsof", ["-ti:%d" % port, "-sTCP:LISTEN"], output, true)
|
||||
return exit_code == 0 and output.size() > 0 and not output[0].strip_edges().is_empty()
|
||||
|
||||
|
||||
## Return the PID currently listening on the given TCP port, or 0 if
|
||||
## the port is free. Thin convenience wrapper around `find_all_pids_on_port`
|
||||
## — the per-OS scraping logic lives in one place.
|
||||
static func find_pid_on_port(port: int, trace: Callable = Callable()) -> int:
|
||||
var pids := find_all_pids_on_port(port, trace)
|
||||
return pids[0] if not pids.is_empty() else 0
|
||||
|
||||
|
||||
## Returns every PID bound LISTEN on `port`. Used by the kill paths so
|
||||
## both the uvicorn reloader parent AND its worker child are caught when
|
||||
## both bind the same port.
|
||||
##
|
||||
## `trace` is an optional Callable that fires once per OS invocation with
|
||||
## a counter name (`"netstat"` / `"powershell"` / `"lsof"`) so the plugin
|
||||
## can keep its cold-start trace accurate. The Windows path may fall
|
||||
## through netstat → PowerShell, and a wrapping caller can't see which
|
||||
## scraper actually ran without the hook.
|
||||
static func find_all_pids_on_port(port: int, trace: Callable = Callable()) -> Array[int]:
|
||||
if OS.get_name() == "Windows":
|
||||
var output: Array = []
|
||||
_trace(trace, "netstat")
|
||||
var exit_code := OS.execute("netstat", ["-ano"], output, true)
|
||||
if exit_code == 0 and not output.is_empty():
|
||||
var netstat_pids := parse_windows_netstat_pids(str(output[0]), port)
|
||||
if not netstat_pids.is_empty():
|
||||
return netstat_pids
|
||||
_trace(trace, "powershell")
|
||||
return find_listener_pids_windows(port)
|
||||
var output: Array = []
|
||||
_trace(trace, "lsof")
|
||||
var exit_code := OS.execute("lsof", ["-ti:%d" % port, "-sTCP:LISTEN"], output, true)
|
||||
if exit_code != 0 or output.is_empty():
|
||||
var empty: Array[int] = []
|
||||
return empty
|
||||
return parse_lsof_pids(str(output[0]))
|
||||
|
||||
|
||||
static func _trace(trace: Callable, counter: String) -> void:
|
||||
if trace.is_valid():
|
||||
trace.call(counter)
|
||||
|
||||
|
||||
static func find_listener_pids_windows(port: int) -> Array[int]:
|
||||
var script := (
|
||||
"Get-NetTCPConnection -LocalPort %d -State Listen "
|
||||
+ "-ErrorAction SilentlyContinue | "
|
||||
+ "Select-Object -ExpandProperty OwningProcess"
|
||||
) % port
|
||||
var output: Array = []
|
||||
var exit_code := execute_windows_powershell(script, output)
|
||||
return windows_listener_pids_from_execute_result(exit_code, output)
|
||||
|
||||
|
||||
static func execute_windows_powershell(script: String, output: Array) -> int:
|
||||
var args := ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script]
|
||||
for exe in windows_powershell_candidates():
|
||||
output.clear()
|
||||
var exit_code := OS.execute(exe, args, output, true)
|
||||
if exit_code == 0:
|
||||
return exit_code
|
||||
return -1
|
||||
|
||||
|
||||
static func windows_powershell_candidates() -> Array[String]:
|
||||
var candidates: Array[String] = []
|
||||
var system_root := OS.get_environment("SystemRoot")
|
||||
if system_root.is_empty():
|
||||
system_root = "C:/Windows"
|
||||
system_root = system_root.replace("\\", "/").trim_suffix("/")
|
||||
candidates.append(system_root + "/System32/WindowsPowerShell/v1.0/powershell.exe")
|
||||
candidates.append("powershell.exe")
|
||||
candidates.append("pwsh.exe")
|
||||
return candidates
|
||||
|
||||
|
||||
static func windows_listener_pids_from_execute_result(exit_code: int, output: Array) -> Array[int]:
|
||||
var empty: Array[int] = []
|
||||
if exit_code == 0 and not output.is_empty():
|
||||
return parse_pid_lines(str(output[0]))
|
||||
return empty
|
||||
|
||||
|
||||
static func windows_listener_execute_result_in_use(exit_code: int, output: Array) -> bool:
|
||||
return not windows_listener_pids_from_execute_result(exit_code, output).is_empty()
|
||||
|
||||
|
||||
## Pure parser for `lsof -ti` output — newline-separated decimal PIDs.
|
||||
## Empty lines and non-numeric tokens are dropped. Duplicates pass
|
||||
## through (uvicorn reloader + worker can produce the same PID twice
|
||||
## across runs but typically two distinct PIDs).
|
||||
static func parse_lsof_pids(raw: String) -> Array[int]:
|
||||
var pids: Array[int] = []
|
||||
for line in raw.strip_edges().split("\n", false):
|
||||
var stripped := line.strip_edges()
|
||||
if stripped.is_valid_int():
|
||||
pids.append(int(stripped))
|
||||
return pids
|
||||
|
||||
|
||||
static func parse_pid_lines(raw: String) -> Array[int]:
|
||||
var pids: Array[int] = []
|
||||
for line in raw.strip_edges().split("\n", false):
|
||||
var stripped := line.strip_edges()
|
||||
if stripped.is_valid_int():
|
||||
var pid := int(stripped)
|
||||
if pid > 0 and not pids.has(pid):
|
||||
pids.append(pid)
|
||||
return pids
|
||||
|
||||
|
||||
## Parse a Windows `netstat -ano` dump and return PIDs of rows whose
|
||||
## local address ends with `:port` AND state is `LISTENING`. Substring
|
||||
## matching the whole dump is wrong: a remote address containing
|
||||
## `:port` would false-positive against an unrelated ESTABLISHED row.
|
||||
static func parse_windows_netstat_pid(stdout: String, port: int) -> int:
|
||||
var pids := parse_windows_netstat_pids(stdout, port)
|
||||
return pids[0] if not pids.is_empty() else 0
|
||||
|
||||
|
||||
static func parse_windows_netstat_pids(stdout: String, port: int) -> Array[int]:
|
||||
var pids: Array[int] = []
|
||||
var port_suffix := ":%d" % port
|
||||
for line in stdout.split("\n"):
|
||||
var s := line.strip_edges()
|
||||
if s.is_empty():
|
||||
continue
|
||||
var fields := split_on_whitespace(s)
|
||||
if fields.size() < 5: # proto, local, remote, state, pid
|
||||
continue
|
||||
if fields[3] != "LISTENING":
|
||||
continue
|
||||
if not fields[1].ends_with(port_suffix):
|
||||
continue
|
||||
var pid_str := fields[fields.size() - 1]
|
||||
if pid_str.is_valid_int():
|
||||
var pid := int(pid_str)
|
||||
if pid > 0 and not pids.has(pid):
|
||||
pids.append(pid)
|
||||
return pids
|
||||
|
||||
|
||||
static func parse_windows_netstat_listening(stdout: String, port: int) -> bool:
|
||||
return parse_windows_netstat_pid(stdout, port) > 0
|
||||
|
||||
|
||||
## `String.split(" ", false)` only splits on single spaces; netstat
|
||||
## columns are separated by runs of spaces / tabs. Collapse manually.
|
||||
static func split_on_whitespace(s: String) -> PackedStringArray:
|
||||
var out: PackedStringArray = []
|
||||
var cur := ""
|
||||
for i in s.length():
|
||||
var c := s.substr(i, 1)
|
||||
if c == " " or c == "\t":
|
||||
if not cur.is_empty():
|
||||
out.append(cur)
|
||||
cur = ""
|
||||
else:
|
||||
cur += c
|
||||
if not cur.is_empty():
|
||||
out.append(cur)
|
||||
return out
|
||||
|
||||
|
||||
static func read_pid_file() -> int:
|
||||
if not FileAccess.file_exists(SERVER_PID_FILE):
|
||||
return 0
|
||||
var f := FileAccess.open(SERVER_PID_FILE, FileAccess.READ)
|
||||
if f == null:
|
||||
return 0
|
||||
var content := f.get_as_text().strip_edges()
|
||||
f.close()
|
||||
if content.is_empty() or not content.is_valid_int():
|
||||
return 0
|
||||
var pid := int(content)
|
||||
return pid if pid > 0 else 0
|
||||
|
||||
|
||||
static func clear_pid_file() -> void:
|
||||
if FileAccess.file_exists(SERVER_PID_FILE):
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(SERVER_PID_FILE))
|
||||
|
||||
|
||||
## `kill -0` returns 0 for both running and zombie processes; Godot
|
||||
## never `waitpid`s on `OS.create_process` children, so a fast-failing
|
||||
## uvx launcher lingers as a zombie forever and `kill -0` would block
|
||||
## the spawn-failure branch in check_server_health from firing. Use
|
||||
## `ps -o stat=` instead. State codes: R/S/D/I/T (live), Z (zombie). #172.
|
||||
static func pid_alive(pid: int) -> bool:
|
||||
if pid <= 0:
|
||||
return false
|
||||
if OS.get_name() == "Windows":
|
||||
var output: Array = []
|
||||
var exit_code := OS.execute("tasklist", ["/FI", "PID eq %d" % pid, "/NH", "/FO", "CSV"], output, true)
|
||||
if exit_code != 0 or output.is_empty():
|
||||
return false
|
||||
for line in output:
|
||||
if str(line).find("\"%d\"" % pid) >= 0:
|
||||
return true
|
||||
return false
|
||||
var output: Array = []
|
||||
var exit_code := OS.execute("ps", ["-p", str(pid), "-o", "stat="], output, true)
|
||||
if exit_code != 0 or output.is_empty():
|
||||
return false
|
||||
var stat := str(output[0]).strip_edges()
|
||||
return not stat.is_empty() and not stat.begins_with("Z")
|
||||
|
||||
|
||||
## Poll until the given port is no longer bound, or the timeout elapses.
|
||||
## Used after `OS.kill` so we don't race the port-in-use check on rebind.
|
||||
static func wait_for_port_free(port: int, timeout_s: float) -> void:
|
||||
var deadline := Time.get_ticks_msec() + int(timeout_s * 1000.0)
|
||||
while is_port_in_use(port):
|
||||
if Time.get_ticks_msec() >= deadline:
|
||||
push_warning("MCP | port %d still in use after %.1fs — proceeding anyway" % [port, timeout_s])
|
||||
return
|
||||
OS.delay_msec(100)
|
||||
|
||||
|
||||
## Choose a non-Windows-reserved WS port. Returns `configured` when free;
|
||||
## otherwise the first non-excluded port within `span` of it. Optional
|
||||
## `log_buffer` is a duck-typed sink (`log(String)`) that gets the
|
||||
## remap notice so users see why the port shifted.
|
||||
static func resolve_ws_port(configured: int, max_port: int, log_buffer = null) -> int:
|
||||
var resolved := WindowsPortReservation.suggest_non_excluded_port(
|
||||
configured,
|
||||
2048,
|
||||
max_port
|
||||
)
|
||||
if resolved != configured:
|
||||
var message := "WebSocket port %d is reserved by Windows; using %d" % [configured, resolved]
|
||||
print("MCP | %s" % message)
|
||||
if log_buffer != null:
|
||||
log_buffer.log(message)
|
||||
return resolved
|
||||
|
||||
|
||||
## Trust the cached ws_port from the managed record only when the record
|
||||
## is current ownership proof — i.e. record version matches the installed
|
||||
## plugin. Otherwise a stale record from an older install (e.g. a 9500
|
||||
## value pre-Windows-reservation collision) would mislead the
|
||||
## compatibility check into killing an unrelated external process. #259.
|
||||
static func resolved_ws_port_for_existing_server(
|
||||
record_ws_port: int,
|
||||
record_version: String,
|
||||
current_version: String,
|
||||
fresh_resolved: int
|
||||
) -> int:
|
||||
if record_ws_port <= 0:
|
||||
return fresh_resolved
|
||||
if current_version.is_empty() or record_version != current_version:
|
||||
return fresh_resolved
|
||||
return record_ws_port
|
||||
|
||||
|
||||
static func resolve_ws_port_from_output(
|
||||
configured_port: int,
|
||||
netsh_output: String,
|
||||
max_port: int,
|
||||
span: int = 2048
|
||||
) -> int:
|
||||
return WindowsPortReservation.suggest_non_excluded_port_from_output(
|
||||
netsh_output,
|
||||
configured_port,
|
||||
span,
|
||||
max_port
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
uid://pk0212qfh61x
|
||||
@@ -0,0 +1,131 @@
|
||||
@tool
|
||||
class_name McpResourceIO
|
||||
extends RefCounted
|
||||
|
||||
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
||||
|
||||
## Shared helpers for "save a Resource to .tres" and the mutually-exclusive
|
||||
## path-vs-resource_path param validation that every resource-authoring
|
||||
## handler needs. Extracted to remove 4-way duplication across
|
||||
## resource_handler, environment_handler, texture_handler, and curve_handler.
|
||||
|
||||
|
||||
## Validate that exactly one of {path, resource_path} is provided.
|
||||
##
|
||||
## When `require_property` is true (default), also requires a non-empty
|
||||
## `property` param when `path` is given — this matches the semantics of
|
||||
## "assign a resource to node.property" (resource_create, texture tools,
|
||||
## curve_set_points). Pass false for tools where the path itself IS the
|
||||
## target (environment_create assigning to WorldEnvironment.environment).
|
||||
##
|
||||
## Returns null on success or an error dict on failure.
|
||||
static func validate_home(params: Dictionary, require_property: bool = true) -> Variant:
|
||||
var node_path: String = params.get("path", "")
|
||||
var property: String = params.get("property", "")
|
||||
var resource_path: String = params.get("resource_path", "")
|
||||
var has_node_target := not node_path.is_empty()
|
||||
var has_file_target := not resource_path.is_empty()
|
||||
|
||||
if has_node_target and has_file_target:
|
||||
var both_msg := "Provide either path+property or resource_path, not both" if require_property else "Provide either path or resource_path, not both"
|
||||
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, both_msg)
|
||||
if not has_node_target and not has_file_target:
|
||||
var none_msg := "Must provide either path+property (assign inline) or resource_path (save .tres)" if require_property else "Must provide either path or resource_path"
|
||||
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, none_msg)
|
||||
if require_property and has_node_target and property.is_empty():
|
||||
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Missing required param: property (required when path is given)")
|
||||
return null
|
||||
|
||||
|
||||
## Save `res` to `resource_path` as a .tres/.res file.
|
||||
##
|
||||
## Handles: res:// prefix validation, overwrite check, parent-directory
|
||||
## creation, ResourceSaver.save error reporting, and the post-save
|
||||
## EditorFileSystem.update_file() so the dock picks up the change.
|
||||
##
|
||||
## `label` is the human-readable resource-kind for error messages (e.g.
|
||||
## "Environment", "Gradient texture", "Curve"). `extra_fields` is merged
|
||||
## into the success response alongside the standard fields
|
||||
## (`resource_path`, `overwritten`, `undoable: false`, `reason`). Passing
|
||||
## a `reason` key in `extra_fields` overrides the default — useful for
|
||||
## tools that edit existing files rather than creating fresh ones.
|
||||
##
|
||||
## `pause_target` should be the handler's `McpConnection`. When supplied,
|
||||
## `pause_processing` is flipped on around `ResourceSaver.save()` so the
|
||||
## dispatcher's WebSocket pump can't re-enter while Godot pumps
|
||||
## `Main::iteration()` for the resource-save's progress UI / script-class
|
||||
## update task. Without this guard a queued command landing during the
|
||||
## save can trigger another `save_to_disk` that tries to add the same
|
||||
## `update_scripts_classes` editor task — "Task already exists" → null
|
||||
## deref → SIGSEGV. Same family of bug as godotengine/godot#118545 and
|
||||
## the same mitigation as `SceneHandler`'s `save_scene*` wraps. See
|
||||
## issue #288.
|
||||
##
|
||||
## Returns either an error dict or a {"data": {...}} success dict — ready
|
||||
## for the handler to return directly.
|
||||
static func save_to_disk(
|
||||
res: Resource,
|
||||
resource_path: String,
|
||||
overwrite: bool,
|
||||
label: String,
|
||||
extra_fields: Dictionary = {},
|
||||
pause_target: McpConnection = null,
|
||||
) -> Dictionary:
|
||||
var path_err = McpPathValidator.path_error(resource_path, "resource_path", true)
|
||||
if path_err != null:
|
||||
return path_err
|
||||
|
||||
var existed_before := FileAccess.file_exists(resource_path)
|
||||
if existed_before and not overwrite:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INVALID_PARAMS,
|
||||
"%s already exists at %s (pass overwrite=true to replace)" % [label, resource_path]
|
||||
)
|
||||
|
||||
var dir_path := resource_path.get_base_dir()
|
||||
var mkdir_err := DirAccess.make_dir_recursive_absolute(dir_path)
|
||||
if mkdir_err != OK and mkdir_err != ERR_ALREADY_EXISTS:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
"Failed to create directory %s: %s" % [dir_path, error_string(mkdir_err)]
|
||||
)
|
||||
|
||||
if pause_target != null:
|
||||
pause_target.pause_processing = true
|
||||
var save_err := ResourceSaver.save(res, resource_path)
|
||||
if pause_target != null:
|
||||
pause_target.pause_processing = false
|
||||
if save_err != OK:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
"Failed to save %s to %s: %s" % [label, resource_path, error_string(save_err)]
|
||||
)
|
||||
|
||||
var efs := EditorInterface.get_resource_filesystem()
|
||||
if efs != null:
|
||||
efs.update_file(resource_path)
|
||||
|
||||
var data := {
|
||||
"resource_path": resource_path,
|
||||
"overwritten": existed_before,
|
||||
"undoable": false,
|
||||
"reason": "File creation is persistent; delete the file manually to revert",
|
||||
}
|
||||
attach_cleanup_hint(data, existed_before, [resource_path])
|
||||
# merge with overwrite=true so callers (e.g. curve_set_points editing an
|
||||
# existing .tres) can supply a domain-specific `reason`.
|
||||
data.merge(extra_fields, true)
|
||||
return {"data": data}
|
||||
|
||||
|
||||
## Attach a `cleanup.rm` hint listing `paths` to `data` — only when the call
|
||||
## just created a new file (`existed_before == false`). On overwrite the field
|
||||
## is omitted because the caller already had the file on disk, and handing
|
||||
## them a cleanup list would invite dropping user content instead of just
|
||||
## scratch artifacts. Used by write-and-return handlers (create_script,
|
||||
## filesystem_write_text, resource_create/save_to_disk) so callers running
|
||||
## transient smoke tests can rm artifacts without tracking paths. See #82.
|
||||
static func attach_cleanup_hint(data: Dictionary, existed_before: bool, paths: Array) -> void:
|
||||
if existed_before:
|
||||
return
|
||||
data["cleanup"] = {"rm": paths}
|
||||
@@ -0,0 +1 @@
|
||||
uid://de2rwdoa4wabf
|
||||
@@ -0,0 +1,146 @@
|
||||
@tool
|
||||
class_name McpScenePath
|
||||
extends RefCounted
|
||||
|
||||
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
||||
|
||||
## Utility for converting between Godot internal node paths and clean
|
||||
## scene-relative paths like /Main/Camera3D.
|
||||
|
||||
|
||||
## Return a clean path relative to the scene root (e.g. /Main/Camera3D).
|
||||
## Returns "" when `node` is not the scene root or a descendant of it —
|
||||
## without the ancestry guard, get_path_to() returns an empty NodePath that
|
||||
## concatenates into a plausible-looking but invalid "/Main/".
|
||||
static func from_node(node: Node, scene_root: Node) -> String:
|
||||
if scene_root == null or node == null:
|
||||
return ""
|
||||
if node == scene_root:
|
||||
return "/" + scene_root.name
|
||||
if not scene_root.is_ancestor_of(node):
|
||||
return ""
|
||||
var relative := scene_root.get_path_to(node)
|
||||
return "/" + scene_root.name + "/" + str(relative)
|
||||
|
||||
|
||||
## Resolve a clean scene path like "/Main/Camera3D" to the actual node.
|
||||
##
|
||||
## Accepts forms relative to the edited scene root:
|
||||
## "/Main" — explicit root prefix (canonical)
|
||||
## "/Main/Camera3D" — descendant path
|
||||
## "Camera3D" — bare relative to scene_root
|
||||
## "World/Ground" — nested bare relative to scene_root
|
||||
##
|
||||
## Also accepts SceneTree-style "/root/<scene_root_name>[/...]" as an alias for
|
||||
## the edited scene root. Agents reach for /root/Foo right after creating a
|
||||
## scene because that's where scenes live at runtime; we honor it so the call
|
||||
## doesn't fail with a confusing "not found" error. The alias only kicks in
|
||||
## when the segment after /root matches the scene root's name — paths like
|
||||
## "/root/@EditorNode@.../Main/..." (returned by Node.get_path() in the editor)
|
||||
## fall through to the absolute-path fallback unchanged.
|
||||
static func resolve(scene_path: String, scene_root: Node) -> Node:
|
||||
if scene_root == null:
|
||||
return null
|
||||
|
||||
## /root/<scene_root_name>[/...] alias: strip the /root prefix and recurse.
|
||||
## Match the scene root by name explicitly so we don't capture editor-
|
||||
## internal paths that legitimately live under /root.
|
||||
var alias_prefix := "/root/" + scene_root.name
|
||||
if scene_path == alias_prefix or scene_path.begins_with(alias_prefix + "/"):
|
||||
return resolve(scene_path.substr(5), scene_root) # keep leading slash
|
||||
|
||||
var root_prefix := "/" + scene_root.name
|
||||
if scene_path == root_prefix:
|
||||
return scene_root
|
||||
if scene_path.begins_with(root_prefix + "/"):
|
||||
var relative := scene_path.substr(root_prefix.length() + 1)
|
||||
return scene_root.get_node_or_null(relative)
|
||||
|
||||
# Try as-is (relative path, or absolute SceneTree path).
|
||||
return scene_root.get_node_or_null(scene_path)
|
||||
|
||||
|
||||
## Return the edited scene root, or an error dict if the editor has no open
|
||||
## scene or the open scene doesn't match `expected_scene_file`.
|
||||
##
|
||||
## `expected_scene_file` is the caller's `scene_file` parameter — an empty
|
||||
## string means "target whatever is currently edited" (current behaviour,
|
||||
## no guard). A non-empty value must match `scene_file_path` on the current
|
||||
## edited scene root exactly, or we return EDITED_SCENE_MISMATCH so the
|
||||
## caller can re-open the right scene.
|
||||
##
|
||||
## Shape on success: {"node": <scene_root>}. Shape on error matches
|
||||
## `ErrorCodes.make()` so callers can propagate the result directly.
|
||||
static func require_edited_scene(expected_scene_file: String) -> Dictionary:
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if root == null:
|
||||
# Mirrors the structured payload that the Python-side require_writable
|
||||
# gate attaches for `playing` / `importing`. Together these cover the
|
||||
# three recoverable editor *states* (playing / importing / no_scene)
|
||||
# — the EDITOR_NOT_READY paths an AI caller can act on. Other
|
||||
# EDITOR_NOT_READY callsites describing internal-state failures
|
||||
# ("EditorFileSystem not available" etc.) intentionally don't carry
|
||||
# this payload because there's no useful caller hint to give.
|
||||
var err := ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "No scene open")
|
||||
err["error"]["data"] = {
|
||||
"editor_state": "no_scene",
|
||||
"retryable": false,
|
||||
"hint": (
|
||||
"No scene is open. Call scene_open with a scene path "
|
||||
+ "(e.g. \"res://main.tscn\") before issuing scene-mutating tools."
|
||||
),
|
||||
}
|
||||
return err
|
||||
if not expected_scene_file.is_empty() and root.scene_file_path != expected_scene_file:
|
||||
var actual := root.scene_file_path if not root.scene_file_path.is_empty() else "<unsaved>"
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.EDITED_SCENE_MISMATCH,
|
||||
(
|
||||
"Expected edited scene \"%s\" but \"%s\" is active. "
|
||||
+ "Call scene_open(\"%s\") first, or omit scene_file to target the active scene."
|
||||
) % [expected_scene_file, actual, expected_scene_file],
|
||||
)
|
||||
return {"node": root}
|
||||
|
||||
|
||||
## Format a "parent not found" error that names the path convention.
|
||||
## Agents routinely try /root/Foo or absolute SceneTree paths; the bare
|
||||
## "Parent not found: X" gave them no hint that paths are scene-relative.
|
||||
## Wording is generic ("Paths are relative...") so the helper works for any
|
||||
## param name (parent_path, new_parent, …).
|
||||
static func format_parent_error(path: String, scene_root: Node) -> String:
|
||||
if scene_root == null:
|
||||
return "Parent not found: %s. No edited scene is open." % path
|
||||
var root_name := str(scene_root.name)
|
||||
return "Parent not found: %s. Paths are relative to the edited scene root (e.g. \"/%s\" or \"\"), not the SceneTree. Scene root is \"/%s\"." % [path, root_name, root_name]
|
||||
|
||||
|
||||
## Format a "node not found" error that names the path convention and, when
|
||||
## possible, suggests a corrected path. Agents routinely pass /root/Foo
|
||||
## (runtime SceneTree) or unprefixed names; the bare "Node not found: X"
|
||||
## gives no hint that paths are edited-scene-relative.
|
||||
##
|
||||
## Suggestion logic (highest-confidence first):
|
||||
## 1. /root/<X>[/...] where <X> is not the scene root → suggest /<sceneRoot>/<X>[/...]
|
||||
## 2. path doesn't start with "/" → suggest "/<sceneRoot>/<path>"
|
||||
## 3. otherwise no concrete "did you mean", just the convention reminder.
|
||||
static func format_node_error(path: String, scene_root: Node) -> String:
|
||||
if scene_root == null:
|
||||
return "Node not found: %s. No edited scene is open." % path
|
||||
var root_name := str(scene_root.name)
|
||||
var suggestion := ""
|
||||
|
||||
if path.begins_with("/root/"):
|
||||
var after_root := path.substr(6) # "/root/" is 6 chars
|
||||
# Only suggest if the segment after /root/ isn't already the scene root
|
||||
# (resolve() handles /root/<sceneRoot>/... as an alias, so a failure
|
||||
# with that prefix means a deeper segment is wrong — no clean rewrite).
|
||||
var first_seg := after_root.split("/")[0]
|
||||
if first_seg != root_name and not first_seg.is_empty():
|
||||
suggestion = "/" + root_name + "/" + after_root
|
||||
elif not path.begins_with("/") and not path.is_empty():
|
||||
suggestion = "/" + root_name + "/" + path
|
||||
|
||||
if suggestion.is_empty():
|
||||
return "Node not found: %s. Paths are relative to the edited scene root (e.g. \"/%s/Child\"), not runtime /root/... paths. Scene root is \"/%s\"." % [path, root_name, root_name]
|
||||
return "Node not found: %s. Did you mean \"%s\"? Paths are relative to the edited scene root, not runtime /root/... paths. Scene root is \"/%s\"." % [path, suggestion, root_name]
|
||||
@@ -0,0 +1 @@
|
||||
uid://c1irdrss0amex
|
||||
@@ -0,0 +1,904 @@
|
||||
@tool
|
||||
class_name McpServerLifecycleManager
|
||||
extends RefCounted
|
||||
|
||||
## Server spawn / stop / respawn / adopt / recover orchestration plus the
|
||||
## update-reload handoff. Owns the server-state machine
|
||||
## (`McpServerState`), version-check seam (`McpServerVersionCheck`),
|
||||
## adoption metadata, and connection-blocked / dev-mismatch flags.
|
||||
##
|
||||
## State previously lived on plugin.gd; PR 6 (#297) moved it here so
|
||||
## PR 7 (UpdateManager extraction) can absorb the same encapsulation
|
||||
## pattern. The plugin still owns the physical editor surfaces
|
||||
## (Connection, Dock, Timer, EditorSettings I/O) and exposes them via
|
||||
## `_host.<method>()` shims; the test fixtures override those shims to
|
||||
## drive the manager without touching the editor.
|
||||
##
|
||||
## `_host` is untyped to honor the self-update field-storage policy
|
||||
## plugin.gd calls out near `_connection`.
|
||||
var _host
|
||||
|
||||
const UvCacheCleanup := preload("res://addons/godot_ai/utils/uv_cache_cleanup.gd")
|
||||
const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd")
|
||||
const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd")
|
||||
const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd")
|
||||
const McpServerStateScript := preload("res://addons/godot_ai/utils/mcp_server_state.gd")
|
||||
const McpStartupPathScript := preload("res://addons/godot_ai/utils/mcp_startup_path.gd")
|
||||
const McpAdoptionLabelScript := preload("res://addons/godot_ai/utils/mcp_adoption_label.gd")
|
||||
const McpServerVersionCheckScript := preload("res://addons/godot_ai/utils/server_version_check.gd")
|
||||
|
||||
# ---- State (owned here, was on plugin.gd through PR 5) ---------------
|
||||
|
||||
## Single source of truth for the server-spawn/adopt/version lifecycle.
|
||||
## See `McpServerState` for the transition table.
|
||||
var _server_state: int = McpServerStateScript.UNINITIALIZED
|
||||
|
||||
## OS-level state populated only when WE spawned the process.
|
||||
var _server_pid: int = -1
|
||||
var _server_spawn_ms: int = 0
|
||||
var _server_exit_ms: int = 0
|
||||
|
||||
## Version metadata. `expected_version` is what the plugin shipped with;
|
||||
## `actual_version` is what the live server reported via handshake_ack.
|
||||
var _server_expected_version: String = ""
|
||||
var _server_actual_version: String = ""
|
||||
var _server_actual_name: String = ""
|
||||
|
||||
## Diagnostic + recovery flags surfaced to the dock via `get_status()`.
|
||||
var _server_status_message: String = ""
|
||||
var _can_recover_incompatible: bool = false
|
||||
var _connection_blocked: bool = false
|
||||
|
||||
## One-shot guard for the stale-uvx-index recovery (#172). Reset at the
|
||||
## top of `start_server` so each fresh spawn attempt gets its own
|
||||
## refresh budget.
|
||||
var _refresh_retried: bool = false
|
||||
|
||||
## Bounded deadline for the foreign-port adoption-confirmation watcher.
|
||||
## Zero when disarmed.
|
||||
var _adoption_watch_deadline_ms: int = 0
|
||||
|
||||
## Branch-tag from the most recent `start_server` walk. See
|
||||
## `McpStartupPath`. Drives the startup-trace log.
|
||||
var _startup_path: String = McpStartupPathScript.UNSET
|
||||
|
||||
## Version-check seam. Lazily constructed on `arm_version_check` so
|
||||
## tests that exercise the manager without a connection don't have to
|
||||
## stub it out.
|
||||
var _version_check
|
||||
|
||||
|
||||
func _init(host) -> void:
|
||||
_host = host
|
||||
|
||||
|
||||
# ---- Public state accessors --------------------------------------------
|
||||
|
||||
func get_state() -> int:
|
||||
return _server_state
|
||||
|
||||
|
||||
func get_status_dict() -> Dictionary:
|
||||
return {
|
||||
"state": _server_state,
|
||||
"exit_ms": _server_exit_ms,
|
||||
"actual_name": _server_actual_name,
|
||||
"actual_version": _server_actual_version,
|
||||
"expected_version": _server_expected_version,
|
||||
"message": _server_status_message,
|
||||
"can_recover_incompatible": _can_recover_incompatible,
|
||||
"connection_blocked": _connection_blocked,
|
||||
}
|
||||
|
||||
|
||||
func get_server_pid() -> int:
|
||||
return _server_pid
|
||||
|
||||
|
||||
func get_startup_path() -> String:
|
||||
return _startup_path
|
||||
|
||||
|
||||
func get_adoption_watch_deadline_ms() -> int:
|
||||
return _adoption_watch_deadline_ms
|
||||
|
||||
|
||||
func is_awaiting_server_version() -> bool:
|
||||
return _version_check != null and _version_check.is_active()
|
||||
|
||||
|
||||
func is_connection_blocked() -> bool:
|
||||
return _connection_blocked
|
||||
|
||||
|
||||
# ---- State-machine entry points ---------------------------------------
|
||||
|
||||
## Validated transition. Returns true on success; false (and logs a
|
||||
## warning) when the transition is illegal under `McpServerState`'s
|
||||
## table. Callers that need first-writer-wins among terminal diagnoses
|
||||
## use `set_terminal_diagnosis` instead — that helper silently no-ops
|
||||
## without warning when the diagnosis would be a regression.
|
||||
func transition_state(target: int) -> bool:
|
||||
if _server_state == target:
|
||||
return true
|
||||
if not McpServerStateScript.can_transition(_server_state, target):
|
||||
push_warning(
|
||||
"MCP | rejected illegal state transition %s -> %s"
|
||||
% [
|
||||
McpServerStateScript.name_of(_server_state),
|
||||
McpServerStateScript.name_of(target),
|
||||
]
|
||||
)
|
||||
return false
|
||||
_server_state = target
|
||||
return true
|
||||
|
||||
|
||||
## First-writer-wins mutator for terminal diagnoses (CRASHED,
|
||||
## NO_COMMAND, PORT_EXCLUDED, INCOMPATIBLE, FOREIGN_PORT). Used during
|
||||
## spawn to make sure a late watch-loop CRASHED doesn't clobber an
|
||||
## earlier proactive PORT_EXCLUDED. Silent no-op when the current state
|
||||
## is already a terminal diagnosis — the existing diagnosis is kept.
|
||||
func set_terminal_diagnosis(target: int) -> bool:
|
||||
if not McpServerStateScript.is_terminal_diagnosis(target):
|
||||
push_warning(
|
||||
"MCP | set_terminal_diagnosis called with non-terminal %s"
|
||||
% McpServerStateScript.name_of(target)
|
||||
)
|
||||
return false
|
||||
if McpServerStateScript.is_terminal_diagnosis(_server_state):
|
||||
return false
|
||||
_server_state = target
|
||||
return true
|
||||
|
||||
|
||||
# ---- Adoption confirmation watcher -------------------------------------
|
||||
|
||||
## Arm the FOREIGN_PORT adoption-confirmation watcher. SPAWN_GRACE_MS
|
||||
## ahead of `now`; `tick_adoption_watch` self-disarms after this expires
|
||||
## so per-frame cost drops back to zero on a permanent foreign occupant.
|
||||
func arm_adoption_watch() -> void:
|
||||
_adoption_watch_deadline_ms = (
|
||||
Time.get_ticks_msec() + int(_host.SPAWN_GRACE_MS)
|
||||
)
|
||||
|
||||
|
||||
func disarm_adoption_watch() -> void:
|
||||
_adoption_watch_deadline_ms = 0
|
||||
|
||||
|
||||
func tick_adoption_watch(now_msec: int) -> void:
|
||||
if _adoption_watch_deadline_ms > 0 and now_msec >= _adoption_watch_deadline_ms:
|
||||
_adoption_watch_deadline_ms = 0
|
||||
|
||||
|
||||
# ---- Server version-check seam ----------------------------------------
|
||||
|
||||
func arm_version_check(connection, expected_version: String) -> void:
|
||||
if _version_check == null:
|
||||
_version_check = McpServerVersionCheckScript.new(self)
|
||||
var expected := _resolve_expected_version(expected_version)
|
||||
_server_expected_version = expected
|
||||
_version_check.arm(connection, expected)
|
||||
|
||||
|
||||
func disarm_version_check() -> void:
|
||||
if _version_check != null:
|
||||
_version_check.disarm()
|
||||
|
||||
|
||||
func get_version_check():
|
||||
return _version_check
|
||||
|
||||
|
||||
## Resolves a possibly-empty expected version to the plugin's shipping
|
||||
## version. Manager methods that are called via test fixtures may
|
||||
## receive an empty string when the test never seeded
|
||||
## `_server_expected_version`, so this is the one place that fallback
|
||||
## lives.
|
||||
func _resolve_expected_version(supplied: String) -> String:
|
||||
if not supplied.is_empty():
|
||||
return supplied
|
||||
return _expected_server_version()
|
||||
|
||||
|
||||
func _expected_server_version() -> String:
|
||||
return ClientConfigurator.get_plugin_version()
|
||||
|
||||
|
||||
## Called by McpServerVersionCheck when handshake_ack carries a version
|
||||
## string. Decides compatible vs incompatible and transitions the state.
|
||||
func handle_server_version_verified(expected_version: String, version: String) -> void:
|
||||
_server_actual_name = "godot-ai"
|
||||
_server_actual_version = version
|
||||
var expected := _resolve_expected_version(expected_version)
|
||||
_server_expected_version = expected
|
||||
var compatibility := _server_version_compatibility(version, expected)
|
||||
if compatibility.get("compatible", false):
|
||||
_can_recover_incompatible = false
|
||||
## Foreign-port and post-spawn handshakes both clear to READY
|
||||
## on a successful handshake. Late re-arms from READY also land
|
||||
## here and self-confirm.
|
||||
transition_state(McpServerStateScript.READY)
|
||||
_host._update_process_enabled()
|
||||
return
|
||||
var live := {"version": version, "status_code": 200, "name": "godot-ai"}
|
||||
_set_incompatible_server(live, expected, ClientConfigurator.http_port())
|
||||
if _host._connection != null:
|
||||
_host._connection.connect_blocked = true
|
||||
_host._connection.connect_block_reason = _server_status_message
|
||||
_host._connection.disconnect_from_server()
|
||||
_host._update_process_enabled()
|
||||
|
||||
|
||||
func handle_server_version_unverified(expected_version: String) -> void:
|
||||
var expected := _resolve_expected_version(expected_version)
|
||||
_server_expected_version = expected
|
||||
var live := {"version": "", "status_code": 0, "error": "missing_handshake_ack"}
|
||||
_set_incompatible_server(live, expected, ClientConfigurator.http_port())
|
||||
if _host._connection != null:
|
||||
_host._connection.connect_blocked = true
|
||||
_host._connection.connect_block_reason = _server_status_message
|
||||
_host._connection.disconnect_from_server()
|
||||
_host._update_process_enabled()
|
||||
|
||||
|
||||
# ---- Compatibility / version helpers (pure) ---------------------------
|
||||
|
||||
## Plugin and server speak a single, version-coupled protocol — new commands
|
||||
## and response fields are added together. Treating dev-mode mismatches as
|
||||
## "compatible" silently adopts a stale server whose code may differ from the
|
||||
## live source tree (e.g. another worktree on a different branch holding
|
||||
## port 8000). Strict match in all modes routes mismatches through
|
||||
## `recover_strong_port_occupant`, which kills the branded port-holder and
|
||||
## lets `start_server` spawn fresh against the current source.
|
||||
static func _server_version_compatibility(
|
||||
actual_version: String,
|
||||
expected_version: String
|
||||
) -> Dictionary:
|
||||
if actual_version.is_empty():
|
||||
return {"compatible": false, "reason": "unknown"}
|
||||
if actual_version == expected_version:
|
||||
return {"compatible": true, "reason": "exact"}
|
||||
return {"compatible": false, "reason": "version_mismatch"}
|
||||
|
||||
|
||||
static func _server_status_compatibility(
|
||||
actual_version: String,
|
||||
expected_version: String,
|
||||
actual_ws_port: int,
|
||||
expected_ws_port: int,
|
||||
) -> Dictionary:
|
||||
var version_result := _server_version_compatibility(actual_version, expected_version)
|
||||
if not bool(version_result.get("compatible", false)):
|
||||
return version_result
|
||||
if actual_ws_port != expected_ws_port:
|
||||
return {"compatible": false, "reason": "ws_port_mismatch"}
|
||||
return version_result
|
||||
|
||||
|
||||
static func _managed_record_has_version_drift(record_version: String, current_version: String) -> bool:
|
||||
return not record_version.is_empty() and record_version != current_version
|
||||
|
||||
|
||||
# ---- Incompatible-server bookkeeping ----------------------------------
|
||||
|
||||
func _set_incompatible_server(live: Dictionary, expected_version: String, port: int) -> void:
|
||||
## Latches the incompatible diagnosis into manager state and asks
|
||||
## the dock to re-sweep client rows so they don't show stale green.
|
||||
## Threads the caller's `live` snapshot through the recovery proof
|
||||
## helper so we don't double-probe the port (~500ms each).
|
||||
transition_state(McpServerStateScript.INCOMPATIBLE)
|
||||
_connection_blocked = true
|
||||
_server_expected_version = expected_version
|
||||
_server_actual_name = str(live.get("name", ""))
|
||||
_server_actual_version = _live_version_for_message(live)
|
||||
_server_status_message = _incompatible_server_message(
|
||||
live, expected_version, port, int(_host._resolved_ws_port)
|
||||
)
|
||||
var proof: Dictionary = _host._evaluate_recovery_port_occupant_proof(port, live)
|
||||
var proof_name := str(proof.get("proof", ""))
|
||||
_can_recover_incompatible = not proof_name.is_empty()
|
||||
print("MCP | proof: %s" % (proof_name if _can_recover_incompatible else "(none)"))
|
||||
_host._refresh_dock_client_statuses()
|
||||
|
||||
|
||||
static func _incompatible_server_message(
|
||||
live: Dictionary,
|
||||
expected_version: String,
|
||||
port: int,
|
||||
expected_ws_port: int
|
||||
) -> String:
|
||||
var version := _live_version_for_message(live)
|
||||
var actual_ws_port := _live_ws_port_for_message(live)
|
||||
## `package_path` is a v2.4.4+ field — older servers omit it. Suffix
|
||||
## the message with "(loaded from <path>)" when present so the user
|
||||
## can tell *which* `src/godot_ai/` is serving the port without
|
||||
## walking the process tree. See #416.
|
||||
var package_path := _live_package_path_for_message(live)
|
||||
var path_suffix := " (loaded from %s)" % package_path if not package_path.is_empty() else ""
|
||||
if not version.is_empty():
|
||||
if actual_ws_port > 0 and actual_ws_port != expected_ws_port:
|
||||
return (
|
||||
"Port %d is occupied by godot-ai server v%s using WS port %d%s; "
|
||||
+ "plugin expects v%s with WS port %d. Stop the old server or "
|
||||
+ "change both HTTP and WS ports."
|
||||
) % [port, version, actual_ws_port, path_suffix, expected_version, expected_ws_port]
|
||||
return (
|
||||
"Port %d is occupied by godot-ai server v%s%s; plugin expects v%s. "
|
||||
+ "Stop the old server or change both HTTP and WS ports."
|
||||
) % [port, version, path_suffix, expected_version]
|
||||
var status_code := int(live.get("status_code", 0))
|
||||
if status_code > 0:
|
||||
return (
|
||||
"Port %d is occupied by an unverified server (status endpoint returned HTTP %d); "
|
||||
+ "plugin expects godot-ai v%s. Stop the other server or change both HTTP and WS ports."
|
||||
) % [port, status_code, expected_version]
|
||||
return (
|
||||
"Port %d is occupied by another process; plugin expects godot-ai v%s. "
|
||||
+ "Stop the other process or change both HTTP and WS ports."
|
||||
) % [port, expected_version]
|
||||
|
||||
|
||||
static func _live_status_identifies_godot_ai(live: Dictionary) -> bool:
|
||||
return str(live.get("name", "")) == "godot-ai"
|
||||
|
||||
|
||||
static func _live_version_for_message(live: Dictionary) -> String:
|
||||
if live.has("name") and str(live.get("name", "")) != "godot-ai":
|
||||
return ""
|
||||
return str(live.get("version", ""))
|
||||
|
||||
|
||||
static func _live_ws_port_for_message(live: Dictionary) -> int:
|
||||
if live.has("name") and str(live.get("name", "")) != "godot-ai":
|
||||
return 0
|
||||
return int(live.get("ws_port", 0))
|
||||
|
||||
|
||||
static func _live_package_path_for_message(live: Dictionary) -> String:
|
||||
## Only trust the path when the live snapshot confirms a godot-ai
|
||||
## server — a probe of some unrelated HTTP service could in theory
|
||||
## return a `package_path` JSON field, and we don't want to mislabel
|
||||
## that as "godot-ai loaded from …" in the incompatible banner.
|
||||
if live.has("name") and str(live.get("name", "")) != "godot-ai":
|
||||
return ""
|
||||
return str(live.get("package_path", ""))
|
||||
|
||||
|
||||
# ---- start_server / spawn watch / respawn -----------------------------
|
||||
|
||||
|
||||
## Sets GODOT_AI_DISABLE_TELEMETRY in the process environment for the
|
||||
## upcoming OS.create_process call if: (a) neither GODOT_AI_DISABLE_TELEMETRY
|
||||
## nor DISABLE_TELEMETRY is already set to a *truthy* value (a falsey "0" does
|
||||
## NOT count — it must not suppress a dock UI opt-out), and (b) the effective
|
||||
## McpSettings.telemetry_enabled() is false. Returns true if the var was
|
||||
## injected so the caller can unset it after spawning.
|
||||
func _inject_telemetry_env() -> bool:
|
||||
## If telemetry is already disabled by a *truthy* env var, leave the env as
|
||||
## the user/CI set it — the post-spawn cleanup unsets what we inject, so
|
||||
## injecting here would strip their own var from the editor process. A
|
||||
## *falsey* value (e.g. DISABLE_TELEMETRY=0) must NOT count as "handled":
|
||||
## fall through so a dock UI opt-out still reaches the spawned server. The
|
||||
## truthy test mirrors McpSettings.telemetry_enabled() and the Python server.
|
||||
if McpSettings.env_truthy("GODOT_AI_DISABLE_TELEMETRY") or McpSettings.env_truthy("DISABLE_TELEMETRY"):
|
||||
return false
|
||||
if not McpSettings.telemetry_enabled():
|
||||
OS.set_environment("GODOT_AI_DISABLE_TELEMETRY", "true")
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
## Set GODOT_AI_OWNER_PID to this editor's PID for the next OS.create_process,
|
||||
## so the spawned server can self-reap if this editor crashes. Returns true if
|
||||
## set (caller must unset right after spawning — keep it out of the persistent
|
||||
## editor env). No-op on Windows, where the server's reaper is disabled.
|
||||
func _set_owner_pid_env() -> bool:
|
||||
if OS.get_name() == "Windows":
|
||||
return false
|
||||
OS.set_environment("GODOT_AI_OWNER_PID", str(OS.get_process_id()))
|
||||
return true
|
||||
|
||||
|
||||
## Branch table (recorded version is the "is this ours?" signal — uvx
|
||||
## launcher PIDs go stale; #135/#137):
|
||||
## port free -> spawn fresh, record PID
|
||||
## port in use, record matches + live ok -> adopt port owner (heals PID)
|
||||
## port in use, record drifts -> kill owner + respawn
|
||||
## port in use, no verified live match -> block adoption + warn
|
||||
func start_server() -> void:
|
||||
if _host._server_started_this_session:
|
||||
## Static flag persists across disable/enable cycles in one editor
|
||||
## session — re-entrant spawn guard for plugin-reload-during-update.
|
||||
_startup_path = McpStartupPathScript.GUARDED
|
||||
transition_state(McpServerStateScript.GUARDED)
|
||||
return
|
||||
|
||||
_refresh_retried = false
|
||||
|
||||
var port := ClientConfigurator.http_port()
|
||||
var ws_port := ClientConfigurator.ws_port()
|
||||
var current_version := _expected_server_version()
|
||||
_server_expected_version = current_version
|
||||
|
||||
if bool(_host._is_port_in_use(port)):
|
||||
var record: Dictionary = _host._read_managed_server_record()
|
||||
var record_version := str(record.get("version", ""))
|
||||
var record_ws_port := int(record.get("ws_port", 0))
|
||||
_host._set_resolved_ws_port(PortResolver.resolved_ws_port_for_existing_server(
|
||||
record_ws_port,
|
||||
record_version,
|
||||
current_version,
|
||||
int(_host._resolve_ws_port())
|
||||
))
|
||||
ws_port = int(_host._resolved_ws_port)
|
||||
var live: Dictionary = _host._probe_live_server_status_for_port(port)
|
||||
var live_version := str(_host._verified_status_version(live))
|
||||
var live_ws_port := int(_host._verified_status_ws_port(live))
|
||||
var compatibility: Dictionary = _server_status_compatibility(
|
||||
live_version,
|
||||
current_version,
|
||||
live_ws_port,
|
||||
ws_port,
|
||||
)
|
||||
if compatibility.get("compatible", false):
|
||||
_server_actual_name = "godot-ai"
|
||||
_server_actual_version = live_version
|
||||
_can_recover_incompatible = false
|
||||
var owner := int(_host._find_managed_pid(port))
|
||||
var owner_label := adopt_compatible_server(record_version, current_version, owner)
|
||||
_host._server_started_this_session = true
|
||||
_startup_path = McpStartupPathScript.ADOPTED
|
||||
transition_state(McpServerStateScript.READY)
|
||||
print(_compatible_adoption_log_message(
|
||||
owner_label,
|
||||
int(_server_pid),
|
||||
owner,
|
||||
str(_server_actual_version),
|
||||
live_ws_port,
|
||||
current_version
|
||||
))
|
||||
return
|
||||
if bool(_managed_record_has_version_drift(record_version, current_version)):
|
||||
print("MCP | managed server v%s does not match plugin v%s, restarting"
|
||||
% [record_version, current_version])
|
||||
## Forward `live` so the recovery proof helper reuses our snapshot.
|
||||
## The kill invalidates it, so the failure arm re-probes below.
|
||||
if not recover_strong_port_occupant(port, 3.0, live):
|
||||
_host._server_started_this_session = true
|
||||
var post_recovery_live: Dictionary = _host._probe_live_server_status_for_port(port)
|
||||
_set_incompatible_server(post_recovery_live, current_version, port)
|
||||
_startup_path = McpStartupPathScript.INCOMPATIBLE
|
||||
push_warning(str(_server_status_message))
|
||||
return
|
||||
else:
|
||||
_startup_path = McpStartupPathScript.FREE
|
||||
|
||||
_host._set_resolved_ws_port(_host._resolve_ws_port())
|
||||
ws_port = _host._resolved_ws_port
|
||||
|
||||
_host._startup_trace_count("server_command_discovery")
|
||||
var server_cmd := ClientConfigurator.get_server_command()
|
||||
if server_cmd.is_empty():
|
||||
set_terminal_diagnosis(McpServerStateScript.NO_COMMAND)
|
||||
_startup_path = McpStartupPathScript.NO_COMMAND
|
||||
push_warning("MCP | could not find server command")
|
||||
return
|
||||
|
||||
var cmd: String = server_cmd[0]
|
||||
var args: Array[String] = []
|
||||
args.assign(server_cmd.slice(1))
|
||||
args.append_array(_host._build_server_flags(port, ws_port))
|
||||
|
||||
## Wipe any stale pid-file so a failed launch can't leave last
|
||||
## session's PID for `_find_managed_pid` to read.
|
||||
_host._clear_pid_file()
|
||||
|
||||
## Proactive Windows port-reservation check (#146) — bind would
|
||||
## fail silently with WinError 10013 inside a Hyper-V / WSL2 /
|
||||
## Docker exclusion range; netstat shows nothing.
|
||||
if WindowsPortReservation.is_port_excluded(port):
|
||||
_host._server_started_this_session = true
|
||||
set_terminal_diagnosis(McpServerStateScript.PORT_EXCLUDED)
|
||||
_startup_path = McpStartupPathScript.RESERVED
|
||||
push_warning("MCP | port %d is reserved by Windows (Hyper-V / WSL2 / Docker)" % port)
|
||||
return
|
||||
|
||||
var injected_telemetry_env := _inject_telemetry_env()
|
||||
|
||||
## PYTHONPATH handling for dev checkouts: when the editor is launched
|
||||
## against a worktree whose `src/godot_ai/__version__` differs from the
|
||||
## root repo's editable install, the dev-venv python's `sitecustomize`
|
||||
## adds the *root repo's* `src/` to `sys.path`. The spawned server then
|
||||
## reports the root repo's version, the plugin's compatibility check
|
||||
## flags it as incompatible, and the user gets a Restart-Server loop
|
||||
## with no exit. `start_dev_server` already prepends the worktree's
|
||||
## `src/` for its --reload spawn; mirror that here for the auto-spawn
|
||||
## path so the same worktree-vs-root version skew is impossible. Gated
|
||||
## on `is_dev_checkout()` so production user installs (no nearby `src/`)
|
||||
## are untouched. See #418.
|
||||
var worktree_src := ""
|
||||
var prev_pythonpath := ""
|
||||
var pythonpath_set := false
|
||||
if ClientConfigurator.is_dev_checkout():
|
||||
worktree_src = ClientConfigurator.find_worktree_src_dir(
|
||||
ProjectSettings.globalize_path("res://")
|
||||
)
|
||||
if not worktree_src.is_empty():
|
||||
prev_pythonpath = OS.get_environment("PYTHONPATH")
|
||||
var sep := ";" if OS.get_name() == "Windows" else ":"
|
||||
var new_pp := (
|
||||
worktree_src
|
||||
if prev_pythonpath.is_empty()
|
||||
else worktree_src + sep + prev_pythonpath
|
||||
)
|
||||
OS.set_environment("PYTHONPATH", new_pp)
|
||||
pythonpath_set = true
|
||||
|
||||
## Tell the spawned server which editor owns it so it can self-reap if we
|
||||
## die without a clean stop_server (crash / hard-kill). Passed via env, not
|
||||
## a CLI flag, so an older server (staggered user-mode upgrade) silently
|
||||
## ignores an unknown var instead of failing argparse. Scoped tightly around
|
||||
## create_process and unset right after (like PYTHONPATH below): the child
|
||||
## inherits it, but it must NOT linger in the editor env, or a later
|
||||
## non-reload `godot-ai` subprocess (dev server, future spawn) would inherit
|
||||
## it and wrongly arm a reaper keyed to this editor.
|
||||
## Skipped on Windows: the server's reaper is POSIX-only for now (Windows
|
||||
## process-liveness/self-shutdown isn't live-validated yet). The server
|
||||
## gates on this too.
|
||||
var owner_env_set := _set_owner_pid_env()
|
||||
|
||||
_server_pid = OS.create_process(cmd, args)
|
||||
var spawned_pid := int(_server_pid)
|
||||
|
||||
if owner_env_set:
|
||||
OS.unset_environment("GODOT_AI_OWNER_PID")
|
||||
|
||||
## Restore PYTHONPATH immediately — the spawned child has already
|
||||
## copied the env, so the editor's own process state returns to
|
||||
## baseline. Leaving it set would leak to any later OS.create_process
|
||||
## from unrelated paths.
|
||||
if pythonpath_set:
|
||||
if prev_pythonpath.is_empty():
|
||||
OS.unset_environment("PYTHONPATH")
|
||||
else:
|
||||
OS.set_environment("PYTHONPATH", prev_pythonpath)
|
||||
|
||||
if injected_telemetry_env:
|
||||
OS.unset_environment("GODOT_AI_DISABLE_TELEMETRY")
|
||||
|
||||
if spawned_pid > 0:
|
||||
_server_spawn_ms = Time.get_ticks_msec()
|
||||
_server_exit_ms = 0
|
||||
_host._server_started_this_session = true
|
||||
transition_state(McpServerStateScript.SPAWNING)
|
||||
## Record the launcher PID so same-session
|
||||
## prepare_for_update_reload has something to kill. The next
|
||||
## editor start's adopt branch heals it to the real port owner.
|
||||
_host._write_managed_server_record(spawned_pid, current_version)
|
||||
_startup_path = McpStartupPathScript.SPAWNED
|
||||
## Log "PYTHONPATH prefix=" rather than "PYTHONPATH=" so the line
|
||||
## isn't misleading when an existing PYTHONPATH was present —
|
||||
## we prepended `worktree_src`, not replaced. Keeps the log
|
||||
## compact (worktree_src is the actionable piece; the full
|
||||
## prev_pythonpath can be 5+ entries long on dev machines).
|
||||
var suffix := " (PYTHONPATH prefix=%s)" % worktree_src if not worktree_src.is_empty() else ""
|
||||
print("MCP | started server (PID %d, v%s): %s %s%s" % [spawned_pid, current_version, cmd, " ".join(args), suffix])
|
||||
_host._start_server_watch()
|
||||
else:
|
||||
set_terminal_diagnosis(McpServerStateScript.CRASHED)
|
||||
_startup_path = McpStartupPathScript.CRASHED
|
||||
push_warning("MCP | failed to start server")
|
||||
|
||||
|
||||
## Watch-loop callback (1 Hz, capped by SERVER_WATCH_MS).
|
||||
## `--pid-file` is the source of truth on Windows / uvx where the
|
||||
## launcher PID dies quickly after spawning the real interpreter.
|
||||
func check_server_health() -> void:
|
||||
if int(_server_pid) <= 0:
|
||||
_host._stop_server_watch()
|
||||
return
|
||||
var elapsed := Time.get_ticks_msec() - int(_server_spawn_ms)
|
||||
var real_pid := PortResolver.read_pid_file()
|
||||
var spawn_pid := int(_server_pid)
|
||||
if real_pid > 0 and real_pid != spawn_pid and PortResolver.pid_alive(real_pid):
|
||||
_server_pid = real_pid
|
||||
elif not PortResolver.pid_alive(spawn_pid):
|
||||
if elapsed >= int(_host.SPAWN_GRACE_MS) and not McpServerStateScript.is_terminal_diagnosis(_server_state):
|
||||
if bool(_host._should_retry_with_refresh()):
|
||||
_refresh_retried = true
|
||||
respawn_with_refresh()
|
||||
return
|
||||
_server_exit_ms = elapsed
|
||||
set_terminal_diagnosis(McpServerStateScript.CRASHED)
|
||||
disarm_version_check()
|
||||
_host._update_process_enabled()
|
||||
_host._log_buffer.log("server exited after %dms — see Godot output log" % int(_server_exit_ms))
|
||||
_host._stop_server_watch()
|
||||
return
|
||||
if elapsed >= int(_host.SERVER_WATCH_MS):
|
||||
## Survived startup — mid-session crashes surface via WebSocket disconnect.
|
||||
_host._stop_server_watch()
|
||||
|
||||
|
||||
## Retry the spawn with uvx `--refresh` prepended (PyPI index can lag a
|
||||
## fresh publish ~10 min — #172). One-shot per session via _refresh_retried.
|
||||
func respawn_with_refresh() -> void:
|
||||
_host._startup_trace_count("server_command_discovery")
|
||||
var server_cmd := ClientConfigurator.get_server_command(true)
|
||||
if server_cmd.is_empty():
|
||||
return
|
||||
var cmd: String = server_cmd[0]
|
||||
var args: Array[String] = []
|
||||
args.assign(server_cmd.slice(1))
|
||||
args.append_array(_host._build_server_flags(ClientConfigurator.http_port(), int(_host._resolved_ws_port)))
|
||||
_host._clear_pid_file()
|
||||
_host._log_buffer.log("retrying with --refresh (PyPI index may be stale)")
|
||||
var injected_telemetry_env := _inject_telemetry_env()
|
||||
## Set owner PID for THIS spawn too (don't rely on it lingering from
|
||||
## start_server) — and unset right after, same scoping as start_server.
|
||||
var owner_env_set := _set_owner_pid_env()
|
||||
_server_pid = OS.create_process(cmd, args)
|
||||
if owner_env_set:
|
||||
OS.unset_environment("GODOT_AI_OWNER_PID")
|
||||
if injected_telemetry_env:
|
||||
OS.unset_environment("GODOT_AI_DISABLE_TELEMETRY")
|
||||
var spawn_pid := int(_server_pid)
|
||||
if spawn_pid > 0:
|
||||
_server_spawn_ms = Time.get_ticks_msec()
|
||||
_server_exit_ms = 0
|
||||
var current_version := _expected_server_version()
|
||||
_host._write_managed_server_record(spawn_pid, current_version)
|
||||
print("MCP | retried server (PID %d, v%s): %s %s" % [spawn_pid, current_version, cmd, " ".join(args)])
|
||||
else:
|
||||
## OS.create_process returned -1 on the retry — surface CRASHED
|
||||
## rather than loop. `_refresh_retried` is already true.
|
||||
set_terminal_diagnosis(McpServerStateScript.CRASHED)
|
||||
disarm_version_check()
|
||||
_host._update_process_enabled()
|
||||
_host._log_buffer.log("refresh retry failed to spawn — see Godot output log")
|
||||
_host._stop_server_watch()
|
||||
|
||||
|
||||
func adopt_compatible_server(record_version: String, current_version: String, owner: int) -> String:
|
||||
_server_actual_name = "godot-ai"
|
||||
_can_recover_incompatible = false
|
||||
if record_version == current_version and owner > 0:
|
||||
_server_pid = owner
|
||||
_host._write_managed_server_record(owner, current_version)
|
||||
return McpAdoptionLabelScript.MANAGED
|
||||
_server_pid = -1
|
||||
_host._clear_managed_server_record()
|
||||
_host._clear_pid_file()
|
||||
return McpAdoptionLabelScript.EXTERNAL
|
||||
|
||||
|
||||
static func _compatible_adoption_log_message(
|
||||
owner_label: String,
|
||||
owned_pid: int,
|
||||
observed_owner_pid: int,
|
||||
live_version: String,
|
||||
live_ws_port: int,
|
||||
current_version: String
|
||||
) -> String:
|
||||
if owner_label == McpAdoptionLabelScript.MANAGED:
|
||||
return "MCP | adopted managed server (PID %d, live v%s, WS %d, plugin v%s)" % [
|
||||
owned_pid,
|
||||
live_version,
|
||||
live_ws_port,
|
||||
current_version
|
||||
]
|
||||
return "MCP | adopted external server owner_pid=%d (live v%s, WS %d, plugin v%s)" % [
|
||||
observed_owner_pid,
|
||||
live_version,
|
||||
live_ws_port,
|
||||
current_version
|
||||
]
|
||||
|
||||
|
||||
## `pre_kill_live` is forwarded into the proof helper so it doesn't
|
||||
## re-probe a port the caller already probed. The kill invalidates the
|
||||
## snapshot — callers MUST re-probe before consuming live-status data
|
||||
## after this returns.
|
||||
func recover_strong_port_occupant(port: int, wait_s: float, pre_kill_live: Dictionary = {}) -> bool:
|
||||
var proof: Dictionary = _host._evaluate_strong_port_occupant_proof(port, pre_kill_live)
|
||||
var targets: Array[int] = []
|
||||
targets.assign(proof.get("pids", []))
|
||||
if targets.is_empty():
|
||||
return false
|
||||
|
||||
print("MCP | strong proof: %s" % str(proof.get("proof", "")))
|
||||
var killed: Array = _host._kill_processes_and_windows_spawn_children(targets)
|
||||
if not killed.is_empty():
|
||||
print("MCP | killed pids %s on port %d" % [str(killed), port])
|
||||
_host._wait_for_port_free(port, wait_s)
|
||||
if bool(_host._is_port_in_use(port)):
|
||||
return false
|
||||
|
||||
_host._clear_managed_server_record()
|
||||
_host._clear_pid_file()
|
||||
return true
|
||||
|
||||
|
||||
func stop_server() -> void:
|
||||
_host._stop_server_watch()
|
||||
if int(_server_pid) <= 0:
|
||||
transition_state(McpServerStateScript.STOPPED)
|
||||
return
|
||||
transition_state(McpServerStateScript.STOPPING)
|
||||
## Kill the tracked PID AND the real Python PID — they differ for the
|
||||
## uvx tier (the launcher exits before its child) and on Windows
|
||||
## `OS.kill` is `TerminateProcess` which doesn't walk the child tree.
|
||||
var port := ClientConfigurator.http_port()
|
||||
var killed: Array = []
|
||||
var candidates: Array[int] = [int(_server_pid)]
|
||||
var real_pid := int(_host._find_managed_pid(port))
|
||||
## Add the real Python PID only if it isn't already tracked and proves out
|
||||
## as ours — re-appending an already-present PID just produces a duplicate
|
||||
## kill candidate.
|
||||
if real_pid > 0 and not candidates.has(real_pid) and _host._pid_cmdline_is_godot_ai_for_proof(real_pid):
|
||||
candidates.append(real_pid)
|
||||
var listener_pids: Array = _host._find_all_pids_on_port(port)
|
||||
for pid in listener_pids:
|
||||
var listener_pid := int(pid)
|
||||
if candidates.has(listener_pid):
|
||||
continue
|
||||
if _host._pid_cmdline_is_godot_ai_for_proof(listener_pid):
|
||||
candidates.append(listener_pid)
|
||||
killed = _host._kill_processes_and_windows_spawn_children(candidates)
|
||||
if not killed.is_empty():
|
||||
print("MCP | stopped server (PID %s)" % str(killed))
|
||||
_server_pid = -1
|
||||
_host._wait_for_port_free(port, 2.0)
|
||||
## Preserve record/pid-file when port is still held — the drift
|
||||
## branch on the next start_server retries the kill (#159 follow-up).
|
||||
_host._finalize_stop_if_port_free(port)
|
||||
transition_state(McpServerStateScript.STOPPED)
|
||||
|
||||
## Server's `_pydantic_core.pyd` hard-link is now released — sweep
|
||||
## stale uvx builds before they trip the next `uvx mcp-proxy`.
|
||||
UvCacheCleanup.purge_stale_builds()
|
||||
|
||||
|
||||
## Kill the server, reset the re-entrancy guard so the re-enabled plugin
|
||||
## spawns fresh (#132). User-mode only kills via strong proof.
|
||||
func prepare_for_update_reload() -> void:
|
||||
stop_server()
|
||||
_host._server_started_this_session = false
|
||||
if ClientConfigurator.is_dev_checkout():
|
||||
return
|
||||
|
||||
var port := ClientConfigurator.http_port()
|
||||
if not bool(_host._is_port_in_use(port)):
|
||||
return
|
||||
|
||||
var proof: Dictionary = _host._evaluate_strong_port_occupant_proof(port)
|
||||
var targets: Array[int] = []
|
||||
targets.assign(proof.get("pids", []))
|
||||
if targets.is_empty():
|
||||
return
|
||||
|
||||
_host._kill_processes_and_windows_spawn_children(targets)
|
||||
_host._wait_for_port_free(port, 3.0)
|
||||
if not bool(_host._is_port_in_use(port)):
|
||||
_host._clear_managed_server_record()
|
||||
_host._clear_pid_file()
|
||||
|
||||
|
||||
# ---- Recovery click ----------------------------------------------------
|
||||
|
||||
## Returns true when a pure-state probe says recovery is allowed:
|
||||
## current state is INCOMPATIBLE, the port is still held, and we have
|
||||
## proof of ownership over the occupant. Pure-state in the sense that
|
||||
## nothing is killed — that's `recover_incompatible_server`.
|
||||
func can_recover_incompatible_server() -> bool:
|
||||
if _server_state != McpServerStateScript.INCOMPATIBLE:
|
||||
return false
|
||||
var port := ClientConfigurator.http_port()
|
||||
if not bool(_host._is_port_in_use(port)):
|
||||
return false
|
||||
var proof: Dictionary = _host._evaluate_recovery_port_occupant_proof(port)
|
||||
return not str(proof.get("proof", "")).is_empty()
|
||||
|
||||
|
||||
func recover_incompatible_server() -> bool:
|
||||
if _server_state != McpServerStateScript.INCOMPATIBLE:
|
||||
return false
|
||||
|
||||
var port := ClientConfigurator.http_port()
|
||||
var proof: Dictionary = _host._evaluate_recovery_port_occupant_proof(port)
|
||||
var targets: Array[int] = []
|
||||
targets.assign(proof.get("pids", []))
|
||||
if targets.is_empty():
|
||||
return false
|
||||
print("MCP | proof: %s" % str(proof.get("proof", "")))
|
||||
|
||||
## Move into STOPPING so the post-kill respawn passes the
|
||||
## first-writer-wins guards.
|
||||
transition_state(McpServerStateScript.STOPPING)
|
||||
var killed: Array = _host._kill_processes_and_windows_spawn_children(targets)
|
||||
if not killed.is_empty():
|
||||
print("MCP | killed pids %s on port %d" % [str(killed), port])
|
||||
_host._wait_for_port_free(port, 5.0)
|
||||
if _host._is_port_in_use(port):
|
||||
## Kill failed; re-latch INCOMPATIBLE so the dock keeps the
|
||||
## diagnostic UI.
|
||||
transition_state(McpServerStateScript.INCOMPATIBLE)
|
||||
return false
|
||||
|
||||
UvCacheCleanup.purge_stale_builds()
|
||||
_host._clear_managed_server_record()
|
||||
_host._clear_pid_file()
|
||||
transition_state(McpServerStateScript.STOPPED)
|
||||
_connection_blocked = false
|
||||
_server_status_message = ""
|
||||
_server_actual_version = ""
|
||||
_server_actual_name = ""
|
||||
_can_recover_incompatible = false
|
||||
_host._server_started_this_session = false
|
||||
_server_pid = -1
|
||||
start_server()
|
||||
return true
|
||||
|
||||
|
||||
## Restart authorisation — a live PID means we spawned/adopted, a
|
||||
## non-empty managed record is the cross-session proof used by the
|
||||
## drift branch.
|
||||
func can_restart_managed_server() -> bool:
|
||||
if _server_pid > 0:
|
||||
return true
|
||||
var record: Dictionary = _host._read_managed_server_record()
|
||||
return not str(record.get("version", "")).is_empty()
|
||||
|
||||
|
||||
func has_managed_server() -> bool:
|
||||
return _server_pid > 0
|
||||
|
||||
|
||||
## Reset state for a force-restart. Drops the managed record, clears
|
||||
## the pid-file, and resets the spawn guard so the follow-up
|
||||
## `start_server()` walks the spawn arm.
|
||||
func reset_for_force_restart() -> void:
|
||||
_host._clear_managed_server_record()
|
||||
_host._clear_pid_file()
|
||||
_host._server_started_this_session = false
|
||||
_server_pid = -1
|
||||
transition_state(McpServerStateScript.UNINITIALIZED)
|
||||
|
||||
|
||||
## Ownership-checked kill of the port occupant + respawn. Driven from
|
||||
## the dock's "Restart Server" button when the plugin adopted a foreign
|
||||
## server whose version drifted from the plugin.
|
||||
func force_restart_server() -> void:
|
||||
if not can_restart_managed_server():
|
||||
push_warning("MCP | refusing to kill server on port %d without managed-server ownership proof"
|
||||
% ClientConfigurator.http_port())
|
||||
return
|
||||
var port := ClientConfigurator.http_port()
|
||||
## Kill every LISTENER on the port, not just the first one. A dev
|
||||
## server run via `uvicorn --reload` owns port 8000 through both a
|
||||
## reloader parent AND a worker child — killing only one (or zero,
|
||||
## if the single-pid parse fell over on multi-line lsof output) leaves
|
||||
## the other holding the port past `_wait_for_port_free`'s window.
|
||||
transition_state(McpServerStateScript.STOPPING)
|
||||
_host._kill_processes_and_windows_spawn_children(_host._find_all_pids_on_port(port))
|
||||
_host._wait_for_port_free(port, 5.0)
|
||||
if _host._is_port_in_use(port):
|
||||
## Kill failed; clean baseline for the follow-up
|
||||
## `_set_incompatible_server`.
|
||||
transition_state(McpServerStateScript.UNINITIALIZED)
|
||||
_set_incompatible_server(
|
||||
_host._probe_live_server_status_for_port(port),
|
||||
_expected_server_version(),
|
||||
port
|
||||
)
|
||||
return
|
||||
## Same rationale as `stop_server`: the server child python just
|
||||
## released its `pydantic_core` mapping, so this is the only window in
|
||||
## which the hard-linked copies under `builds-v0\.tmp*` are deletable.
|
||||
## Sweep before respawning so the upcoming `uvx mcp-proxy` build doesn't
|
||||
## inherit the same cleanup-failure path that triggered the restart.
|
||||
UvCacheCleanup.purge_stale_builds()
|
||||
reset_for_force_restart()
|
||||
start_server()
|
||||
@@ -0,0 +1 @@
|
||||
uid://bwfx8b0w2mgf6
|
||||
@@ -0,0 +1,136 @@
|
||||
@tool
|
||||
class_name McpServerVersionCheck
|
||||
extends RefCounted
|
||||
|
||||
## Standalone polling seam for the post-connection server-version
|
||||
## handshake gate. Extracted from `plugin.gd` so the lifecycle manager
|
||||
## stays focused on spawn/adopt/stop and the version-verify dance has
|
||||
## its own home.
|
||||
##
|
||||
## The seam itself does NOT transition `McpServerState` on arm/disarm —
|
||||
## the version check runs concurrently with whatever spawn-state the
|
||||
## caller had latched (typically FOREIGN_PORT during adoption
|
||||
## confirmation, or no-op directly to READY for a fresh spawn). Result
|
||||
## transitions land on the manager via `handle_server_version_verified`
|
||||
## (READY / INCOMPATIBLE) or `handle_server_version_unverified`
|
||||
## (INCOMPATIBLE on deadline expiry); arm() leaves the state alone so a
|
||||
## FOREIGN_PORT diagnosis isn't accidentally cleared before the
|
||||
## handshake actually arrives.
|
||||
##
|
||||
## Owns the deadline timer (`_deadline_ms`) and requires the manager to
|
||||
## feed it `tick(now_msec)` from the plugin's `_process` while
|
||||
## `is_active()` is true.
|
||||
##
|
||||
## Decoupled from the connection's signal surface: `tick()` polls
|
||||
## `_connection.is_connected` and `_connection.server_version` directly.
|
||||
## A same-release signal addition plus a new consumer is shape-coupled work
|
||||
## for old two-phase runners; they can parse the consumer while the
|
||||
## McpConnection Script object still reflects v(N). We still null-check
|
||||
## `_connection` because `disarm()` releases it.
|
||||
|
||||
## How long to wait after the WebSocket opens before declaring the
|
||||
## handshake_ack overdue. Mirrors `plugin.gd::SERVER_HANDSHAKE_VERSION_TIMEOUT_MS`
|
||||
## — kept at this layer so the version-check seam is self-contained.
|
||||
const TIMEOUT_MS := 5 * 1000
|
||||
|
||||
## Untyped on purpose for the same self-update field-storage reason
|
||||
## plugin.gd's fields are untyped. `_connection` is the live
|
||||
## `McpConnection`; `_manager` is `McpServerLifecycleManager`.
|
||||
## `_connection` is null between disarm() and the next arm() — the
|
||||
## seam can spend most of the plugin's life dormant and we don't want
|
||||
## to pin a Node that may be queue_freed in `_exit_tree`. `_manager` is
|
||||
## set once at construction and held for the seam's lifetime (the
|
||||
## manager owns this instance, so the cycle is short).
|
||||
var _connection
|
||||
var _manager
|
||||
var _active: bool = false
|
||||
var _deadline_ms: int = 0
|
||||
var _expected_version: String = ""
|
||||
|
||||
|
||||
func _init(manager) -> void:
|
||||
_manager = manager
|
||||
|
||||
|
||||
## Arm the version-check. Marks the seam active, (re)attaches the
|
||||
## connection it should poll, and starts watching for
|
||||
## `_connection.server_version`. Does NOT transition manager state —
|
||||
## the version check runs concurrently with whatever spawn-state was
|
||||
## latched (e.g. FOREIGN_PORT during adoption confirmation, READY for
|
||||
## a fresh spawn). Result transitions land on the manager via
|
||||
## `handle_server_version_verified` / `_unverified` once the handshake
|
||||
## (or its deadline) lands.
|
||||
##
|
||||
## The deadline starts the moment the connection actually opens, not at
|
||||
## arm-time, because uvx cold-starts can take ~30s to bind the
|
||||
## WebSocket and we don't want to count that against the handshake.
|
||||
func arm(connection, expected_version: String) -> void:
|
||||
_active = true
|
||||
_deadline_ms = 0
|
||||
_expected_version = expected_version
|
||||
_connection = connection
|
||||
|
||||
|
||||
## Disarm without firing a verdict. Used when the manager moves on
|
||||
## (e.g. recovery click → STOPPING). Releases the connection /
|
||||
## manager references so the seam doesn't pin them past the active
|
||||
## window — the plugin can spend most of its life with the version
|
||||
## check disarmed, and `_connection` is a Node that may be queue_free'd
|
||||
## by `_exit_tree`. Caller has already transitioned state, so we don't
|
||||
## touch the manager.
|
||||
func disarm() -> void:
|
||||
_active = false
|
||||
_deadline_ms = 0
|
||||
_connection = null
|
||||
|
||||
|
||||
## True while the version-check needs `_process` ticks. Plugin uses
|
||||
## this to gate `set_process(true)`.
|
||||
func is_active() -> bool:
|
||||
return _active
|
||||
|
||||
|
||||
## Per-frame tick from the plugin's `_process`. No-op when disarmed.
|
||||
## Returns true when the check finished this tick (verified or
|
||||
## unverified) so the plugin can re-evaluate `set_process` enable.
|
||||
func tick(now_msec: int) -> bool:
|
||||
if not _active:
|
||||
return false
|
||||
if _connection == null:
|
||||
return false
|
||||
if not bool(_connection.is_connected):
|
||||
return false
|
||||
if _deadline_ms == 0:
|
||||
_deadline_ms = now_msec + TIMEOUT_MS
|
||||
var server_version := str(_connection.server_version)
|
||||
if not server_version.is_empty():
|
||||
_complete_with_version(server_version)
|
||||
return true
|
||||
if now_msec >= _deadline_ms:
|
||||
_complete_unverified()
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
## Invoked when `_on_connection_established` notices that we transitioned
|
||||
## out of FOREIGN_PORT — the server may yet prove itself compatible.
|
||||
## Re-arming is idempotent: if already active, no-op; otherwise the
|
||||
## caller's connection + last-known expected version are reused.
|
||||
func rearm_for_foreign_port_recovery(connection) -> void:
|
||||
if _active:
|
||||
return
|
||||
arm(connection, _expected_version)
|
||||
|
||||
|
||||
func _complete_with_version(version: String) -> void:
|
||||
_active = false
|
||||
_deadline_ms = 0
|
||||
if _manager != null:
|
||||
_manager.handle_server_version_verified(_expected_version, version)
|
||||
|
||||
|
||||
func _complete_unverified() -> void:
|
||||
_active = false
|
||||
_deadline_ms = 0
|
||||
if _manager != null:
|
||||
_manager.handle_server_version_unverified(_expected_version)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ciqldbuaq8i8u
|
||||
@@ -0,0 +1,39 @@
|
||||
@tool
|
||||
class_name McpSettings
|
||||
extends RefCounted
|
||||
|
||||
## Shared EditorSettings key constants for the godot_ai/* namespace.
|
||||
##
|
||||
## Centralised here so lightweight files (e.g. telemetry.gd) can reference
|
||||
## settings keys without pulling in the full client_configurator.gd dep tree.
|
||||
## All keys must keep their raw string values stable across releases because
|
||||
## they are persisted in the user's editor_settings-4.tres.
|
||||
|
||||
const SETTING_HTTP_PORT := "godot_ai/http_port"
|
||||
## Comma-separated list of tool domains excluded from the server at spawn time.
|
||||
const SETTING_EXCLUDED_DOMAINS := "godot_ai/excluded_domains"
|
||||
const SETTING_TELEMETRY_ENABLED := "godot_ai/telemetry_enabled"
|
||||
|
||||
|
||||
## Returns true if the string value is truthy
|
||||
## ("1", "true", "yes", "on", case-insensitive, whitespace-trimmed).
|
||||
static func truthy(value: String) -> bool:
|
||||
return value.strip_edges().to_lower() in ["1", "true", "yes", "on"]
|
||||
|
||||
|
||||
## Returns true if the named environment variable is set to a truthy value.
|
||||
static func env_truthy(var_name: String) -> bool:
|
||||
return truthy(OS.get_environment(var_name))
|
||||
|
||||
|
||||
## Returns true if telemetry should be active, checking in priority order:
|
||||
## 1. GODOT_AI_DISABLE_TELEMETRY / DISABLE_TELEMETRY env vars
|
||||
## 2. The godot_ai/telemetry_enabled EditorSetting written by the dock UI
|
||||
## Defaults to true when neither source has set a preference.
|
||||
static func telemetry_enabled() -> bool:
|
||||
if env_truthy("GODOT_AI_DISABLE_TELEMETRY") or env_truthy("DISABLE_TELEMETRY"):
|
||||
return false
|
||||
var es := EditorInterface.get_editor_settings()
|
||||
if es != null and es.has_setting(SETTING_TELEMETRY_ENABLED):
|
||||
return bool(es.get_setting(SETTING_TELEMETRY_ENABLED))
|
||||
return true
|
||||
@@ -0,0 +1 @@
|
||||
uid://pefrtofs7ijw
|
||||
@@ -0,0 +1,156 @@
|
||||
@tool
|
||||
class_name McpStructuredLogRing
|
||||
extends RefCounted
|
||||
|
||||
## Head-indexed circular buffer of structured log entries shared by
|
||||
## game_log_buffer and editor_log_buffer.
|
||||
##
|
||||
## Once `_max_lines` (set in subclass `_init`) is reached, new appends
|
||||
## overwrite the oldest slot at `_head`, keeping append O(1) on overflow
|
||||
## — the previous slice() approach reallocated the full retained array
|
||||
## on every drop, which a chatty game would pay for thousands of times
|
||||
## per second.
|
||||
##
|
||||
## Lockless. Subclasses needing thread-safety (editor_log_buffer is
|
||||
## written from any thread a Godot Logger virtual can fire on) wrap each
|
||||
## public method with their own Mutex around the `_*_unlocked` helpers.
|
||||
## Keeping the base lockless means the hot game-side path (single thread,
|
||||
## called from _process) doesn't pay an unused mutex cost.
|
||||
##
|
||||
## Entry shape is owned by subclasses — `_append_entry` takes a
|
||||
## ready-built Dictionary so each buffer can carry the fields it needs
|
||||
## (game: `source/level/text`; editor: adds `path/line/function`).
|
||||
|
||||
const VALID_LEVELS := ["info", "warn", "error"]
|
||||
|
||||
var _max_lines: int
|
||||
var _storage: Array[Dictionary] = []
|
||||
## Next write position within `_storage`. While filling (before first
|
||||
## wrap) equals `_storage.size()`; once full, points at the oldest entry
|
||||
## (the one about to be overwritten).
|
||||
var _head := 0
|
||||
var _dropped_count := 0
|
||||
## Monotonic number of entries appended since this ring was created. Unlike
|
||||
## `_storage.size()` and `_dropped_count`, this intentionally survives clear()
|
||||
## so callers can use it as a stable "next entry to read" cursor.
|
||||
var _appended_total := 0
|
||||
|
||||
|
||||
func _init(max_lines: int) -> void:
|
||||
_max_lines = max_lines
|
||||
|
||||
|
||||
## Append `entry` to the ring, evicting the oldest slot when full.
|
||||
## Subclasses build the dict with their per-source shape and pass it in.
|
||||
func _append_entry(entry: Dictionary) -> void:
|
||||
if _storage.size() < _max_lines:
|
||||
_storage.append(entry)
|
||||
_head = _storage.size() % _max_lines
|
||||
else:
|
||||
## Full — overwrite oldest in place, advance head, count the drop.
|
||||
_storage[_head] = entry
|
||||
_head = (_head + 1) % _max_lines
|
||||
_dropped_count += 1
|
||||
_appended_total += 1
|
||||
|
||||
|
||||
## Lockless slice. Subclasses with a mutex wrap their `get_range` /
|
||||
## `get_recent` overrides around this; the lockless base implementations
|
||||
## of those public methods just delegate here.
|
||||
func _get_range_unlocked(offset: int, count: int) -> Array[Dictionary]:
|
||||
var size := _storage.size()
|
||||
var start := maxi(0, offset)
|
||||
var stop := mini(size, start + count)
|
||||
var out: Array[Dictionary] = []
|
||||
for i in range(start, stop):
|
||||
out.append(_storage[_logical_to_physical(i)])
|
||||
return out
|
||||
|
||||
|
||||
func get_range(offset: int, count: int) -> Array[Dictionary]:
|
||||
return _get_range_unlocked(offset, count)
|
||||
|
||||
|
||||
func get_recent(count: int) -> Array[Dictionary]:
|
||||
var size := _storage.size()
|
||||
var start := maxi(0, size - count)
|
||||
return _get_range_unlocked(start, size - start)
|
||||
|
||||
|
||||
## Lockless cursor read. The cursor is the next sequence to read: calling
|
||||
## get_since(appended_total()) after a snapshot returns only later appends.
|
||||
func _get_since_unlocked(since_seq: int, limit: int = -1) -> Dictionary:
|
||||
var size := _storage.size()
|
||||
var oldest_seq := _appended_total - size
|
||||
var start_seq := mini(maxi(since_seq, oldest_seq), _appended_total)
|
||||
var start := start_seq - oldest_seq
|
||||
var available := maxi(0, size - start)
|
||||
var count := available
|
||||
if limit >= 0:
|
||||
count = mini(available, limit)
|
||||
var entries := _get_range_unlocked(start, count)
|
||||
var next_cursor := start_seq + entries.size()
|
||||
return {
|
||||
"cursor": since_seq,
|
||||
"oldest_cursor": oldest_seq,
|
||||
"next_cursor": next_cursor,
|
||||
"appended_total": _appended_total,
|
||||
"truncated": since_seq < oldest_seq,
|
||||
"has_more": next_cursor < _appended_total,
|
||||
"entries": entries,
|
||||
}
|
||||
|
||||
|
||||
func get_since(since_seq: int, limit: int = -1) -> Dictionary:
|
||||
return _get_since_unlocked(since_seq, limit)
|
||||
|
||||
|
||||
## Lockless accessors. Subclasses with a mutex use these under their lock
|
||||
## so the field reads stay encapsulated in the base instead of leaking
|
||||
## `_storage` / `_dropped_count` reach-through into the subclass.
|
||||
func _total_count_unlocked() -> int:
|
||||
return _storage.size()
|
||||
|
||||
|
||||
func _dropped_count_unlocked() -> int:
|
||||
return _dropped_count
|
||||
|
||||
|
||||
func _appended_total_unlocked() -> int:
|
||||
return _appended_total
|
||||
|
||||
|
||||
func total_count() -> int:
|
||||
return _total_count_unlocked()
|
||||
|
||||
|
||||
func dropped_count() -> int:
|
||||
return _dropped_count_unlocked()
|
||||
|
||||
|
||||
func appended_total() -> int:
|
||||
return _appended_total_unlocked()
|
||||
|
||||
|
||||
## Translate a logical index (0 = oldest retained) to a physical
|
||||
## `_storage` slot. Before the first wrap, storage-order is logical-
|
||||
## order. After wrapping, the oldest entry lives at `_head`.
|
||||
func _logical_to_physical(logical: int) -> int:
|
||||
if _storage.size() < _max_lines:
|
||||
return logical
|
||||
return (_head + logical) % _max_lines
|
||||
|
||||
|
||||
## Reset the ring to empty. Subclasses with a mutex wrap this with their
|
||||
## lock; subclasses that surface `clear` to callers (McpEditorLogBuffer)
|
||||
## return the prior size from their wrapper.
|
||||
func _clear_storage() -> void:
|
||||
_storage.clear()
|
||||
_head = 0
|
||||
_dropped_count = 0
|
||||
|
||||
|
||||
## Coerce unknown levels to "info" so a misbehaving sender can't poison
|
||||
## downstream filters with arbitrary strings.
|
||||
static func _coerce_level(level: String) -> String:
|
||||
return level if level in VALID_LEVELS else "info"
|
||||
@@ -0,0 +1 @@
|
||||
uid://c4yh3jqfn6dwe
|
||||
@@ -0,0 +1,443 @@
|
||||
@tool
|
||||
class_name McpUpdateManager
|
||||
extends Node
|
||||
|
||||
## Self-update manager for pre-runner work. Owns release checks, HTTP ZIP
|
||||
## download, the install-in-flight gate, and install state signals back to
|
||||
## the dock. Once `_install_zip()` calls
|
||||
## `plugin.gd::install_downloaded_update(...)`, ownership transfers to
|
||||
## `update_reload_runner.gd`, which owns extract, scan, plugin re-enable,
|
||||
## and detached-dock cleanup.
|
||||
##
|
||||
## The dock owns banner rendering and forwards button clicks. The split
|
||||
## exists because the dock script is one of the files overwritten on disk
|
||||
## during install — keeping pipeline state on a separate Node lets the dock
|
||||
## tear down cleanly without losing the in-flight gate that other dock spawn
|
||||
## paths consult.
|
||||
##
|
||||
## `class_name McpUpdateManager` is retained because it shipped in a
|
||||
## published release. If this class is ever retired, follow CLAUDE.md's
|
||||
## never-delete-published-class_name shim policy instead of deleting the
|
||||
## declaration.
|
||||
##
|
||||
## `_plugin` and `_dock` are deliberately untyped: the same self-update
|
||||
## window that overwrites this script also overwrites the dock and plugin
|
||||
## scripts, and a static-typed reference into a script being hot-reloaded
|
||||
## crashes inside `GDScriptFunction::call`. `server_lifecycle.gd` follows
|
||||
## the same convention.
|
||||
|
||||
const RELEASES_URL := (
|
||||
"https://api.github.com/repos/hi-godot/godot-ai/releases/latest"
|
||||
)
|
||||
const RELEASES_PAGE := "https://github.com/hi-godot/godot-ai/releases/latest"
|
||||
const UPDATE_TEMP_DIR := "user://godot_ai_update/"
|
||||
const UPDATE_TEMP_ZIP := "user://godot_ai_update/update.zip"
|
||||
const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd")
|
||||
|
||||
## Emitted after `check_for_updates()` resolves a newer remote version.
|
||||
## Payload mirrors the Dictionary returned by `parse_releases_response`:
|
||||
## {has_update, version, forced, label_text, download_url}
|
||||
signal update_check_completed(result: Dictionary)
|
||||
|
||||
## Emitted at every UI-relevant step of the install pipeline. Payload
|
||||
## keys are all optional and apply on top of the current banner state:
|
||||
## label_text: String ## banner label override
|
||||
## button_text: String ## update button text override
|
||||
## button_disabled: bool ## update button disabled state
|
||||
## banner_visible: bool ## banner visibility override
|
||||
## outcome: String ## "success" -> dock paints green
|
||||
signal install_state_changed(state: Dictionary)
|
||||
|
||||
var _plugin
|
||||
var _dock
|
||||
|
||||
var _http_request: HTTPRequest
|
||||
var _download_request: HTTPRequest
|
||||
var _latest_download_url: String = ""
|
||||
|
||||
## Set for the duration of `_install_zip` — extract-overwrite of plugin
|
||||
## scripts on disk would crash any worker mid-`GDScriptFunction::call`
|
||||
## (confirmed via SIGABRT in the dock's refresh worker). Dock spawn paths
|
||||
## consult this via `is_install_in_flight()`; in-flight workers are
|
||||
## drained before any disk write.
|
||||
var _install_in_flight: bool = false
|
||||
|
||||
|
||||
# ---- Setup -------------------------------------------------------------
|
||||
|
||||
func setup(plugin, dock) -> void:
|
||||
_plugin = plugin
|
||||
_dock = dock
|
||||
|
||||
|
||||
# ---- Public API ---------------------------------------------------------
|
||||
|
||||
## Kick off the GitHub Releases API check. No-ops in dev checkouts —
|
||||
## `addons/godot_ai/` is a symlink into canonical `plugin/` source there,
|
||||
## and an extract would clobber tracked files (#116). `is_dev_checkout()`
|
||||
## honours the mode override (dock dropdown > GODOT_AI_MODE env), so
|
||||
## testers can force `user` to exercise the AssetLib flow from a dev tree;
|
||||
## `_install_zip` still gates on the physical symlink check so a forced-
|
||||
## user mode can never clobber source.
|
||||
func check_for_updates() -> void:
|
||||
if ClientConfigurator.is_dev_checkout():
|
||||
return
|
||||
if _http_request == null:
|
||||
_http_request = HTTPRequest.new()
|
||||
_http_request.request_completed.connect(_on_update_check_completed)
|
||||
add_child(_http_request)
|
||||
_http_request.request(RELEASES_URL, ["Accept: application/vnd.github+json"])
|
||||
|
||||
|
||||
## Cancel any in-flight check. The dock calls this before re-issuing a
|
||||
## check after a mode-override flip — without the cancel, `request()`
|
||||
## returns ERR_BUSY and the dropdown change silently fails to repaint.
|
||||
func cancel_check() -> void:
|
||||
if _http_request != null:
|
||||
_http_request.cancel_request()
|
||||
|
||||
|
||||
## Reset the cached download URL. The dock calls this on mode-override
|
||||
## flips so a fresh check paints over a clean banner.
|
||||
func clear_pending_download() -> void:
|
||||
_latest_download_url = ""
|
||||
|
||||
|
||||
## True when the running Godot can self-update in place. Godot < 4.4 takes
|
||||
## the `_install_zip_inline` extract-then-restart path, and that engine's
|
||||
## stricter `GDScript::reload()` (`!p_keep_state && has_instances` ->
|
||||
## `ERR_ALREADY_IN_USE`) turns the extract-over-live-scripts into a reload
|
||||
## error flood plus a SIGSEGV in `EditorDockManager::remove_dock` /
|
||||
## `SceneTree::finalize` on the restart/quit (#475). So on < 4.4 we don't
|
||||
## run the in-editor pipeline at all — the user updates manually.
|
||||
## Guards `major` too so a future Godot 5.x (minor 0) isn't misclassified.
|
||||
func _can_self_update() -> bool:
|
||||
var v := Engine.get_version_info()
|
||||
return _version_can_self_update(int(v.get("major", 0)), int(v.get("minor", 0)))
|
||||
|
||||
|
||||
## Pure version predicate, split out so it's testable without faking the
|
||||
## running engine. In-editor self-update needs Godot >= 4.4.
|
||||
static func _version_can_self_update(major: int, minor: int) -> bool:
|
||||
return major > 4 or (major == 4 and minor >= 4)
|
||||
|
||||
|
||||
## Banner guidance for the gated (< 4.4) path. Shown up-front at check time
|
||||
## (with the available version) and again on click, so the user understands
|
||||
## the manual-update flow before they press anything. Single source of truth
|
||||
## so check-time and click-time text never drift.
|
||||
static func _manual_update_label(version: String) -> String:
|
||||
var prefix := "Update available"
|
||||
if not version.is_empty():
|
||||
prefix = "Update v%s available" % version
|
||||
return (
|
||||
prefix
|
||||
+ " — in-editor update needs Godot 4.4+. Open the download page, then "
|
||||
+ "replace addons/godot_ai/ manually and relaunch."
|
||||
)
|
||||
|
||||
|
||||
## Driven by the dock's Update button. On Godot < 4.4 (see `_can_self_update`)
|
||||
## the in-editor install is disabled — we open the release page for a manual
|
||||
## download instead, never entering the extract pipeline that crashes those
|
||||
## engines. With no resolved download URL — either the check never completed,
|
||||
## or the release didn't ship a matching asset — also falls back to opening
|
||||
## the release page. Otherwise kicks off the download → extract → reload
|
||||
## pipeline.
|
||||
func start_install() -> void:
|
||||
if not _can_self_update():
|
||||
## Only claim success + lock the button if the browser actually opened.
|
||||
## On failure (no handler, headless) keep the button enabled so the
|
||||
## user can retry. Either way, leave the version-bearing guidance label
|
||||
## from check time in place — don't re-emit label_text.
|
||||
if OS.shell_open(RELEASES_PAGE) == OK:
|
||||
install_state_changed.emit({
|
||||
"button_text": "Opened download page",
|
||||
"button_disabled": true,
|
||||
})
|
||||
else:
|
||||
install_state_changed.emit({
|
||||
"button_text": "Couldn't open browser — retry",
|
||||
"button_disabled": false,
|
||||
})
|
||||
return
|
||||
|
||||
if _latest_download_url.is_empty():
|
||||
OS.shell_open(RELEASES_PAGE)
|
||||
return
|
||||
|
||||
install_state_changed.emit({
|
||||
"button_text": "Downloading...",
|
||||
"button_disabled": true,
|
||||
})
|
||||
|
||||
if _download_request != null:
|
||||
_download_request.queue_free()
|
||||
_download_request = HTTPRequest.new()
|
||||
var global_zip := ProjectSettings.globalize_path(UPDATE_TEMP_ZIP)
|
||||
var global_dir := ProjectSettings.globalize_path(UPDATE_TEMP_DIR)
|
||||
DirAccess.make_dir_recursive_absolute(global_dir)
|
||||
_download_request.download_file = global_zip
|
||||
_download_request.max_redirects = 10
|
||||
_download_request.request_completed.connect(_on_download_completed)
|
||||
add_child(_download_request)
|
||||
var err := _download_request.request(_latest_download_url)
|
||||
if err != OK:
|
||||
## `request_completed` never fires when `request()` itself errors,
|
||||
## so cleanup (queue_free + null + drop the staged zip) has to land
|
||||
## inline — otherwise the HTTPRequest stays parented under the
|
||||
## manager until the next click.
|
||||
_download_request.queue_free()
|
||||
_download_request = null
|
||||
DirAccess.remove_absolute(global_zip)
|
||||
install_state_changed.emit({
|
||||
"button_text": "Request failed",
|
||||
"button_disabled": false,
|
||||
})
|
||||
|
||||
|
||||
## Consulted by the dock's spawn paths (focus-in refresh, manual button,
|
||||
## deferred initial refresh) — true while plugin scripts are being
|
||||
## overwritten. A worker mid-`GDScriptFunction::call` into a half-
|
||||
## overwritten script SIGABRTs the editor.
|
||||
func is_install_in_flight() -> bool:
|
||||
return _install_in_flight
|
||||
|
||||
|
||||
# ---- Releases-API parse (pure, testable) -------------------------------
|
||||
|
||||
## Parses the GitHub Releases API JSON response. Returns:
|
||||
## has_update: bool ## true if remote tag > local version
|
||||
## version: String ## remote tag minus leading "v"
|
||||
## forced: bool ## mode_override() == "user" (banner-only hint)
|
||||
## label_text: String ## "Update available: vX.Y.Z" + " (forced)"
|
||||
## download_url: String ## matching `godot-ai-plugin.zip` asset URL
|
||||
##
|
||||
## Static so tests drive it without instancing the manager.
|
||||
static func parse_releases_response(
|
||||
result: int, response_code: int, body: PackedByteArray
|
||||
) -> Dictionary:
|
||||
var out := {
|
||||
"has_update": false,
|
||||
"version": "",
|
||||
"forced": false,
|
||||
"label_text": "",
|
||||
"download_url": "",
|
||||
}
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code != 200:
|
||||
return out
|
||||
var parsed = JSON.parse_string(body.get_string_from_utf8())
|
||||
if parsed == null or not (parsed is Dictionary):
|
||||
return out
|
||||
var json: Dictionary = parsed
|
||||
var tag: String = String(json.get("tag_name", ""))
|
||||
if tag.is_empty():
|
||||
return out
|
||||
var remote_version := tag.trim_prefix("v")
|
||||
var local_version := ClientConfigurator.get_plugin_version()
|
||||
if not _is_newer(remote_version, local_version):
|
||||
return out
|
||||
|
||||
var url := ""
|
||||
var assets: Array = json.get("assets", [])
|
||||
for asset in assets:
|
||||
var asset_dict: Dictionary = asset
|
||||
if String(asset_dict.get("name", "")) == "godot-ai-plugin.zip":
|
||||
url = String(asset_dict.get("browser_download_url", ""))
|
||||
break
|
||||
|
||||
var forced := ClientConfigurator.mode_override() == "user"
|
||||
var label_text := "Update available: v%s" % remote_version
|
||||
if forced:
|
||||
## Forced-user mode (dropdown or env) is the only way the banner
|
||||
## lights up in a dev tree; suffix so the operator notices.
|
||||
label_text += " (forced)"
|
||||
|
||||
out["has_update"] = true
|
||||
out["version"] = remote_version
|
||||
out["forced"] = forced
|
||||
out["label_text"] = label_text
|
||||
out["download_url"] = url
|
||||
return out
|
||||
|
||||
|
||||
static func _is_newer(remote: String, local: String) -> bool:
|
||||
var r := remote.split(".")
|
||||
var l := local.split(".")
|
||||
for i in range(max(r.size(), l.size())):
|
||||
var rv := int(r[i]) if i < r.size() else 0
|
||||
var lv := int(l[i]) if i < l.size() else 0
|
||||
if rv > lv:
|
||||
return true
|
||||
if rv < lv:
|
||||
return false
|
||||
return false
|
||||
|
||||
|
||||
# ---- HTTPRequest callbacks (instance-side) -----------------------------
|
||||
|
||||
func _on_update_check_completed(
|
||||
result: int,
|
||||
response_code: int,
|
||||
_headers: PackedStringArray,
|
||||
body: PackedByteArray
|
||||
) -> void:
|
||||
var parsed := parse_releases_response(result, response_code, body)
|
||||
if not bool(parsed.get("has_update", false)):
|
||||
return
|
||||
_latest_download_url = String(parsed.get("download_url", ""))
|
||||
update_check_completed.emit(parsed)
|
||||
## On engines that can't self-update (Godot < 4.4, #475), surface the
|
||||
## full manual-update guidance AND relabel the button up-front — before
|
||||
## any click — so the user knows what the button does and why.
|
||||
if not _can_self_update():
|
||||
install_state_changed.emit({
|
||||
"button_text": "Open download page",
|
||||
"label_text": _manual_update_label(String(parsed.get("version", ""))),
|
||||
})
|
||||
|
||||
|
||||
func _on_download_completed(
|
||||
result: int,
|
||||
response_code: int,
|
||||
_headers: PackedStringArray,
|
||||
_body: PackedByteArray
|
||||
) -> void:
|
||||
if _download_request != null:
|
||||
_download_request.queue_free()
|
||||
_download_request = null
|
||||
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code != 200:
|
||||
print("MCP | update download failed: result=%d code=%d" % [result, response_code])
|
||||
install_state_changed.emit({
|
||||
"button_text": "Download failed (%d)" % response_code,
|
||||
"button_disabled": false,
|
||||
})
|
||||
return
|
||||
|
||||
install_state_changed.emit({"button_text": "Installing..."})
|
||||
# Deferred so the HTTPRequest callback returns before the extract starts.
|
||||
_install_zip.call_deferred()
|
||||
|
||||
|
||||
# ---- Install orchestration ---------------------------------------------
|
||||
|
||||
func _install_zip() -> void:
|
||||
## Symlinked addons dir means an extract would clobber canonical
|
||||
## `plugin/` source through the link. Symlink detection is independent
|
||||
## of the mode override: even forced-user aborts here. See #116.
|
||||
if ClientConfigurator.addons_dir_is_symlink():
|
||||
install_state_changed.emit({
|
||||
"button_text": "Dev checkout — update via git",
|
||||
"button_disabled": true,
|
||||
"banner_visible": false,
|
||||
})
|
||||
return
|
||||
|
||||
## Drain in-flight workers + block new ones BEFORE any disk write.
|
||||
## Without this, focus-in landing in the extract→reload window spawns
|
||||
## a worker that walks into a partially-overwritten script and
|
||||
## SIGABRTs in `GDScriptFunction::call`.
|
||||
_install_in_flight = true
|
||||
_drain_dock_workers()
|
||||
|
||||
var version := Engine.get_version_info()
|
||||
var has_runner: bool = (
|
||||
_plugin != null
|
||||
and _plugin.has_method("install_downloaded_update")
|
||||
)
|
||||
## Same major-aware predicate as the _can_self_update() gate, so a future
|
||||
## Godot 5.x (minor 0) takes the runner path the gate promised — not the
|
||||
## pre-4.4 inline extract. A bare `minor >= 4` here would route 5.0 to the
|
||||
## crash-prone inline path even though the gate let it in.
|
||||
if _version_can_self_update(int(version.get("major", 0)), int(version.get("minor", 0))) and has_runner:
|
||||
install_state_changed.emit({"button_text": "Reloading..."})
|
||||
## Runner takes over: plugin tears down, runner extracts + scans +
|
||||
## re-enables. `install_downloaded_update` calls
|
||||
## `prepare_for_update_reload()` internally (kills the server,
|
||||
## resets the spawn guard) — see plugin.gd::install_downloaded_update.
|
||||
_plugin.install_downloaded_update(UPDATE_TEMP_ZIP, UPDATE_TEMP_DIR, _dock)
|
||||
return
|
||||
|
||||
_install_zip_inline(version)
|
||||
|
||||
|
||||
func _install_zip_inline(version: Dictionary) -> void:
|
||||
## Pre-4.4 fallback. EditorInterface.set_plugin_enabled off/on is
|
||||
## re-entry-unsafe on older Godot; we extract in-process and ask the
|
||||
## user to restart.
|
||||
var zip_path := ProjectSettings.globalize_path(UPDATE_TEMP_ZIP)
|
||||
var install_base := ProjectSettings.globalize_path("res://")
|
||||
|
||||
var reader := ZIPReader.new()
|
||||
if reader.open(zip_path) != OK:
|
||||
_install_in_flight = false
|
||||
install_state_changed.emit({
|
||||
"button_text": "Extract failed",
|
||||
"button_disabled": false,
|
||||
})
|
||||
return
|
||||
|
||||
var files := reader.get_files()
|
||||
for file_path in files:
|
||||
if not file_path.begins_with("addons/godot_ai/"):
|
||||
continue
|
||||
if file_path.ends_with("/"):
|
||||
DirAccess.make_dir_recursive_absolute(install_base.path_join(file_path))
|
||||
else:
|
||||
var dir := file_path.get_base_dir()
|
||||
DirAccess.make_dir_recursive_absolute(install_base.path_join(dir))
|
||||
var content := reader.read_file(file_path)
|
||||
var f := FileAccess.open(install_base.path_join(file_path), FileAccess.WRITE)
|
||||
if f != null:
|
||||
f.store_buffer(content)
|
||||
f.close()
|
||||
|
||||
reader.close()
|
||||
|
||||
DirAccess.remove_absolute(zip_path)
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(UPDATE_TEMP_DIR))
|
||||
|
||||
## Kill the old server before the reload so the re-enabled plugin spawns
|
||||
## a fresh one against the new plugin version (#132).
|
||||
if _plugin != null and _plugin.has_method("prepare_for_update_reload"):
|
||||
_plugin.prepare_for_update_reload()
|
||||
|
||||
if _version_can_self_update(int(version.get("major", 0)), int(version.get("minor", 0))):
|
||||
install_state_changed.emit({"button_text": "Scanning..."})
|
||||
## Filesystem scan must complete before plugin reload — otherwise
|
||||
## plugin.gd re-parses against a ClassDB that hasn't seen the new
|
||||
## files yet, parse errors, dock tears down silently. See #127.
|
||||
var fs := EditorInterface.get_resource_filesystem()
|
||||
if fs != null:
|
||||
fs.filesystem_changed.connect(
|
||||
_on_filesystem_scanned_for_update, CONNECT_ONE_SHOT
|
||||
)
|
||||
fs.scan()
|
||||
else:
|
||||
_reload_after_update.call_deferred()
|
||||
else:
|
||||
## Pre-4.4: no plugin reload; refreshes resume on the old dock
|
||||
## instance until the user restarts.
|
||||
_install_in_flight = false
|
||||
install_state_changed.emit({
|
||||
"button_text": "Restart editor to apply",
|
||||
"button_disabled": true,
|
||||
"label_text": "Updated! Restart the editor.",
|
||||
"outcome": "success",
|
||||
})
|
||||
|
||||
|
||||
func _on_filesystem_scanned_for_update() -> void:
|
||||
install_state_changed.emit({"button_text": "Reloading..."})
|
||||
_reload_after_update.call_deferred()
|
||||
|
||||
|
||||
func _reload_after_update() -> void:
|
||||
EditorInterface.set_plugin_enabled("res://addons/godot_ai/plugin.cfg", false)
|
||||
EditorInterface.set_plugin_enabled("res://addons/godot_ai/plugin.cfg", true)
|
||||
|
||||
|
||||
func _drain_dock_workers() -> void:
|
||||
if _dock != null and _dock.has_method("prepare_for_self_update_drain"):
|
||||
_dock.prepare_for_self_update_drain()
|
||||
@@ -0,0 +1 @@
|
||||
uid://cegiyw3fjcwev
|
||||
@@ -0,0 +1,140 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
## Scanner that detects whether `addons/godot_ai/` is in a half-installed
|
||||
## state left behind by a self-update whose rollback couldn't restore the
|
||||
## previous addon contents (`UpdateReloadRunner.InstallStatus.FAILED_MIXED`).
|
||||
##
|
||||
## Without this surface the user sees "plugin won't start" with no actionable
|
||||
## context, re-runs the update, and compounds the mismatch (issue #354 /
|
||||
## audit-v2 #10). The dock paints a banner from `diagnose()` and
|
||||
## `editor_handler.gd::get_editor_state` includes the same Dictionary so an
|
||||
## MCP agent can see and report the state.
|
||||
|
||||
const ADDON_DIR := "res://addons/godot_ai/"
|
||||
## Producer is `update_reload_runner.gd::INSTALL_BACKUP_SUFFIX`. Inlined as a
|
||||
## literal because old two-phase runners can parse this diagnostic script
|
||||
## against stale runner Script-object content during their mixed-snapshot
|
||||
## scan. `test_update_backup_suffix_stays_in_sync` guards against drift.
|
||||
const BACKUP_SUFFIX := ".update_backup"
|
||||
## Cap so a runaway addons tree (someone parented the wrong dir, an old
|
||||
## crashed install left thousands of artifacts) can't blow the
|
||||
## `editor_state` payload size or freeze the editor on first paint.
|
||||
const MAX_BACKUP_RESULTS := 200
|
||||
## TTL for the `diagnose()` cache. `editor_state` is one of the highest-
|
||||
## traffic MCP tools (agents poll it constantly) and a recursive
|
||||
## `DirAccess` walk on every call would put I/O on the 4ms `_process()`
|
||||
## budget. Mixed-state is rare and persistent across editor restarts, so
|
||||
## a few seconds of staleness is acceptable; the dock's Re-scan button
|
||||
## bypasses the cache via `force=true` for immediate feedback.
|
||||
const CACHE_TTL_MSEC := 5000
|
||||
|
||||
static var _cache_value: Dictionary = {}
|
||||
static var _cache_timestamp_msec: int = -1
|
||||
|
||||
|
||||
## Walk `dir` recursively and return every `res://`-relative path that ends
|
||||
## in `.update_backup`, sorted ascending. Truncates at `MAX_BACKUP_RESULTS`
|
||||
## — the truncation flag is exposed via `diagnose()`.
|
||||
##
|
||||
## Walk order is deterministic: entries within each directory are sorted
|
||||
## alphabetically, subdirs pushed reverse-sorted so DFS pops them in
|
||||
## ascending order. Without this two scans of the same mixed tree could
|
||||
## return different 200-file slices when truncation kicks in (Godot's
|
||||
## `list_dir` order isn't guaranteed stable across filesystems).
|
||||
static func find_backups(dir: String = ADDON_DIR) -> Array:
|
||||
var results: Array = []
|
||||
var stack: Array = [dir]
|
||||
while not stack.is_empty():
|
||||
if results.size() >= MAX_BACKUP_RESULTS:
|
||||
break
|
||||
var current: String = stack.pop_back()
|
||||
var d := DirAccess.open(current)
|
||||
## Missing dir, permission error, or unreadable junction — skip
|
||||
## silently. A missing addons dir is the bare-clone case; mid-walk
|
||||
## errors stay quiet so a single permission glitch can't block the
|
||||
## diagnostic the rest of the scan would have produced.
|
||||
if d == null:
|
||||
continue
|
||||
var entries: Array = []
|
||||
d.list_dir_begin()
|
||||
while true:
|
||||
var entry := d.get_next()
|
||||
if entry.is_empty():
|
||||
break
|
||||
if entry == "." or entry == "..":
|
||||
continue
|
||||
entries.append({"name": entry, "is_dir": d.current_is_dir()})
|
||||
d.list_dir_end()
|
||||
entries.sort_custom(func(a, b): return a["name"] < b["name"])
|
||||
## Push subdirs reverse-sorted so the next outer iteration pops
|
||||
## them in ascending order — see method docstring for why this
|
||||
## determinism matters for the truncated case.
|
||||
for i in range(entries.size() - 1, -1, -1):
|
||||
var entry: Dictionary = entries[i]
|
||||
if entry["is_dir"]:
|
||||
stack.append(current.path_join(entry["name"]))
|
||||
for entry in entries:
|
||||
if entry["is_dir"]:
|
||||
continue
|
||||
if not String(entry["name"]).ends_with(BACKUP_SUFFIX):
|
||||
continue
|
||||
results.append(current.path_join(entry["name"]))
|
||||
if results.size() >= MAX_BACKUP_RESULTS:
|
||||
break
|
||||
results.sort()
|
||||
return results
|
||||
|
||||
|
||||
## Build the structured diagnostic Dictionary surfaced via `editor_state`
|
||||
## and the dock banner. Empty when the addons tree is clean — callers
|
||||
## gate banner visibility / response field on `is_empty()`.
|
||||
##
|
||||
## Cached for `CACHE_TTL_MSEC` when scanning the default `ADDON_DIR` so
|
||||
## per-`editor_state` polls don't re-walk the addons tree every frame.
|
||||
## Tests passing a custom `dir` always see a fresh scan (cache only
|
||||
## tracks the production path). `force=true` bypasses the cache — used
|
||||
## by the dock's Re-scan button so a manual fix is reflected immediately.
|
||||
static func diagnose(dir: String = ADDON_DIR, force: bool = false) -> Dictionary:
|
||||
var use_cache := dir == ADDON_DIR and not force
|
||||
if use_cache and _cache_timestamp_msec >= 0:
|
||||
if Time.get_ticks_msec() - _cache_timestamp_msec < CACHE_TTL_MSEC:
|
||||
return _cache_value.duplicate(true)
|
||||
|
||||
var backups := find_backups(dir)
|
||||
var result: Dictionary = {}
|
||||
if not backups.is_empty():
|
||||
## Most commonly produced by `_rollback_paths_written` returning
|
||||
## FAILED_MIXED, but `_finalize_install_success` removes backups on
|
||||
## a best-effort basis so a successful install can also leave them
|
||||
## behind if the cleanup `remove_absolute` hit a permission error.
|
||||
## The recovery action — delete the *.update_backup files — is the
|
||||
## same in both cases, so the message acknowledges both
|
||||
## possibilities rather than asserting the alarming one.
|
||||
result = {
|
||||
"addon_dir": dir,
|
||||
"backup_files": backups,
|
||||
"backup_count": backups.size(),
|
||||
"truncated": backups.size() >= MAX_BACKUP_RESULTS,
|
||||
"message": (
|
||||
"Found .update_backup files in addons/godot_ai/. This usually"
|
||||
+ " means a self-update rollback couldn't restore the previous"
|
||||
+ " addon contents (FAILED_MIXED) — the plugin may load a mix"
|
||||
+ " of old and new files. Restore the addon from your VCS or a"
|
||||
+ " fresh release ZIP, then delete the listed *.update_backup"
|
||||
+ " files. If the plugin runs without issues these are likely"
|
||||
+ " stale from a successful install and safe to delete."
|
||||
),
|
||||
}
|
||||
if use_cache:
|
||||
_cache_value = result.duplicate(true)
|
||||
_cache_timestamp_msec = Time.get_ticks_msec()
|
||||
return result
|
||||
|
||||
|
||||
## Reset the `diagnose()` cache. Tests that flip the addons-tree state
|
||||
## between calls use this to avoid TTL-bound flakiness; the dock's
|
||||
## Re-scan button uses `force=true` instead.
|
||||
static func clear_cache() -> void:
|
||||
_cache_value = {}
|
||||
_cache_timestamp_msec = -1
|
||||
@@ -0,0 +1 @@
|
||||
uid://dd5rti52vgs71
|
||||
@@ -0,0 +1,161 @@
|
||||
@tool
|
||||
class_name McpUvCacheCleanup
|
||||
extends RefCounted
|
||||
|
||||
## Sweeps stale `.tmp*` build venvs out of `%LOCALAPPDATA%\uv\cache\builds-v0`.
|
||||
##
|
||||
## Background
|
||||
## ----------
|
||||
## When Claude Desktop's MCP launcher invokes `uvx mcp-proxy ...` to talk to
|
||||
## a running godot-ai server, uv builds an ephemeral venv under
|
||||
## `builds-v0\.tmpXXXXXX\`. To save disk it hard-links shared C extensions
|
||||
## (notably `pydantic_core/_pydantic_core.cp313-win_amd64.pyd`) from
|
||||
## `archive-v0\<hash>\Lib\site-packages\...` into the build venv.
|
||||
##
|
||||
## If the godot-ai server's own Python child has that same `.pyd` mapped via
|
||||
## `LoadLibrary` (it does — godot-ai imports pydantic), the file is locked
|
||||
## under BOTH paths because hard links share the inode and Windows tracks
|
||||
## handles per-file, not per-path. uv's post-install cleanup of the build
|
||||
## venv then dies with:
|
||||
##
|
||||
## Failed to install: pywin32-311-cp313-cp313-win_amd64.whl (pywin32==311)
|
||||
## Caused by: failed to remove directory `...\.tmpXXXXXX\Lib\site-packages\pywin32-311.data`
|
||||
## 다른 프로세스가 파일을 사용 중이기 때문에 ... (os error 32)
|
||||
##
|
||||
## (the `pywin32` mention is incidental — the actual lock is on the earlier
|
||||
## hard-linked `_pydantic_core.pyd`; pywin32 is just the last install step
|
||||
## in the wheel-resolution order that triggers the cleanup pass).
|
||||
##
|
||||
## What this does
|
||||
## --------------
|
||||
## After the plugin stops/restarts the managed server — i.e. the moment when
|
||||
## the archive-v0 `.pyd` mappings drop and the hard-linked builds-v0 copy
|
||||
## becomes deletable — sweep `builds-v0\` for `.tmp*` orphans:
|
||||
##
|
||||
## 1. Rename each `.tmpXXX` to `_dead_.tmpXXX`. Rename succeeds even when
|
||||
## AV scanners hold the file open without `FILE_SHARE_DELETE` (Defender
|
||||
## and Softcamp SDS both do this), so this step always advances.
|
||||
## 2. Recursively remove the renamed dir, swallowing per-file
|
||||
## access-denied. Anything still genuinely locked is left for the next
|
||||
## sweep — uv won't reuse the renamed name, so no future build collides.
|
||||
##
|
||||
## No-op on non-Windows (uv's hard-link strategy only causes this lock
|
||||
## pattern on NTFS) and when the cache directory doesn't exist.
|
||||
|
||||
const DEAD_PREFIX := "_dead_"
|
||||
const TMP_PREFIX := ".tmp"
|
||||
|
||||
|
||||
## Live entrypoint. Resolves `%LOCALAPPDATA%\uv\cache\builds-v0` and runs
|
||||
## the sweep. Returns the same counts the testable `purge_directory` returns,
|
||||
## or all zeros on non-Windows / missing cache.
|
||||
static func purge_stale_builds() -> Dictionary:
|
||||
if OS.get_name() != "Windows":
|
||||
return _empty_result()
|
||||
var local_appdata := OS.get_environment("LOCALAPPDATA")
|
||||
if local_appdata.is_empty():
|
||||
return _empty_result()
|
||||
var builds_root := local_appdata.replace("\\", "/").path_join("uv/cache/builds-v0")
|
||||
return purge_directory(builds_root)
|
||||
|
||||
|
||||
## Pure-ish entrypoint that takes a directory path. Returns
|
||||
## `{ "scanned": int, "renamed": int, "deleted": int, "remaining": int }`.
|
||||
## - `scanned`: how many `.tmp*` subdirs we saw on entry.
|
||||
## - `renamed`: how many we successfully renamed to `_dead_*`.
|
||||
## - `deleted`: how many we then fully removed.
|
||||
## - `remaining`: how many `_dead_*` dirs are still on disk after the sweep
|
||||
## (left for the next call to retry).
|
||||
##
|
||||
## Errors are swallowed — the caller is on a server-stop hot path and
|
||||
## must not raise.
|
||||
static func purge_directory(builds_root: String) -> Dictionary:
|
||||
var result := _empty_result()
|
||||
if not DirAccess.dir_exists_absolute(builds_root):
|
||||
return result
|
||||
var dir := DirAccess.open(builds_root)
|
||||
if dir == null:
|
||||
return result
|
||||
dir.include_hidden = true
|
||||
|
||||
## Pass 1: collect names. Iterating + renaming in the same walk would
|
||||
## confuse DirAccess's internal cursor on NTFS.
|
||||
var tmp_names: Array[String] = []
|
||||
var dead_names: Array[String] = []
|
||||
dir.list_dir_begin()
|
||||
var entry := dir.get_next()
|
||||
while entry != "":
|
||||
if dir.current_is_dir() and not (entry == "." or entry == ".."):
|
||||
if entry.begins_with(TMP_PREFIX):
|
||||
tmp_names.append(entry)
|
||||
elif entry.begins_with(DEAD_PREFIX):
|
||||
dead_names.append(entry)
|
||||
entry = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
result.scanned = tmp_names.size()
|
||||
|
||||
## Pass 2: rename `.tmp*` → `_dead_.tmp*`. Rename works even on
|
||||
## AV-locked files (Defender opens without FILE_SHARE_DELETE, but rename
|
||||
## doesn't need delete share). Any rename failure is non-fatal.
|
||||
for name in tmp_names:
|
||||
var src := builds_root.path_join(name)
|
||||
var dst := builds_root.path_join(DEAD_PREFIX + name)
|
||||
if dir.rename(src, dst) == OK:
|
||||
result.renamed += 1
|
||||
dead_names.append(DEAD_PREFIX + name)
|
||||
|
||||
## Pass 3: best-effort recursive delete of every `_dead_*`, including
|
||||
## ones left over from earlier sweeps that couldn't be cleaned then.
|
||||
for name in dead_names:
|
||||
var path := builds_root.path_join(name)
|
||||
if _remove_recursive(path):
|
||||
result.deleted += 1
|
||||
|
||||
## Final pass: count `_dead_*` survivors so the caller (and tests) can
|
||||
## see how many genuinely-locked dirs we couldn't reach.
|
||||
var dir2 := DirAccess.open(builds_root)
|
||||
if dir2 != null:
|
||||
dir2.include_hidden = true
|
||||
dir2.list_dir_begin()
|
||||
var e := dir2.get_next()
|
||||
while e != "":
|
||||
if dir2.current_is_dir() and e.begins_with(DEAD_PREFIX):
|
||||
result.remaining += 1
|
||||
e = dir2.get_next()
|
||||
dir2.list_dir_end()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
## Recursive `rm -rf` that swallows access-denied per-file. Returns true
|
||||
## only when the target directory itself was removed.
|
||||
static func _remove_recursive(path: String) -> bool:
|
||||
var dir := DirAccess.open(path)
|
||||
if dir == null:
|
||||
## Already gone, or unreadable — try a direct remove just in case
|
||||
## (an empty dir handle-leak path) and report based on existence.
|
||||
DirAccess.remove_absolute(path)
|
||||
return not DirAccess.dir_exists_absolute(path)
|
||||
dir.include_hidden = true
|
||||
dir.list_dir_begin()
|
||||
var entry := dir.get_next()
|
||||
while entry != "":
|
||||
if entry == "." or entry == "..":
|
||||
entry = dir.get_next()
|
||||
continue
|
||||
var child := path.path_join(entry)
|
||||
if dir.current_is_dir():
|
||||
_remove_recursive(child)
|
||||
else:
|
||||
DirAccess.remove_absolute(child)
|
||||
entry = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
## Remove the (hopefully now empty) dir itself. If a hard-linked .pyd is
|
||||
## still mapped by a surviving process, this fails silently and the
|
||||
## caller sees `remaining > 0` so it can retry on the next sweep.
|
||||
DirAccess.remove_absolute(path)
|
||||
return not DirAccess.dir_exists_absolute(path)
|
||||
|
||||
|
||||
static func _empty_result() -> Dictionary:
|
||||
return { "scanned": 0, "renamed": 0, "deleted": 0, "remaining": 0 }
|
||||
@@ -0,0 +1 @@
|
||||
uid://d33ukg65qf7q0
|
||||
@@ -0,0 +1,71 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
## Converts Godot Variants into values that can be encoded as JSON.
|
||||
|
||||
|
||||
static func serialize(value: Variant) -> Variant:
|
||||
if value == null:
|
||||
return null
|
||||
match typeof(value):
|
||||
TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING:
|
||||
return value
|
||||
TYPE_STRING_NAME:
|
||||
return str(value)
|
||||
TYPE_VECTOR2, TYPE_VECTOR2I:
|
||||
return {"x": value.x, "y": value.y}
|
||||
TYPE_VECTOR3, TYPE_VECTOR3I:
|
||||
return {"x": value.x, "y": value.y, "z": value.z}
|
||||
TYPE_VECTOR4, TYPE_VECTOR4I, TYPE_QUATERNION:
|
||||
return {"x": value.x, "y": value.y, "z": value.z, "w": value.w}
|
||||
TYPE_COLOR:
|
||||
return {"r": value.r, "g": value.g, "b": value.b, "a": value.a}
|
||||
TYPE_RECT2, TYPE_RECT2I, TYPE_AABB:
|
||||
return {
|
||||
"position": serialize(value.position),
|
||||
"size": serialize(value.size),
|
||||
}
|
||||
TYPE_PLANE:
|
||||
return {"normal": serialize(value.normal), "d": value.d}
|
||||
TYPE_BASIS:
|
||||
return {
|
||||
"x": serialize(value.x),
|
||||
"y": serialize(value.y),
|
||||
"z": serialize(value.z),
|
||||
}
|
||||
TYPE_TRANSFORM2D:
|
||||
return {
|
||||
"x": serialize(value.x),
|
||||
"y": serialize(value.y),
|
||||
"origin": serialize(value.origin),
|
||||
}
|
||||
TYPE_TRANSFORM3D:
|
||||
return {
|
||||
"basis": serialize(value.basis),
|
||||
"origin": serialize(value.origin),
|
||||
}
|
||||
TYPE_PROJECTION:
|
||||
return {
|
||||
"x": serialize(value.x),
|
||||
"y": serialize(value.y),
|
||||
"z": serialize(value.z),
|
||||
"w": serialize(value.w),
|
||||
}
|
||||
TYPE_NODE_PATH:
|
||||
return str(value)
|
||||
TYPE_ARRAY, TYPE_PACKED_BYTE_ARRAY, TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY, TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY, TYPE_PACKED_STRING_ARRAY, TYPE_PACKED_VECTOR2_ARRAY, TYPE_PACKED_VECTOR3_ARRAY, TYPE_PACKED_VECTOR4_ARRAY, TYPE_PACKED_COLOR_ARRAY:
|
||||
var arr: Array = []
|
||||
for item in value:
|
||||
arr.append(serialize(item))
|
||||
return arr
|
||||
TYPE_DICTIONARY:
|
||||
var out := {}
|
||||
for key in value:
|
||||
out[str(key)] = serialize(value[key])
|
||||
return out
|
||||
TYPE_OBJECT:
|
||||
if value is Resource and value.resource_path:
|
||||
return value.resource_path
|
||||
return str(value)
|
||||
_:
|
||||
return str(value)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cte37mtbd61n3
|
||||
@@ -0,0 +1,166 @@
|
||||
@tool
|
||||
class_name McpWindowsPortReservation
|
||||
extends RefCounted
|
||||
|
||||
## Detects whether Windows has reserved a TCP port range that covers the
|
||||
## plugin's server port. Hyper-V, WSL2, Docker Desktop, and Windows
|
||||
## Sandbox all grab port ranges at boot via the winnat service. When a
|
||||
## user's chosen port sits inside a reserved range, bind(2) fails with
|
||||
## WinError 10013 ("forbidden by its access permissions") rather than
|
||||
## 10048 ("address in use") — `netstat` shows nothing because no process
|
||||
## owns the port, making the failure invisible. See issue #146.
|
||||
|
||||
const NETSH_ARGS := ["interface", "ipv4", "show", "excludedportrange", "protocol=tcp"]
|
||||
const NETSH_CACHE_TTL_MS := 2000
|
||||
|
||||
static var _netsh_cache_text := ""
|
||||
static var _netsh_cache_msec := 0
|
||||
static var _netsh_cache_valid := false
|
||||
static var _netsh_query_count := 0
|
||||
|
||||
|
||||
## Returns true if `port` falls inside a currently-reserved range on this
|
||||
## Windows host. No-op on non-Windows (returns false).
|
||||
static func is_port_excluded(port: int) -> bool:
|
||||
if OS.get_name() != "Windows":
|
||||
return false
|
||||
var now_ms := Time.get_ticks_msec()
|
||||
var cached := _get_cached_excluded_output(now_ms)
|
||||
if bool(cached.get("hit", false)):
|
||||
return parse_excluded(str(cached.get("text", "")), port)
|
||||
var output: Array = []
|
||||
var exit_code := _execute_netsh_excluded_ranges(output)
|
||||
if exit_code != 0 or output.is_empty():
|
||||
return false
|
||||
var text := str(output[0])
|
||||
_store_excluded_output(text, now_ms)
|
||||
return parse_excluded(text, port)
|
||||
|
||||
|
||||
static func _store_excluded_output(text: String, now_ms: int) -> void:
|
||||
_netsh_cache_text = text
|
||||
_netsh_cache_msec = now_ms
|
||||
_netsh_cache_valid = true
|
||||
|
||||
|
||||
static func _get_cached_excluded_output(now_ms: int) -> Dictionary:
|
||||
if not _netsh_cache_valid:
|
||||
return {"hit": false, "text": ""}
|
||||
if now_ms - _netsh_cache_msec > NETSH_CACHE_TTL_MS:
|
||||
return {"hit": false, "text": ""}
|
||||
return {"hit": true, "text": _netsh_cache_text}
|
||||
|
||||
|
||||
static func _clear_cache_for_tests() -> void:
|
||||
_netsh_cache_text = ""
|
||||
_netsh_cache_msec = 0
|
||||
_netsh_cache_valid = false
|
||||
|
||||
|
||||
static func netsh_query_count() -> int:
|
||||
return _netsh_query_count
|
||||
|
||||
|
||||
static func _execute_netsh_excluded_ranges(output: Array) -> int:
|
||||
_netsh_query_count += 1
|
||||
return OS.execute("netsh", NETSH_ARGS, output, true)
|
||||
|
||||
|
||||
## Parse the `netsh` excluded-port-range output and return true if `port`
|
||||
## sits inside any reserved range. Exposed for testing; the live check
|
||||
## uses `is_port_excluded`. Expected input format:
|
||||
##
|
||||
## Protocol tcp Port Exclusion Ranges
|
||||
##
|
||||
## Start Port End Port
|
||||
## ---------- --------
|
||||
## 80 80
|
||||
## 5040 5040
|
||||
## 8000 8099
|
||||
##
|
||||
## * - Administered port exclusions.
|
||||
static func parse_excluded(text: String, port: int) -> bool:
|
||||
return _ranges_contain(parse_excluded_ranges(text), port)
|
||||
|
||||
|
||||
## Parse the `netsh` excluded-port-range output once into inclusive ranges.
|
||||
static func parse_excluded_ranges(text: String) -> Array[Vector2i]:
|
||||
var ranges: Array[Vector2i] = []
|
||||
for line in text.split("\n"):
|
||||
var trimmed := line.strip_edges()
|
||||
if trimmed.is_empty() or trimmed.begins_with("-") or trimmed.begins_with("*"):
|
||||
continue
|
||||
var parts: PackedStringArray = trimmed.split(" ", false)
|
||||
if parts.size() < 2:
|
||||
continue
|
||||
if not parts[0].is_valid_int() or not parts[1].is_valid_int():
|
||||
continue
|
||||
var start_p := int(parts[0])
|
||||
var end_p := int(parts[1])
|
||||
ranges.append(Vector2i(start_p, end_p))
|
||||
return ranges
|
||||
|
||||
|
||||
static func _ranges_contain(ranges: Array[Vector2i], port: int) -> bool:
|
||||
for r in ranges:
|
||||
if port >= r.x and port <= r.y:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
## Return the first port in `start`..`start+span-1` that is not excluded by
|
||||
## Windows' port reservation table. Runs `netsh` once, unlike probing every
|
||||
## candidate with `is_port_excluded`, which keeps fallback port selection cheap
|
||||
## when Hyper-V / WSL2 / Docker reserve many adjacent ranges.
|
||||
static func suggest_non_excluded_port(start: int, span: int = 2048, max_port: int = 65535) -> int:
|
||||
if OS.get_name() != "Windows":
|
||||
return start
|
||||
var now_ms := Time.get_ticks_msec()
|
||||
var cached := _get_cached_excluded_output(now_ms)
|
||||
if bool(cached.get("hit", false)):
|
||||
return suggest_non_excluded_port_from_output(str(cached.get("text", "")), start, span, max_port)
|
||||
var output: Array = []
|
||||
var exit_code := _execute_netsh_excluded_ranges(output)
|
||||
if exit_code != 0 or output.is_empty():
|
||||
return start
|
||||
var text := str(output[0])
|
||||
_store_excluded_output(text, now_ms)
|
||||
return suggest_non_excluded_port_from_output(text, start, span, max_port)
|
||||
|
||||
|
||||
## Pure parser-backed helper for tests and for `suggest_non_excluded_port`.
|
||||
static func suggest_non_excluded_port_from_output(text: String, start: int, span: int = 2048, max_port: int = 65535) -> int:
|
||||
var ranges := parse_excluded_ranges(text)
|
||||
var limit := mini(start + span - 1, max_port)
|
||||
var p := start
|
||||
while p <= limit:
|
||||
var advanced := false
|
||||
for r in ranges:
|
||||
if p >= r.x and p <= r.y:
|
||||
p = r.y + 1
|
||||
advanced = true
|
||||
break
|
||||
if not advanced:
|
||||
return p
|
||||
return start
|
||||
|
||||
|
||||
## User-facing hint for the proactive port-reservation detection path —
|
||||
## rendered when `is_port_excluded(port)` returns true *before* we even
|
||||
## try to bind. Same copy as the post-crash WinError-10013 branch in
|
||||
## `hint_from_output` so the two entry points agree.
|
||||
static func port_excluded_hint(port: int) -> String:
|
||||
return "Port %d is reserved by Windows (often Hyper-V / WSL2 / Docker Desktop). In an admin PowerShell: `net stop winnat; net start winnat`, then click Reconnect." % port
|
||||
|
||||
|
||||
## Scan captured server output for known failure signatures and return a
|
||||
## short, user-facing hint. Empty string means no match.
|
||||
static func hint_from_output(lines: PackedStringArray, port: int) -> String:
|
||||
var joined := "\n".join(lines).to_lower()
|
||||
if joined.find("winerror 10013") >= 0 or joined.find("forbidden by its access permissions") >= 0:
|
||||
return port_excluded_hint(port)
|
||||
if joined.find("errno 98") >= 0 or joined.find("winerror 10048") >= 0 or joined.find("address already in use") >= 0:
|
||||
return "Port %d is already in use by another process. Stop the conflicting process, then click Reconnect." % port
|
||||
if joined.find("modulenotfounderror") >= 0 or joined.find("no module named") >= 0:
|
||||
return "The `godot-ai` Python package didn't load. Try `uv cache clean`, then Reconnect."
|
||||
return ""
|
||||
@@ -0,0 +1 @@
|
||||
uid://bt7mxpjcdrobq
|
||||
Reference in New Issue
Block a user