godot ai插件

This commit is contained in:
2026-09-21 16:16:59 +08:00
parent c9c7d59d1e
commit c1a381c589
284 changed files with 45866 additions and 776 deletions
+170
View File
@@ -0,0 +1,170 @@
@tool
class_name McpAllowHosts
extends RefCounted
## Client-side helpers for the `--allow-host` LAN opt-in (#507, server core
## in #421). Pure static functions only — no EditorSettings, no sockets —
## so the settings-value → launch-args plumbing and the manual-command LAN
## URL builder are deterministically testable without a live editor.
##
## Accepted syntax mirrors the server's `parse_allow_hosts`
## (src/godot_ai/transport/origin_guard.py): each token is a bare IP
## (IPv4 or IPv6) or a CIDR, comma-separated. Host bits set on a CIDR are
## tolerated server-side (`strict=False`), so we only validate the IP part
## and the prefix length here — anything else fails loudly at server
## startup, which the dock-side validation exists to pre-empt.
## Canonicalize a comma-separated allow-host value: whitespace-stripped,
## deduplicated, sorted. Returns "" for a value with no usable tokens so
## callers can skip appending `--allow-host` entirely (keeps spawns
## compatible with pre-#421 servers — same contract as
## `ClientConfigurator.excluded_domains()`).
static func normalize(raw: String) -> String:
var parts := PackedStringArray()
for p in raw.split(","):
var t := p.strip_edges()
if not t.is_empty() and parts.find(t) == -1:
parts.append(t)
parts.sort()
return ",".join(parts)
## Whether a single token is a bare IP or a CIDR the server will accept.
static func token_is_valid(token: String) -> bool:
var t := token.strip_edges()
if t.is_empty():
return false
var ip := t
var prefix := 0
## Track slash presence separately from the prefix value: reusing -1 as
## the "no slash" sentinel let an explicit negative prefix like
## "10.0.0.0/-1" validate (is_valid_int accepts "-1"). CodeRabbit review.
var has_prefix := false
var slash := t.find("/")
if slash != -1:
ip = t.substr(0, slash)
var prefix_text := t.substr(slash + 1)
## Explicit signs are rejected by the server's parse_allow_hosts
## (ipaddress refuses "10.0.0.0/+8"), but is_valid_int accepts
## them — keep the mirror honest.
if prefix_text.is_empty() or prefix_text.begins_with("+") or prefix_text.begins_with("-"):
return false
if not prefix_text.is_valid_int():
return false
prefix = int(prefix_text)
has_prefix = true
if not ip.is_valid_ip_address():
return false
var max_prefix := 128 if ip.contains(":") else 32
return not has_prefix or (prefix >= 0 and prefix <= max_prefix)
## Every token in `raw` that fails `token_is_valid` — the dock surfaces
## these inline so a typo is caught before it aborts the server spawn.
static func invalid_tokens(raw: String) -> PackedStringArray:
var bad := PackedStringArray()
for p in raw.split(","):
var t := p.strip_edges()
if t.is_empty():
continue
if not token_is_valid(t) and bad.find(t) == -1:
bad.append(t)
return bad
## True when the allowlist names at least one non-loopback range — i.e.
## the server is actually reachable off this machine and the manual
## command should surface a LAN URL.
static func is_lan_allowlist_active(value: String) -> bool:
for p in value.split(","):
var t := p.strip_edges()
if t.is_empty():
continue
var ip := t.substr(0, t.find("/")) if t.contains("/") else t
if not _is_loopback(ip):
return true
return false
## Choose the LAN address to show in the manual command from the
## machine's local addresses (caller passes `IP.get_local_addresses()`
## so this stays pure). Loopback and link-local addresses are dropped;
## the first private-range IPv4 wins, then any remaining IPv4, then
## anything left (IPv6). Returns `{"address": String, "ambiguous": bool}`
## — `ambiguous` flags multiple viable candidates so the note can tell
## the user to pick the interface on their trusted network.
static func pick_lan_address(addresses: PackedStringArray) -> Dictionary:
var candidates := PackedStringArray()
for a in addresses:
var addr := String(a).strip_edges()
if addr.is_empty() or _is_loopback(addr) or _is_link_local(addr):
continue
candidates.append(addr)
if candidates.is_empty():
return {"address": "", "ambiguous": false}
var chosen := ""
for addr in candidates:
if _is_private_ipv4(addr):
chosen = addr
break
if chosen.is_empty():
for addr in candidates:
if addr.contains("."):
chosen = addr
break
if chosen.is_empty():
chosen = candidates[0]
return {"address": chosen, "ambiguous": candidates.size() > 1}
## Informational LAN-URL note appended to the manual command when the
## allowlist is active (#507). Never changes what gets WRITTEN to client
## configs — loopback stays the write target; this is copy-paste help for
## pointing a remote agent at the right address.
static func lan_url_note(allow_hosts_value: String, addresses: PackedStringArray, http_port: int) -> String:
if not is_lan_allowlist_active(allow_hosts_value):
return ""
var pick := pick_lan_address(addresses)
var addr := String(pick.get("address", ""))
if addr.is_empty():
return (
"LAN access is enabled (--allow-host %s), but no LAN address was detected on this machine."
% allow_hosts_value
)
var host := "[%s]" % addr if addr.contains(":") else addr
var note := (
"LAN access is enabled (--allow-host %s). Remote agents on the allowed network can use: http://%s:%d/mcp"
% [allow_hosts_value, host, http_port]
)
if bool(pick.get("ambiguous", false)):
note += "\n(multiple network interfaces detected — pick the address on the network you allowed)"
return note
static func _is_loopback(addr: String) -> bool:
var a := addr.to_lower()
return a.begins_with("127.") or a == "::1" or a == "localhost"
static func _is_link_local(addr: String) -> bool:
var a := addr.to_lower()
if a.begins_with("169.254."):
return true
## IPv6 link-local is fe80::/10 — the whole fe80-febf first hextet, not
## just literal "fe80" (Copilot review on #507's PR: fea0::... etc. must
## also be excluded from LAN-URL candidates).
if a.length() >= 4 and a.begins_with("fe") and a[2] in "89ab":
return true
return false
static func _is_private_ipv4(addr: String) -> bool:
if not addr.contains("."):
return false
if addr.begins_with("10.") or addr.begins_with("192.168."):
return true
if addr.begins_with("172."):
var second := int(addr.get_slice(".", 1))
return second >= 16 and second <= 31
return false
+1
View File
@@ -0,0 +1 @@
uid://b7qk3vw2nxr4d
@@ -0,0 +1,259 @@
@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")
## Sections returned when the caller does not name any. Deliberately narrow:
## a bare `get_class` is almost always "what properties does X have", and the
## full five-section dump for a large class (e.g. Node, Control) costs an agent
## thousands of tokens it rarely wanted. Callers opt into the rest by name, or
## request the lot with the "all" keyword (see `_sections`).
const DEFAULT_SECTIONS: Array[String] = ["properties"]
## The full documentation-shaped section set (excludes the heavier, separately
## gated "inheritors"). Expanded from the "all" keyword.
const ALL_SECTIONS: Array[String] = ["properties", "methods", "signals", "enums", "constants"]
const KNOWN_SECTIONS: Array[String] = ["properties", "methods", "signals", "enums", "constants", "inheritors"]
## Tokens a caller may legitimately pass in `sections` — the known sections plus
## the "all" meta-keyword. Used for error suggestions so a typo like "al" can
## resolve to "all"; "all" is NOT a section (it expands in `_sections`), so it
## stays out of KNOWN_SECTIONS which gates validity.
const SUGGESTABLE_SECTION_TOKENS: Array[String] = [
"properties", "methods", "signals", "enums", "constants", "inheritors", "all"
]
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 section == "all":
for expanded in ALL_SECTIONS:
if not result.has(expanded):
result.append(expanded)
continue
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
+966
View File
@@ -0,0 +1,966 @@
@tool
extends Node
## Plugin-lifetime owner for every background client-configuration job.
##
## The Dock is replaceable UI. Threads cannot share that lifetime: a timed-out
## probe or package pre-warm can outlive the panel that requested it, and
## destroying a live Godot Thread corrupts the GDScript VM. This node therefore
## owns both worker pools, their cancellation, phases, and one action slot per
## client until its Thread has been joined. It publishes copied value snapshots
## and outcomes; it never retains the Dock, plugin, lifecycle, or update manager.
const Client := preload("res://addons/godot_ai/clients/_base.gd")
const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd")
const ClientRegistry := preload("res://addons/godot_ai/clients/_registry.gd")
const JsonStrategy := preload("res://addons/godot_ai/clients/_json_strategy.gd")
const TomlStrategy := preload("res://addons/godot_ai/clients/_toml_strategy.gd")
const DshStrategy := preload("res://addons/godot_ai/clients/_dsh_strategy.gd")
const CliStrategy := preload("res://addons/godot_ai/clients/_cli_strategy.gd")
const RefreshState := preload("res://addons/godot_ai/utils/mcp_client_refresh_state.gd")
const MutationLock := preload("res://addons/godot_ai/utils/client_mutation_lock.gd")
const STATUS_COOLDOWN_MSEC := 15 * 1000
const STATUS_TIMEOUT_MSEC := 30 * 1000
## One scoped CLI configure may run three bounded cleanup commands, one write,
## discovery, and read-back verification before it reaches the optional package
## prewarm. Keep the owner watchdog above that real mutation budget.
const ACTION_TIMEOUT_MSEC := 75 * 1000
const MCP_ACTION_DEFERRED_GRACE_MSEC := 5 * 1000
const MAX_MCP_STATUS_WAITERS := 64
const PHASE_PREWARM := "prewarm"
const MUTATION_UNPROVEN_META := &"_godot_ai_client_mutation_termination_unproven"
const POST_UPDATE_UNPROVEN_META := &"_godot_ai_post_update_termination_unproven"
signal snapshot_changed(snapshot: Dictionary)
signal status_refresh_completed(results: Dictionary)
signal mcp_status_completed(request_ids: Array[String], payload: Dictionary)
signal mcp_action_completed(request_id: String, payload: Dictionary)
signal post_update_repin_completed(result: Dictionary)
signal action_completed(client_id: String, action: String, result: Dictionary, prewarm: Dictionary)
signal action_timed_out(client_id: String, action: String, detail: String)
var _accepting_work := false
var _client_health_blocked := false
var _client_ids: Array[String] = []
var _refresh_thread: Thread
var _refresh_state := RefreshState.IDLE
var _refresh_pending := false
var _refresh_pending_force := false
var _refresh_completed_msec := 0
var _refresh_started_msec := 0
var _refresh_generation := 0
## request_id -> first refresh generation allowed to answer it. An MCP request
## arriving behind a partial Dock refresh waits for the coalesced all-client
## retry instead of accepting the partial result.
var _mcp_status_waiters: Dictionary = {}
var _action_threads: Dictionary = {}
var _action_started_msec: Dictionary = {}
var _action_names: Dictionary = {}
var _action_request_ids: Dictionary = {}
var _action_timeout_reported: Dictionary = {}
var _action_phase_mutex := Mutex.new()
var _action_phases: Dictionary = {}
var _action_cancel_mutex := Mutex.new()
var _action_cancelled: Dictionary = {}
## client_id -> action whose CLI termination could not be proven. Mirrored into
## process-lifetime Engine metadata for immediate reload safety; the CLI
## strategy's global lock directory is the durable cross-restart authority.
var _mutation_termination_unproven: Dictionary = {}
## A successful update enters one pre-activation migration job. It is owned and
## joined here like every other client worker, while ordinary Dock/MCP work
## remains closed and the server lifecycle stays dormant.
var _post_update_thread: Thread
var _post_update_generation := 0
var _post_update_cancel_mutex := Mutex.new()
var _post_update_cancelled := false
var _post_update_termination_unproven := false
var _last_snapshot: Dictionary = {}
func _init() -> void:
var mutations: Variant = Engine.get_meta(MUTATION_UNPROVEN_META, {})
if mutations is Dictionary:
_mutation_termination_unproven = (mutations as Dictionary).duplicate(true)
_post_update_termination_unproven = bool(
Engine.get_meta(POST_UPDATE_UNPROVEN_META, false)
)
func _ready() -> void:
## Work can start — and the owner can be activated — before this node is
## ready. The composition root adds this child, wires it, and calls
## `activate()` inside the plugin's `_enter_tree`; Godot defers a child's
## `_ready` until that returns, so this callback runs LAST, after both the
## post-update migration thread and the ordinary activation seam.
##
## Scripts that define `_process` are processing by default, so an inert
## owner still has to turn it off here. What it must never do is turn it
## off again once work has been admitted: `_process` is the only thing that
## joins finished workers, so disabling it after `activate()` strands every
## refresh and client action for the rest of the session — the sweep never
## leaves RUNNING, Configure never leaves its last published phase.
set_process(_accepting_work or _has_work_in_flight())
func _has_work_in_flight() -> bool:
return _post_update_thread != null or _refresh_thread != null or not _action_threads.is_empty()
## Construction is inert. The composition root calls activate only after its
## startup barrier has allowed normal client work.
func activate() -> void:
if _refresh_state == RefreshState.SHUTTING_DOWN or _post_update_thread != null:
return
_accepting_work = true
set_process(true)
_publish_snapshot()
_retry_deferred_refresh()
func set_client_health_blocked(blocked: bool) -> void:
_client_health_blocked = blocked
if not blocked:
_retry_deferred_refresh()
func snapshot() -> Dictionary:
var busy: Array[String] = []
for client_id in _action_threads:
busy.append(String(client_id))
busy.sort()
return {
"accepting_work": _accepting_work,
"refresh_state": _refresh_state,
"refresh_completed": _refresh_completed_msec > 0,
"post_update_repin_running": _post_update_thread != null,
"busy_actions": busy,
"action_names": _action_names.duplicate(true),
"action_phases": _read_action_phases(),
}
func _publish_snapshot() -> void:
var value := snapshot()
if value == _last_snapshot:
return
_last_snapshot = value.duplicate(true)
snapshot_changed.emit(value)
func _process(_delta: float) -> void:
_poll_post_update_repin()
_poll_refresh()
_poll_actions()
_check_refresh_timeout()
_check_action_timeouts()
_retry_deferred_refresh()
## Stop accepting work, cooperatively cancel action subprocesses, then realize
## every Thread before scripts can be overwritten. Godot exposes no timed join;
## deadline_msec is reported honestly rather than pretending the join can be
## interrupted without risking VM corruption.
func quiesce(deadline_msec: int = 0) -> Dictionary:
_accepting_work = false
_refresh_state = RefreshState.SHUTTING_DOWN
_publish_snapshot()
for client_id in _action_threads:
_set_action_cancelled(String(client_id), true)
_set_post_update_cancelled(true)
_refresh_generation += 1
if _refresh_thread != null:
_refresh_thread.wait_to_finish()
_refresh_thread = null
if _post_update_thread != null:
_record_unproven_post_update_result(_post_update_thread.wait_to_finish())
_post_update_thread = null
for thread in _action_threads.values():
if thread != null:
_record_unproven_action_payload((thread as Thread).wait_to_finish())
_action_threads.clear()
_action_started_msec.clear()
_action_names.clear()
_action_request_ids.clear()
_action_timeout_reported.clear()
_action_phase_mutex.lock()
_action_phases.clear()
_action_phase_mutex.unlock()
_action_cancel_mutex.lock()
_action_cancelled.clear()
_action_cancel_mutex.unlock()
_refresh_pending = false
_refresh_pending_force = false
_mcp_status_waiters.clear()
set_process(false)
_publish_snapshot()
var termination_unproven := (
_post_update_termination_unproven
or not _mutation_termination_unproven.is_empty()
or MutationLock.is_locked()
)
return {
"ok": not termination_unproven,
"deadline_exceeded": deadline_msec > 0 and Time.get_ticks_msec() > deadline_msec,
"termination_unproven": termination_unproven,
}
## Re-open the same owner after a pre-activation update failure. Successful
## activation tears down the plugin instead, so this is only a bail-out path.
func resume_after_quiesce() -> void:
if _refresh_state != RefreshState.SHUTTING_DOWN:
return
_refresh_state = RefreshState.IDLE
_set_post_update_cancelled(false)
activate()
## Start the hard post-update client barrier. The signed transaction outcome
## supplies both versions; a mismatch with the code actually loaded fails
## closed before touching client config or starting the server.
func begin_post_update_repin(
from_version: String, to_version: String, replace_owned_mismatches: bool = false
) -> Dictionary:
## `from_version` is empty when the closed-editor installer put v4 into a
## project that had no add-on: nothing was replaced, and owned client
## entries still repin to the installed version on this first start.
var replaced := from_version.strip_edges()
var installed := to_version.strip_edges()
if installed.is_empty():
return {"ok": false, "error": "Post-update target version is missing."}
if installed != ClientConfigurator.get_plugin_version():
return {"ok": false, "error": "Post-update target does not match the loaded plugin."}
if MutationLock.is_locked():
return {"ok": false, "error": MutationLock.recovery_message()}
if _post_update_termination_unproven or not _mutation_termination_unproven.is_empty():
return {"ok": false, "error": MutationLock.recovery_message()}
if (
_accepting_work
or _post_update_thread != null
or _refresh_thread != null
or not _action_threads.is_empty()
or _refresh_state == RefreshState.SHUTTING_DOWN
):
return {"ok": false, "error": "Client work is not idle for post-update migration."}
ClientConfigurator.warm_env_snapshot()
_warm_worker_bytecode()
var probes: Array[Dictionary] = []
for client_id in ClientConfigurator.client_ids():
var probe := ClientConfigurator.client_status_probe_snapshot(client_id)
## Manual-only descriptors remain visible in ordinary status UI, but M6
## cannot safely parse or rewrite them. In particular Zed owns JSONC and
## may contain comments that the strict shared JSON parser must reject.
## Keep manual remediation outside the hard startup barrier.
if not probe.is_empty() and bool(probe.get("automatic_config_edits", true)):
probes.append(probe)
var context := ClientConfigurator.capture_launch_context()
if context.is_empty():
return {"ok": false, "error": "Post-update launch context is unavailable."}
_set_post_update_cancelled(false)
_post_update_generation += 1
_post_update_thread = Thread.new()
var start_error := _post_update_thread.start(
Callable(self, "_run_post_update_repin").bind(
probes,
context,
replaced,
installed,
replace_owned_mismatches,
_post_update_generation,
)
)
if start_error != OK:
_post_update_thread = null
return {"ok": false, "error": "Could not start post-update client migration."}
set_process(true)
_publish_snapshot()
return {"ok": true}
func _run_post_update_repin(
probes: Array[Dictionary],
context: Dictionary,
from_version: String,
to_version: String,
replace_owned_mismatches: bool,
generation: int,
) -> Dictionary:
var prewarm := ClientConfigurator.prewarm_server_package_blocking(
to_version,
ClientConfigurator.PREWARM_TIMEOUT_MS,
Callable(self, "_is_post_update_cancelled"),
)
var configured_ids: Array[String] = []
var repinned_ids: Array[String] = []
var foreign_ids: Array[String] = []
var deferred: Array[Dictionary] = []
if bool(prewarm.get("termination_failed", false)):
return _post_update_result(
false,
generation,
configured_ids,
repinned_ids,
"Post-update package pre-warm could not be proven stopped; stop relevant uvx processes or reboot before retrying.",
prewarm,
true,
)
var server_url := ClientConfigurator.server_url_from(context)
var resolved_launch := ClientConfigurator.resolve_attach_launch(context)
for probe in probes:
if _is_post_update_cancelled():
return _post_update_result(false, generation, configured_ids, repinned_ids,
"Post-update client migration was cancelled.", prewarm)
var client_id := String(probe.get("id", ""))
var details := ClientConfigurator.check_status_details_for_url_with_cli_path(
client_id,
server_url,
String(probe.get("cli_path", "")),
context,
resolved_launch,
)
var status := int(details.get("status", Client.Status.ERROR))
if status == Client.Status.NOT_CONFIGURED:
continue
if status == Client.Status.ERROR:
## One client whose configuration cannot even be read must not
## hold every other client and the server itself hostage: leave
## it for the dock's Configure and say so.
deferred.append({
"id": client_id,
"reason": "its configuration could not be read: %s"
% str(details.get("error_msg", "unknown error")),
})
continue
if status == Client.Status.CONFIGURED:
configured_ids.append(client_id)
continue
if replace_owned_mismatches:
## A major migration rewrites only entries that launch Godot AI.
## An entry under our name that starts something else is the
## user's own server: leave it, name it, let Configure replace it.
if not bool(details.get("owned", false)):
foreign_ids.append(client_id)
continue
elif not ClientConfigurator.entry_drift_is_version_pin_only(
client_id, from_version, context
):
## #890's write restriction stands: an entry that is not provably
## what Configure wrote before the update is never rewritten here.
## But refusing the write is not a reason to refuse the server
## (#999): the entry is left untouched, named, and deferred to
## the dock's Configure, and startup continues.
deferred.append({
"id": client_id,
"reason": "its godot-ai entry differs from what Configure wrote before the update",
})
continue
var configured := ClientConfigurator.configure(client_id, server_url, context)
if str(configured.get("status", "error")) != "ok":
return _post_update_result(false, generation, configured_ids, repinned_ids,
"%s repin failed: %s" % [client_id, str(configured.get("message", "unknown error"))],
prewarm,
bool(configured.get("termination_failed", false)),
client_id)
var verified := ClientConfigurator.check_status_details_for_url_with_cli_path(
client_id,
server_url,
String(probe.get("cli_path", "")),
context,
resolved_launch,
)
if int(verified.get("status", Client.Status.ERROR)) != Client.Status.CONFIGURED:
return _post_update_result(false, generation, configured_ids, repinned_ids,
"%s repin could not be verified." % client_id, prewarm)
configured_ids.append(client_id)
repinned_ids.append(client_id)
configured_ids.sort()
repinned_ids.sort()
foreign_ids.sort()
deferred.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return str(a.id) < str(b.id))
return _post_update_result(
true, generation, configured_ids, repinned_ids, "", prewarm, false, "", foreign_ids, deferred
)
static func _post_update_result(
ok: bool,
generation: int,
configured_ids: Array[String],
repinned_ids: Array[String],
error: String,
prewarm: Dictionary,
termination_failed: bool = false,
unsafe_client_id: String = "",
foreign_ids: Array[String] = [],
deferred: Array[Dictionary] = [],
) -> Dictionary:
return {
"ok": ok,
"generation": generation,
"configured_ids": configured_ids.duplicate(),
"repinned_ids": repinned_ids.duplicate(),
"error": error,
"prewarm": prewarm.duplicate(true),
"termination_failed": termination_failed,
"unsafe_client_id": unsafe_client_id,
## Entries under our name that launch something else; left unchanged.
"foreign_ids": foreign_ids.duplicate(),
## `{id, reason}` for entries the migration left unchanged because
## they were unreadable or drifted beyond the version pin (#999).
## They are the dock's Configure job, not a startup barrier.
"deferred": deferred.duplicate(true),
}
func _poll_post_update_repin() -> void:
if _post_update_thread == null or _post_update_thread.is_alive():
return
var payload: Variant = _post_update_thread.wait_to_finish()
_post_update_thread = null
if _refresh_state == RefreshState.SHUTTING_DOWN:
return
var result: Dictionary = (
(payload as Dictionary).duplicate(true)
if payload is Dictionary
else {"ok": false, "error": "Post-update client migration returned no result."}
)
_record_unproven_post_update_result(result)
if int(result.get("generation", -1)) != _post_update_generation:
return
post_update_repin_completed.emit(result)
_publish_snapshot()
func request_action(client_id: String, action: String) -> bool:
var started := _start_action(client_id, action, "")
if not bool(started.get("ok", false)) and bool(started.get("termination_failed", false)):
action_completed.emit(
client_id,
action,
{
"status": "error",
"message": str(started.get("error", "Client action is unavailable.")),
"termination_failed": true,
},
{},
)
return bool(started.get("ok", false))
## MCP mutations use the same one-slot owner as Dock actions. They preserve the
## tool's previous mutation-only behavior: package prewarm remains a Dock-only
## convenience. The returned timeout is the bounded out-of-band reply budget.
func request_mcp_action(request_id: String, client_id: String, action: String) -> Dictionary:
if request_id.is_empty():
return {"ok": false, "error": "Client mutation requires a deferred request context."}
return _start_action(client_id, action, request_id)
func _start_action(client_id: String, action: String, request_id: String) -> Dictionary:
if not _accepting_work:
return {"ok": false, "error": "Client worker is unavailable."}
if action not in ["configure", "remove"]:
return {"ok": false, "error": "Unknown client action: %s" % action}
if _mutation_termination_unproven.has(client_id):
return {
"ok": false,
"error": MutationLock.recovery_message(),
"termination_failed": true,
}
if _action_threads.has(client_id):
return {"ok": false, "error": "A client action is already running for %s." % client_id}
if not ClientConfigurator.has_client(client_id):
return {"ok": false, "error": "Unknown client: %s" % client_id}
ClientConfigurator.warm_env_snapshot()
_set_action_cancelled(client_id, false)
_clear_action_phase(client_id)
var context := ClientConfigurator.capture_launch_context()
_action_names[client_id] = action
_action_request_ids[client_id] = request_id
_action_timeout_reported.erase(client_id)
_action_started_msec[client_id] = Time.get_ticks_msec()
var thread := Thread.new()
_action_threads[client_id] = thread
var error := thread.start(
Callable(self, "_run_action").bind(
client_id,
action,
ClientConfigurator.server_url_from(context),
context,
request_id.is_empty(),
)
)
if error != OK:
_action_threads.erase(client_id)
_action_started_msec.erase(client_id)
_action_names.erase(client_id)
_action_request_ids.erase(client_id)
return {"ok": false, "error": "Could not start client worker thread."}
## Same invariant `begin_post_update_repin` states: a live Thread is only
## ever joined from `_process`, so spawning one must guarantee the poll.
set_process(true)
_publish_snapshot()
return {
"ok": true,
"deferred_timeout_ms": _mcp_action_deferred_timeout_msec(action),
}
func _run_action(
client_id: String,
action: String,
server_url: String,
context: Dictionary,
prewarm_after_configure: bool,
) -> Dictionary:
var result: Dictionary
var prewarm: Dictionary = {}
if action == "remove":
result = ClientConfigurator.remove(client_id, server_url, context)
else:
result = ClientConfigurator.configure(client_id, server_url, context)
if result.get("status") == "ok" and prewarm_after_configure:
_set_action_phase(client_id, PHASE_PREWARM)
prewarm = ClientConfigurator.prewarm_attach_launch(
context,
ClientConfigurator.PREWARM_TIMEOUT_MS,
{},
Callable(self, "_is_action_cancelled").bind(client_id),
)
return {
"client_id": client_id,
"action": action,
"result": result,
"prewarm": prewarm,
}
func _poll_actions() -> void:
var phase_changed := false
for client_id in _action_threads.keys():
var thread: Thread = _action_threads[client_id]
if thread == null:
continue
if thread.is_alive():
phase_changed = phase_changed or not _read_action_phase(String(client_id)).is_empty()
continue
var payload: Variant = thread.wait_to_finish()
_action_threads[client_id] = null
var action := String(_action_names.get(client_id, "configure"))
var result: Dictionary = {"status": "error", "message": "worker returned no result"}
var prewarm: Dictionary = {}
if payload is Dictionary:
var data := payload as Dictionary
action = String(data.get("action", action))
result = (data.get("result", {}) as Dictionary).duplicate(true)
prewarm = (data.get("prewarm", {}) as Dictionary).duplicate(true)
_finalize_action(String(client_id), action, result, prewarm)
if phase_changed:
_publish_snapshot()
func _finalize_action(
client_id: String,
action: String,
result: Dictionary,
prewarm: Dictionary,
) -> void:
## Safety evidence is consumed before the sole slot is released. A completed
## timed-out worker therefore cannot reopen a race window between is_alive()
## flipping false and its termination_failed payload being persisted.
_record_unproven_action_result(client_id, action, result, prewarm)
var request_id := String(_action_request_ids.get(client_id, ""))
_action_threads.erase(client_id)
_action_started_msec.erase(client_id)
_action_names.erase(client_id)
_action_request_ids.erase(client_id)
_action_timeout_reported.erase(client_id)
_set_action_cancelled(client_id, false)
_clear_action_phase(client_id)
if _refresh_state != RefreshState.SHUTTING_DOWN:
action_completed.emit(client_id, action, result.duplicate(true), prewarm.duplicate(true))
if not request_id.is_empty():
mcp_action_completed.emit(
request_id,
_mcp_action_payload(client_id, result),
)
_publish_snapshot()
static func _mcp_action_payload(client_id: String, result: Dictionary) -> Dictionary:
if str(result.get("status", "error")) == "error":
return McpErrorCodes.make(
McpErrorCodes.INTERNAL_ERROR,
str(result.get("message", "Configuration failed for '%s'" % client_id)),
)
return {"data": result.duplicate(true)}
static func _mcp_action_deferred_timeout_msec(_action: String) -> int:
return ACTION_TIMEOUT_MSEC + MCP_ACTION_DEFERRED_GRACE_MSEC
func _record_unproven_action_payload(payload: Variant) -> void:
if not payload is Dictionary:
return
var data := payload as Dictionary
var result: Dictionary = {}
var prewarm: Dictionary = {}
if data.get("result", null) is Dictionary:
result = data.get("result", {}) as Dictionary
if data.get("prewarm", null) is Dictionary:
prewarm = data.get("prewarm", {}) as Dictionary
_record_unproven_action_result(
str(data.get("client_id", "")),
str(data.get("action", "configure")),
result,
prewarm,
)
func _record_unproven_action_result(
client_id: String,
action: String,
result: Dictionary,
prewarm: Dictionary,
) -> void:
if client_id.is_empty():
return
if (
bool(result.get("termination_failed", false))
or bool(prewarm.get("termination_failed", false))
):
_mark_mutation_termination_unproven(client_id, action)
func _record_unproven_post_update_result(payload: Variant) -> void:
if not payload is Dictionary:
return
var result := payload as Dictionary
if not bool(result.get("termination_failed", false)):
return
var client_id := str(result.get("unsafe_client_id", ""))
if client_id.is_empty():
_post_update_termination_unproven = true
Engine.set_meta(POST_UPDATE_UNPROVEN_META, true)
else:
_mark_mutation_termination_unproven(client_id, "post_update_repin")
func _mark_mutation_termination_unproven(client_id: String, action: String) -> void:
_mutation_termination_unproven[client_id] = action
Engine.set_meta(
MUTATION_UNPROVEN_META,
_mutation_termination_unproven.duplicate(true),
)
func _check_action_timeouts(now_msec: int = -1) -> void:
var now := Time.get_ticks_msec() if now_msec < 0 else now_msec
for client_id in _action_threads.keys():
if bool(_action_timeout_reported.get(client_id, false)):
continue
var started := int(_action_started_msec.get(client_id, 0))
if started > 0 and now - started >= _action_budget(String(client_id)):
_report_action_timeout(String(client_id), now - started)
func _action_budget(client_id: String) -> int:
if _read_action_phase(client_id) == PHASE_PREWARM:
return ACTION_TIMEOUT_MSEC + ClientConfigurator.PREWARM_TIMEOUT_MS
return ACTION_TIMEOUT_MSEC
func _report_action_timeout(client_id: String, elapsed_msec: int) -> void:
if not _action_threads.has(client_id):
return
var thread: Thread = _action_threads[client_id]
var alive := thread != null and thread.is_alive()
if alive:
_set_action_cancelled(client_id, true)
_action_timeout_reported[client_id] = true
var action := String(_action_names.get(client_id, "configure"))
var label := "Remove" if action == "remove" else "Configure"
var detail := (
"%s is taking longer than expected and is being stopped; this row remains locked until its worker is joined." % label
if alive
else "%s did not report completion in time; waiting to inspect its result." % label
)
print(
"MCP | client action timed out: client=%s action=%s elapsed_ms=%d worker_alive=%s"
% [client_id, action, elapsed_msec, str(alive)]
)
action_timed_out.emit(client_id, action, detail)
_publish_snapshot()
func request_status_refresh(ids: Array[String], force := false) -> bool:
_client_ids = ids.duplicate()
if not _accepting_work or _client_health_blocked:
return false
if RefreshState.has_worker_alive(_refresh_state):
## One refresh worker total. A timed-out probe remains visible and owns
## the slot until its bounded subprocess calls return; repeated force
## requests collapse into one retry instead of growing live Threads.
_refresh_pending = true
_refresh_pending_force = _refresh_pending_force or force
_publish_snapshot()
return false
if RefreshState.is_blocked_for_spawn(_refresh_state):
return false
if not force and _refresh_completed_msec > 0:
if Time.get_ticks_msec() - _refresh_completed_msec < STATUS_COOLDOWN_MSEC:
return false
if ids.is_empty():
return false
if _filesystem_busy():
_refresh_state = RefreshState.DEFERRED_FOR_FILESYSTEM
_refresh_pending_force = _refresh_pending_force or force
_publish_snapshot()
return false
if force:
ClientConfigurator.invalidate_cli_cache()
_warm_worker_bytecode()
var probes: Array[Dictionary] = []
for client_id in ids:
var probe := ClientConfigurator.client_status_probe_snapshot(client_id)
if not probe.is_empty():
probes.append(probe)
if probes.is_empty():
_finish_refresh({})
return false
var context := ClientConfigurator.capture_launch_context()
_refresh_state = RefreshState.RUNNING
_refresh_pending = false
_refresh_pending_force = false
_refresh_started_msec = Time.get_ticks_msec()
_refresh_generation += 1
_refresh_thread = Thread.new()
var error := _refresh_thread.start(
Callable(self, "_run_status_refresh").bind(
probes,
ClientConfigurator.server_url_from(context),
context,
_refresh_generation,
)
)
if error != OK:
_refresh_thread = null
_refresh_state = RefreshState.IDLE
_publish_snapshot()
return false
## See `_start_action`: the sweep leaves RUNNING only through `_poll_refresh`.
set_process(true)
_publish_snapshot()
return true
## Queue one deferred MCP response onto the same aggregate refresh worker the
## Dock uses. The dispatcher bounds its pending table; this local cap also
## protects direct/future callers. No connection or handler is retained.
func request_mcp_status(request_id: String) -> bool:
if (
request_id.is_empty()
or not _accepting_work
or _client_health_blocked
or _refresh_state == RefreshState.SHUTTING_DOWN
or _mcp_status_waiters.has(request_id)
or _mcp_status_waiters.size() >= MAX_MCP_STATUS_WAITERS
):
return false
var target_generation := _refresh_generation + 1
_mcp_status_waiters[request_id] = target_generation
request_status_refresh(ClientConfigurator.client_ids(), true)
if (
_refresh_generation >= target_generation
or _refresh_pending
or _refresh_state == RefreshState.DEFERRED_FOR_FILESYSTEM
):
return true
_mcp_status_waiters.erase(request_id)
return false
func _run_status_refresh(
probes: Array[Dictionary],
server_url: String,
context: Dictionary,
generation: int,
) -> Dictionary:
var results: Dictionary = {}
var resolved_launch := ClientConfigurator.resolve_attach_launch(context)
for probe in probes:
var client_id := String(probe.get("id", ""))
if client_id.is_empty():
continue
var details := ClientConfigurator.check_status_details_for_url_with_cli_path(
client_id,
server_url,
String(probe.get("cli_path", "")),
context,
resolved_launch,
)
results[client_id] = {
"status": details.get("status", Client.Status.NOT_CONFIGURED),
"installed": bool(probe.get("installed", false)),
"error_msg": details.get("error_msg", ""),
}
return {"results": results, "generation": generation}
func _poll_refresh() -> void:
if _refresh_thread == null or _refresh_thread.is_alive():
return
var payload: Variant = _refresh_thread.wait_to_finish()
_refresh_thread = null
if not (payload is Dictionary):
_finish_refresh({}, _refresh_generation, "Client status worker returned an invalid response.")
return
var data := payload as Dictionary
if int(data.get("generation", -1)) != _refresh_generation:
return
var results := (data.get("results", {}) as Dictionary).duplicate(true)
var timed_out := _refresh_state == RefreshState.RUNNING_TIMED_OUT
_finish_refresh(
{} if timed_out else results,
int(data.get("generation", _refresh_generation)),
"Client status probe timed out." if timed_out else "",
)
func _finish_refresh(
results: Dictionary, completed_generation: int = -1, mcp_error: String = ""
) -> void:
_refresh_completed_msec = Time.get_ticks_msec()
if _refresh_state != RefreshState.SHUTTING_DOWN:
_refresh_state = RefreshState.IDLE
if not _client_health_blocked and _refresh_state != RefreshState.SHUTTING_DOWN:
status_refresh_completed.emit(results.duplicate(true))
_complete_mcp_status_waiters(results, completed_generation, mcp_error)
_publish_snapshot()
if _refresh_pending:
var force := _refresh_pending_force
_refresh_pending = false
_refresh_pending_force = false
request_status_refresh(_client_ids, force)
func _complete_mcp_status_waiters(
results: Dictionary, completed_generation: int, error: String
) -> void:
if completed_generation < 0 or _mcp_status_waiters.is_empty():
return
var completed: Array[String] = []
for request_id in _mcp_status_waiters:
if int(_mcp_status_waiters[request_id]) <= completed_generation:
completed.append(String(request_id))
for request_id in completed:
_mcp_status_waiters.erase(request_id)
if completed.is_empty():
return
var payload := (
McpErrorCodes.make(McpErrorCodes.INTERNAL_ERROR, error)
if not error.is_empty()
else ClientConfigurator.client_status_response(results)
)
mcp_status_completed.emit(completed, payload.duplicate(true))
func _refresh_timed_out() -> bool:
return (
RefreshState.has_worker_alive(_refresh_state)
and _refresh_started_msec > 0
and Time.get_ticks_msec() - _refresh_started_msec >= STATUS_TIMEOUT_MSEC
)
func _check_refresh_timeout() -> void:
if _refresh_state == RefreshState.RUNNING and _refresh_timed_out():
_refresh_state = RefreshState.RUNNING_TIMED_OUT
_publish_snapshot()
func _retry_deferred_refresh() -> void:
if _refresh_state != RefreshState.DEFERRED_FOR_FILESYSTEM:
return
if not _accepting_work or _client_health_blocked or _filesystem_busy():
return
var force := _refresh_pending_force
_refresh_state = RefreshState.IDLE
_refresh_pending_force = false
request_status_refresh(_client_ids, force)
func _filesystem_busy() -> bool:
var filesystem := EditorInterface.get_resource_filesystem()
return filesystem != null and filesystem.is_scanning()
func _warm_worker_bytecode() -> void:
var ids := ClientConfigurator.client_ids()
if ids.is_empty():
return
var client := ClientRegistry.get_by_id(String(ids[0]))
if client != null:
JsonStrategy.verify_entry(client, {}, "")
DshStrategy.command_launch_error(client, {})
TomlStrategy.format_body(PackedStringArray(), "")
CliStrategy.format_args(PackedStringArray(), "", "")
ClientConfigurator.warm_env_snapshot()
func _set_action_cancelled(client_id: String, cancelled: bool) -> void:
_action_cancel_mutex.lock()
if cancelled:
_action_cancelled[client_id] = true
else:
_action_cancelled.erase(client_id)
_action_cancel_mutex.unlock()
func _is_action_cancelled(client_id: String) -> bool:
_action_cancel_mutex.lock()
var cancelled := bool(_action_cancelled.get(client_id, false))
_action_cancel_mutex.unlock()
return cancelled
func _set_post_update_cancelled(cancelled: bool) -> void:
_post_update_cancel_mutex.lock()
_post_update_cancelled = cancelled
_post_update_cancel_mutex.unlock()
func _is_post_update_cancelled() -> bool:
_post_update_cancel_mutex.lock()
var cancelled := _post_update_cancelled
_post_update_cancel_mutex.unlock()
return cancelled
func _set_action_phase(client_id: String, phase: String) -> void:
_action_phase_mutex.lock()
_action_phases[client_id] = phase
_action_phase_mutex.unlock()
func _read_action_phase(client_id: String) -> String:
_action_phase_mutex.lock()
var phase := String(_action_phases.get(client_id, ""))
_action_phase_mutex.unlock()
return phase
func _read_action_phases() -> Dictionary:
_action_phase_mutex.lock()
var phases := _action_phases.duplicate(true)
_action_phase_mutex.unlock()
return phases
func _clear_action_phase(client_id: String) -> void:
_action_phase_mutex.lock()
_action_phases.erase(client_id)
_action_phase_mutex.unlock()
@@ -0,0 +1 @@
uid://jo18d41wbdpx
@@ -0,0 +1,131 @@
@tool
class_name McpClientMutationLock
extends RefCounted
## Durable, atomic authority for all automatic client mutations.
##
## A POSIX exact-PID kill cannot prove that a timed-out CLI left no descendant
## behind. The lock directory therefore survives plugin reload, editor restart,
## and crashes. Only a sequence whose every direct child terminated without an
## ambiguous kill removes it. An existing or malformed lock always fails closed.
const ROOT_DIR_NAME := "godot-ai"
const LOCK_DIR_NAME := "client_mutation.lock"
const RECORD_NAME := "owner.json"
const SCHEMA := 1
const _POSIX_PERMISSION_MASK := 0x1ff ## 0777
const _OWNER_DIRECTORY_MODE := (
FileAccess.UNIX_READ_OWNER
| FileAccess.UNIX_WRITE_OWNER
| FileAccess.UNIX_EXECUTE_OWNER
)
static func lock_path() -> String:
return _root_path().path_join(LOCK_DIR_NAME)
static func recovery_message() -> String:
return (
"Automatic MCP client mutations are safety-locked. Stop relevant client processes "
+ "or reboot, then explicitly remove '%s' before retrying; restarting Godot alone "
+ "does not prove an old descendant stopped."
) % lock_path()
static func is_locked() -> bool:
var path := lock_path()
return DirAccess.dir_exists_absolute(path) or FileAccess.file_exists(path)
## Atomically claim the one global mutation slot. Creation wins ownership;
## failure for any reason is indistinguishable from an existing unsafe owner.
static func acquire(client_id: String, operation: String) -> Dictionary:
var path := lock_path()
if not _prepare_private_root():
return {"ok": false, "error": recovery_message(), "path": path}
if DirAccess.make_dir_absolute(path) != OK:
return {"ok": false, "error": recovery_message(), "path": path}
if not _has_private_mode(path):
## The directory itself remains as the durable deny marker. Never
## write ownership data into a location whose privacy is unproven.
return {"ok": false, "error": recovery_message(), "path": path}
var token := Crypto.new().generate_random_bytes(16).hex_encode()
var record := {
"schema": SCHEMA,
"token": token,
"pid": OS.get_process_id(),
"client_id": client_id,
"operation": operation,
"started_unix": int(Time.get_unix_time_from_system()),
}
var record_path := path.path_join(RECORD_NAME)
var file := FileAccess.open(record_path, FileAccess.WRITE)
if file == null:
return {"ok": false, "error": recovery_message(), "path": path}
file.store_string(JSON.stringify(record))
file.flush()
var write_error := file.get_error()
file.close()
if write_error != OK:
return {"ok": false, "error": recovery_message(), "path": path}
return {"ok": true, "path": path, "record_path": record_path, "token": token}
## Release only the exact claim this process wrote. Every failure leaves either
## the record or its parent directory in place, which keeps later mutations
## denied rather than guessing that cleanup succeeded.
static func release(claim: Dictionary) -> bool:
var path := str(claim.get("path", ""))
var record_path := str(claim.get("record_path", ""))
var token := str(claim.get("token", ""))
if (
path != lock_path()
or record_path != path.path_join(RECORD_NAME)
or token.is_empty()
):
return false
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(record_path))
if (
not parsed is Dictionary
or int(parsed.get("schema", 0)) != SCHEMA
or str(parsed.get("token", "")) != token
or int(parsed.get("pid", 0)) != OS.get_process_id()
):
return false
if DirAccess.remove_absolute(record_path) != OK:
return false
return DirAccess.remove_absolute(path) == OK
static func _root_path() -> String:
## Unlike user://, the OS config directory is shared by every Godot
## project owned by this account. User-scope client config is shared too,
## so a project-specific lock would leave the real cross-editor race open.
return OS.get_config_dir().path_join(ROOT_DIR_NAME)
static func _prepare_private_root() -> bool:
var root := _root_path()
if FileAccess.file_exists(root) or _is_link(root):
return false
if not DirAccess.dir_exists_absolute(root):
var created := DirAccess.make_dir_absolute(root)
## Another editor may have won the benign root-creation race. Accept
## that only after independently proving the resulting directory.
if created != OK and not DirAccess.dir_exists_absolute(root):
return false
return not _is_link(root) and _has_private_mode(root)
static func _has_private_mode(path: String) -> bool:
if OS.get_name() == "Windows":
return true
var permission_error := FileAccess.set_unix_permissions(path, _OWNER_DIRECTORY_MODE)
var actual_mode := FileAccess.get_unix_permissions(path) & _POSIX_PERMISSION_MASK
return permission_error == OK and actual_mode == _OWNER_DIRECTORY_MODE
static func _is_link(path: String) -> bool:
var parent := DirAccess.open(path.get_base_dir())
return parent == null or parent.is_link(path)
@@ -0,0 +1 @@
uid://climutlockv4x1
@@ -0,0 +1,66 @@
@tool
class_name McpDiagnosticsCapture
extends RefCounted
## Small helper for scoped validation-log capture windows. Callers snapshot a
## private log cursor, perform a deliberate validation action, then only report
## new diagnostics whose original source location is the target file.
static func capture_this_file(log_buffer: McpEditorLogBuffer, target_path: String, action: Callable) -> Dictionary:
var cursor := 0
if log_buffer != null:
cursor = log_buffer.appended_total()
var action_result = action.call()
var diagnostics: Array[Dictionary] = []
var truncated := false
if log_buffer != null:
var captured: Dictionary = log_buffer.get_since(cursor)
truncated = captured.get("truncated", false)
diagnostics = _diagnostics_for_target(captured.get("entries", []), target_path)
return {
"action": action_result if action_result is Dictionary else {},
"diagnostics": diagnostics,
"diagnostics_detail": "log_capture" if not diagnostics.is_empty() else "none",
"diagnostics_scope": "this_file",
"diagnostics_status": "partial" if truncated else "checked",
}
static func _diagnostics_for_target(entries: Array, target_path: String) -> Array[Dictionary]:
var out: Array[Dictionary] = []
for raw_entry in entries:
if not raw_entry is Dictionary:
continue
var entry: Dictionary = raw_entry
if not _entry_matches_target(entry, target_path):
continue
out.append(_normalize_entry(entry, target_path))
return out
static func _entry_matches_target(entry: Dictionary, target_path: String) -> bool:
var source := _source_location(entry)
return str(source.get("path", "")) == target_path
static func _normalize_entry(entry: Dictionary, target_path: String) -> Dictionary:
var normalized := entry.duplicate(true)
var source := _source_location(entry)
normalized["path"] = str(source.get("path", target_path))
normalized["line"] = int(source.get("line", normalized.get("line", 0)))
normalized["function"] = str(source.get("function", normalized.get("function", "")))
if normalized.has("details") and normalized.details is Dictionary:
normalized["details"] = normalized.details.duplicate(true)
return normalized
static func _source_location(entry: Dictionary) -> Dictionary:
if entry.get("details") is Dictionary:
var details: Dictionary = entry.details
if details.get("source") is Dictionary:
return details.source
return {}
@@ -0,0 +1 @@
uid://b3npxxpuobbc2
+127
View File
@@ -0,0 +1,127 @@
@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()
var _error_appended_total := 0
var _warn_appended_total := 0
func _init() -> void:
super._init(MAX_LINES)
func append(level: String, text: String, path: String = "", line: int = 0, function: String = "", details: Dictionary = {}) -> void:
var coerced_level := _coerce_level(level)
var entry := {
"source": "editor",
"level": coerced_level,
"text": text,
"path": path,
"line": line,
"function": function,
}
if not details.is_empty():
entry["details"] = details.duplicate(true)
_mutex.lock()
_append_entry(entry)
if coerced_level == "error":
_error_appended_total += 1
elif coerced_level == "warn":
_warn_appended_total += 1
_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 error_appended_total() -> int:
_mutex.lock()
var n := _error_appended_total
_mutex.unlock()
return n
func warn_appended_total() -> int:
_mutex.lock()
var n := _warn_appended_total
_mutex.unlock()
return n
func clear() -> int:
_mutex.lock()
var n := _total_count_unlocked()
_clear_storage()
_error_appended_total = 0
_warn_appended_total = 0
_mutex.unlock()
return n
@@ -0,0 +1 @@
uid://b6ynms0856hhq
+164
View File
@@ -0,0 +1,164 @@
@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"
## Python-originated transport/attach bridge codes. GDScript has no emit path,
## but the public registry intentionally mirrors protocol/errors.py.
const TRANSPORT_OUTCOME_UNKNOWN := "TRANSPORT_OUTCOME_UNKNOWN"
const TRANSPORT_OVERLOADED := "TRANSPORT_OVERLOADED"
const NEW_CLIENT_SESSION_REQUIRED := "NEW_CLIENT_SESSION_REQUIRED"
const ATTACH_LOCK_TIMEOUT := "ATTACH_LOCK_TIMEOUT"
const ATTACH_LOCK_ERROR := "ATTACH_LOCK_ERROR"
const ATTACH_RUNTIME_DIR_ERROR := "ATTACH_RUNTIME_DIR_ERROR"
const PORT_OCCUPIED := "PORT_OCCUPIED"
const BACKEND_START_FAILED := "BACKEND_START_FAILED"
const BACKEND_START_TIMEOUT := "BACKEND_START_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/#859: 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 cannot service evals: the helper did not register within
## EVAL_READY_WAIT_SEC, its main-loop beacon is stale, or its debugger session
## closed mid-eval. Carved out of INTERNAL_ERROR so these fast,
## caller-actionable failures do not burn the opaque 10s eval backstop.
const EVAL_GAME_NOT_READY := "EVAL_GAME_NOT_READY"
## #518: the eval genuinely never finished inside the timeout ladder — the
## game-side 8s deadline aborted a hung await, or the editor-side 10s backstop
## fired because the game never replied at all (CPU-bound loop, frozen /
## backgrounded idle loop). Carved out of INTERNAL_ERROR — the last big
## still-unlabeled bucket from #487/#488 — so "your eval code never finished"
## stops reading as an internal fault in telemetry and agent-facing errors.
const EVAL_HUNG := "EVAL_HUNG"
## #518: the eval completed but its serialized result is too large for the
## debugger + WebSocket pipeline. Without this the reply is dropped silently
## (the debugger TCP peer discards messages over ~8 MiB) and the request rides
## to the 10s backstop as a phantom "hang". Failing fast game-side with the
## real byte count makes the failure actionable (return a smaller slice).
const EVAL_RESULT_TOO_LARGE := "EVAL_RESULT_TOO_LARGE"
## #777: a game-side request (currently editor_screenshot source="game")
## reached a live, registered game helper but no reply came back before the
## editor-side timer fired. Every editor gate already passed
## (is_playing_scene, helper hello) so this is a TOP-LEVEL code, not an
## EDITOR_NOT_READY sub-code: the game process itself failed to respond —
## backgrounded with a frozen main loop and nothing rendered to fall back
## on, main thread blocked, or the helper died mid-run. Carved out of
## INTERNAL_ERROR (the largest opaque timeout bucket fleet-wide) so the
## residual timeout is attributable and actionable.
const GAME_HELPER_TIMEOUT := "GAME_HELPER_TIMEOUT"
## 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"
const CUSTOM_TOOL_NOT_UNDOABLE := "CUSTOM_TOOL_NOT_UNDOABLE"
const CUSTOM_TOOL_DISABLED := "CUSTOM_TOOL_DISABLED"
## #651 stage 1: EDITOR_NOT_READY sub-codes. These travel in
## `error.data.sub_code`, NEVER as the top-level `error.code` — existing
## callers and dashboards key on EDITOR_NOT_READY, so the top-level code is
## frozen. Each sub-code names the concrete editor state at rejection time,
## limited to states EditorInterface/EditorFileSystem can report
## deterministically. States we cannot observe (script compilation,
## resource reload, modal dialogs) intentionally get NO sub-code: a bare
## EDITOR_NOT_READY stays the honest fallback rather than a guessed label.
## Keep in sync with protocol/errors.py::EditorNotReadySubCode — enforced
## by tests/unit/test_editor_not_ready_hint_contract.py.
const SUB_EDITOR_IMPORTING := "EDITOR_IMPORTING"
const SUB_EDITOR_PLAYING := "EDITOR_PLAYING"
const SUB_EDITOR_NO_SCENE := "EDITOR_NO_SCENE"
const SUB_EDITOR_GAME_NOT_RUNNING := "EDITOR_GAME_NOT_RUNNING"
const SUB_EDITOR_VIEWPORT_UNAVAILABLE := "EDITOR_VIEWPORT_UNAVAILABLE"
const SUB_EDITOR_VIEWPORT_NOT_3D := "EDITOR_VIEWPORT_NOT_3D"
const SUB_EDITOR_VIEWPORT_EMPTY := "EDITOR_VIEWPORT_EMPTY"
const SUB_EDITOR_UNAVAILABLE := "EDITOR_UNAVAILABLE"
## Emitted only by the exclusive-run transport servicing path: a command
## arrived while a synchronous test run holds the main thread, and was
## rejected (not buffered) so it can't replay stale after its server-side
## future expires. See connection.gd::service_transport_during_exclusive_run.
const SUB_EDITOR_TEST_RUNNING := "EDITOR_TEST_RUNNING"
## Terminal code for a test run that hit its between-test abort ceiling
## before finishing. error.data carries the partial summary; full partial
## results stay retrievable via get_test_results.
const TEST_RUN_TIMEOUT := "TEST_RUN_TIMEOUT"
## Build a standard error response dictionary.
static func make(code: String, message: String) -> Dictionary:
return {"status": "error", "error": {"code": code, "message": message}}
## Build an EDITOR_NOT_READY error carrying the #651 stage-1 attribution
## payload: `data.sub_code` + `retryable` + `hint`. Mirrors the shape
## scene_path.gd::require_edited_scene established (editor_state/retryable/
## hint). `hint` may be empty when `message` already IS the recovery hint —
## the server's GodotCommandError string-appends every data key, so
## duplicating the message into data would double the agent-visible text.
static func make_not_ready(
sub_code: String, message: String, retryable: bool, hint: String = ""
) -> Dictionary:
var err := make(EDITOR_NOT_READY, message)
var data := {"sub_code": sub_code, "retryable": retryable}
if not hint.is_empty():
data["hint"] = hint
err["error"]["data"] = data
return err
## 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])
+1
View File
@@ -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
+105
View File
@@ -0,0 +1,105 @@
@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 at play-start, giving agents a stable cursor for "lines from
## this run" even when the game never reaches the mcp:hello boot beacon.
##
## 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 := ""
var _run_seq := 0
var _error_warn_total := 0
var _error_total := 0
func _init() -> void:
super._init(MAX_LINES)
func append(level: String, text: String, details: Dictionary = {}) -> void:
var coerced_level := _coerce_level(level)
var entry := {
"source": "game",
"level": coerced_level,
"text": text,
"run_id": _run_id,
}
if not details.is_empty():
entry["details"] = details.duplicate(true)
_append_entry(entry)
if coerced_level in ["warn", "error"]:
_error_warn_total += 1
if coerced_level == "error":
_error_total += 1
## Rotate the run identifier without dropping buffered entries. Called at
## play-start so even no-hello parse failures get a fresh current-run identity.
## Historical lines stay tagged with their original run_id and can still be
## queried explicitly.
func clear_for_new_run() -> String:
_run_id = _generate_run_id()
_error_warn_total = 0
_error_total = 0
return _run_id
func run_id() -> String:
return _run_id
func error_warn_total() -> int:
return _error_warn_total
func error_total() -> int:
return _error_total
## Warn-level lines for the current run: the combined error+warn tally minus
## the error-only tally. Feeds the `game_warn` watermark component so a run
## that only emitted push_warning is no longer reported as clean.
func warn_total() -> int:
return _error_warn_total - _error_total
func get_run_range(run_id: String, offset: int, count: int) -> Array[Dictionary]:
return get_run_page(run_id, offset, count).entries
func get_run_page(run_id: String, offset: int, count: int) -> Dictionary:
var entries := _entries_for_run(run_id)
var start := mini(maxi(0, offset), entries.size())
var stop := mini(entries.size(), start + maxi(0, count))
var out: Array[Dictionary] = []
for i in range(start, stop):
out.append(entries[i])
return {
"entries": out,
"total_count": entries.size(),
}
func _entries_for_run(run_id: String) -> Array[Dictionary]:
var out: Array[Dictionary] = []
for entry in get_range(0, total_count()):
if str(entry.get("run_id", "")) == run_id:
out.append(entry)
return out
func _generate_run_id() -> String:
## Opaque to agents — they only check equality. Time-based is plenty
## unique within a single editor session; the local sequence protects
## fast back-to-back test runs within the same millisecond.
_run_seq += 1
return "r%d-%d" % [Time.get_ticks_msec(), _run_seq]
@@ -0,0 +1 @@
uid://biojw0xl64haw
+108
View File
@@ -0,0 +1,108 @@
@tool
class_name McpJsonValues
extends RefCounted
## Canonical JSON→Variant parsers for the wire shapes agents send.
##
## One parser family instead of five drifted per-handler copies (#714) —
## the canonical color set is the maintainer decision recorded on that
## issue. parse_color accepts: Color passthrough; "#rrggbb"/"#rrggbbaa"
## hex or named-color strings (two-sentinel Color.from_string
## validation); {r,g,b[,a]} dicts; [r,g,b[,a]] arrays. parse_vector2/3
## accept the Vector passthrough, {x,y[,z]} dicts, and [x,y[,z]] arrays.
##
## Strict WITHIN each shape (the #123/#126 contract): wrong dict keys,
## wrong array lengths, or non-numeric components return null instead of
## guessing zeros — callers turn null into their own typed error.
const COLOR_KEYS: Array[String] = ["r", "g", "b"]
const VECTOR2_KEYS: Array[String] = ["x", "y"]
const VECTOR3_KEYS: Array[String] = ["x", "y", "z"]
static func parse_color(value: Variant) -> Variant:
if value is Color:
return value
if value is String:
## Color.from_string returns the fallback on parse failure — call
## twice with distinct sentinels; agreement means a real parse.
var a := Color.from_string(value, Color(0, 0, 0, 0))
var b := Color.from_string(value, Color(1, 1, 1, 1))
if a != b:
return null
return a
if value is Dictionary:
var d: Dictionary = value
if not d.has_all(COLOR_KEYS):
return null
var alpha: Variant = d.get("a", 1.0)
if not (_is_number(d.r) and _is_number(d.g) and _is_number(d.b) and _is_number(alpha)):
return null
return Color(float(d.r), float(d.g), float(d.b), float(alpha))
if value is Array:
var arr: Array = value
if arr.size() != 3 and arr.size() != 4:
return null
for item in arr:
if not _is_number(item):
return null
var a4 := float(arr[3]) if arr.size() == 4 else 1.0
return Color(float(arr[0]), float(arr[1]), float(arr[2]), a4)
return null
static func parse_vector2(value: Variant) -> Variant:
if value is Vector2:
return value
if value is Dictionary:
var d: Dictionary = value
if not d.has_all(VECTOR2_KEYS) or not (_is_number(d.x) and _is_number(d.y)):
return null
return Vector2(float(d.x), float(d.y))
if value is Array:
var arr: Array = value
if arr.size() != 2 or not (_is_number(arr[0]) and _is_number(arr[1])):
return null
return Vector2(float(arr[0]), float(arr[1]))
return null
static func parse_vector3(value: Variant) -> Variant:
if value is Vector3:
return value
if value is Dictionary:
var d: Dictionary = value
if not d.has_all(VECTOR3_KEYS):
return null
if not (_is_number(d.x) and _is_number(d.y) and _is_number(d.z)):
return null
return Vector3(float(d.x), float(d.y), float(d.z))
if value is Array:
var arr: Array = value
if arr.size() != 3:
return null
for item in arr:
if not _is_number(item):
return null
return Vector3(float(arr[0]), float(arr[1]), float(arr[2]))
return null
## Parse a scalar float from the wire shapes agents send: float passthrough,
## int (JSON integers land as numbers, not strings), and strictly-numeric
## strings — some MCP clients stringify float arguments ("4.0"; #964).
## Anything else returns null so callers turn it into their own typed
## error; null input stays null (callers gate on the input's type first
## when null is a meaningful "clear" value).
static func parse_float(value: Variant) -> Variant:
if _is_number(value):
return float(value)
if value is String:
var text := value as String
if text.is_valid_float():
return text.to_float()
return null
static func _is_number(v: Variant) -> bool:
return v is int or v is float
+1
View File
@@ -0,0 +1 @@
uid://4wbf83hwckms
+113
View File
@@ -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
+67
View File
@@ -0,0 +1,67 @@
@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
## `echo=false` records the line into the ring (so the dock's log panel shows
## it) without printing to the Godot console. Used for high-frequency
## machine-driven lines like readiness flips, which spammed the console of
## every install (#626) — filesystem scans toggle readiness on each import.
func log(msg: String, echo: bool = true) -> void:
var line := "MCP | %s" % msg
if enabled and console_echo and 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
+1
View File
@@ -0,0 +1 @@
uid://ddkslse7511e6
@@ -0,0 +1,8 @@
@tool
class_name McpAdoptionLabel
extends RefCounted
## Retired v4 lifecycle vocabulary kept only because this class_name and its
## constants were published. Runtime ownership now lives in the tagged episode.
const MANAGED := "managed"
const EXTERNAL := "external"
@@ -0,0 +1 @@
uid://klhsu1cuhcue
@@ -0,0 +1,101 @@
@tool
class_name McpClientRefreshState
extends RefCounted
## State machine for the plugin-lifetime client's 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 job owner parks the request and retries on the next `_process` after
## the scan settles. A separate force bit preserves an explicit retry request.
const DEFERRED_FOR_FILESYSTEM := 1
## Worker thread is alive and probing client status off-main. The
## Dock snapshots paint "(checking...)" in the clients summary and the owner 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)"; further requests coalesce into
## one retry after this sole worker exits.
const RUNNING_TIMED_OUT := 3
## Plugin teardown / update activation is draining workers. New refresh
## requests are rejected outright until an explicitly failed handoff resumes
## the same owner.
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 the sole worker running.
static func has_worker_alive(state: int) -> bool:
return state == RUNNING or state == RUNNING_TIMED_OUT
## True while the status worker is still within its healthy budget. Once a
## refresh has timed out, the dock keeps the warning badge but must let users
## retry Configure / Configure all instead of stranding the controls behind an
## orphaned, uninterruptible worker.
static func should_disable_client_actions(state: int) -> bool:
return state == RUNNING
## True when the dock should reject new refresh spawns. Used by the
## job owner's deferred entrypoint and status-refresh scheduler.
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.
return to == IDLE or to == RUNNING_TIMED_OUT
RUNNING_TIMED_OUT:
## The sole late-arriving worker drops back to IDLE. A coalesced
## retry then starts through the ordinary IDLE -> RUNNING edge.
return to == IDLE
return false
@@ -0,0 +1 @@
uid://dv4tukg6eioww
+57
View File
@@ -0,0 +1,57 @@
@tool
class_name McpServerState
extends RefCounted
## Stable presentation values consumed by the Dock and editor-status API.
## McpServerLifecycleManager's tagged episode is the only transition owner.
const UNINITIALIZED := 0
const SPAWNING := 1
# Slot 2 stays unused so existing status consumers keep stable values.
const READY := 3
const INCOMPATIBLE := 4
const CRASHED := 5
const NO_COMMAND := 6
const PORT_EXCLUDED := 7
const FOREIGN_PORT := 8
const STOPPING := 10
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",
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 when the dock should skip interpreting client health (incompatible
## tool surface). This must NOT block Configure writes — those take an
## explicit url and the live plugin version (#916). 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
@@ -0,0 +1 @@
uid://d3ial4erjonlq
+15
View File
@@ -0,0 +1,15 @@
@tool
class_name McpStartupPath
extends RefCounted
## Retired v4 lifecycle vocabulary kept only because this class_name and its
## constants were published. Runtime startup state lives in one tagged episode.
const UNSET := ""
const GUARDED := "guarded"
const ADOPTED := "adopted"
const SPAWNED := "spawned"
const CRASHED := "crashed"
const RESERVED := "reserved"
const NO_COMMAND := "no_command"
const INCOMPATIBLE := "incompatible"
const FREE := "free"
@@ -0,0 +1 @@
uid://cikdvq2x4vs4x
+216
View File
@@ -0,0 +1,216 @@
@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
## True when `path` carries a codepoint that must never reach a filesystem API.
##
## Issue #889: the previous guard built its sentinel with `String.chr(0)`, and
## Godot emits `Unicode parsing error … Unexpected NUL character` while
## *constructing* that string. Every validated call — including reads that then
## failed with "File not found" — printed an alarming engine error that had
## nothing to do with the caller's path. Reading codepoints instead never
## materialises a NUL String, so the noise is gone.
##
## Both codepoints are rejected, for different reasons:
## * `0x0000` — a real embedded NUL can truncate a C string, so the path that
## is validated is not the path that gets opened. Current Godot cannot
## retain one in a String, making this a no-op today; it stays as
## defense-in-depth for any build or input path that can.
## * `0xFFFD` — what current Godot actually substitutes for a NUL or any other
## undecodable input. Once the engine has replaced the byte there is no way
## left to tell an attempted NUL from malformed UTF-8 from a literal
## replacement character, so the only safe reading at a security boundary is
## to refuse all three. A legitimate filename containing U+FFFD is rejected
## too; that false positive is deliberate and vanishingly rare.
static func _contains_invalid_path_codepoint(path: String) -> bool:
for i in path.length():
var codepoint := path.unicode_at(i)
if codepoint == 0x0000 or codepoint == 0xFFFD:
return true
return false
## 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"
if _contains_invalid_path_codepoint(path):
return "Path must not contain null bytes or invalid Unicode"
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"
if _contains_invalid_path_codepoint(path):
return "Path must not contain null bytes or invalid Unicode"
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:
# Normalize once, then reuse for filename and segment checks. The writer
# (`FileAccess::fix_path` / `FileAccessWindows`) folds `\`, `//`, and `.`
# the same way, so `res://project.godot/.` and `res://override.cfg/.` must
# not slip past `get_file()` as a trailing `.` while still opening the
# protected file.
#
# - `res://.godot\uid_cache.bin` used to collapse to one fused segment
# and match neither this check nor the loaded-plugin one below, while the
# write itself still landed in `.godot/`.
# - a `.` segment shifts the *anchored* plugin-tree check below, so
# `res://./addons/godot_ai/plugin.gd` slipped past it even once the
# separators were normalized.
#
# `..` never reaches here -- `_confine_under` refuses it first -- so its
# folding is inert. Because the engine normalizes backslashes on Linux too,
# a POSIX file literally named `addons\godot_ai\x.gd` is not addressable
# through `res://` at all, so normalizing costs no legitimate write.
var normalized := path.replace("\\", "/").simplify_path()
var file_lower := normalized.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.
var segments := normalized.trim_prefix("res://").split("/", false)
for segment in segments:
if segment.to_lower() == ".godot":
return "Refusing to write under res://.godot/ (editor metadata)"
# Reject the currently-loaded plugin's own script tree. Overwriting a
# loaded .gd here (plus the immediate reimport triggered by update_file())
# can SIGABRT the editor mid-call, or silently corrupt the installed
# plugin so the next enable fails to resolve scripts.
if segments.size() >= 2 and segments[0].to_lower() == "addons" and segments[1].to_lower() == "godot_ai":
return "Refusing to write under res://addons/godot_ai/ (overwriting a loaded plugin script can crash the editor)"
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
+125
View File
@@ -0,0 +1,125 @@
@tool
extends RefCounted
## Ordinary in-editor reload only. Transaction activation keeps its own
## quiescence, lock and readiness protocol; this is not a hot-update grant.
const PLUGIN_CFG := "res://addons/godot_ai/plugin.cfg"
const ScriptWork := preload("res://addons/godot_ai/utils/script_work.gd")
## A reload waits for the filesystem scan it asks for. An editor that is
## still importing at startup can take well over 5 s to answer on a slow
## host, and a reload abandoned then leaves the plugin unchanged while the
## caller keeps waiting; stay under the server's 90 s reconnect budget.
const SCAN_TIMEOUT_SECONDS := 60.0
## The native timer's budget; tests shorten it to prove the timer itself
## (not its length) settles an unfinished scan under a paused time scale.
static var _scan_timeout_seconds: float = SCAN_TIMEOUT_SECONDS
## One command-scoped native-signal handoff, not a suspended GDScript frame.
## Named callbacks survive the source reload caused by the scan itself.
static var _pending_scan: Dictionary = {}
static func is_reload_pending() -> bool:
return not _pending_scan.is_empty()
## Reserve before deferring the scan so the current dispatcher tick cannot
## start another handler which pumps the already-scheduled reload callback.
static func reserve_reload() -> int:
if is_reload_pending():
return 0
var work := ScriptWork.begin("reload_plugin")
_pending_scan = {"work": work}
return work
static func reload_after_scan(work: int = 0) -> void:
if work == 0:
work = reserve_reload()
if work == 0:
return
elif _pending_scan.get("work", 0) != work or bool(_pending_scan.get("toggling", false)):
return # A direct reload or cancellation consumed this deferred request.
_start_scan(EditorInterface.get_resource_filesystem(), null, work)
static func _start_scan(filesystem: Object, timer: Object, work: int) -> void:
if not _pending_scan.is_empty() and (
_pending_scan.get("work", 0) != work or _pending_scan.has("filesystem")
or bool(_pending_scan.get("toggling", false))
):
if _pending_scan.get("work", 0) != work:
ScriptWork.finish(work)
push_error("MCP | a plugin reload is already waiting for its filesystem scan")
return
if timer == null:
# Editor deadlines must not inherit a project's pause or time scale.
timer = Engine.get_main_loop().create_timer(_scan_timeout_seconds, true, false, true)
var complete := _finish_scan.bind(work, false)
var timeout := _finish_scan.bind(work, true)
_pending_scan = {"work": work, "filesystem": filesystem, "timer": timer,
"complete": complete, "timeout": timeout}
filesystem.filesystem_changed.connect(complete, CONNECT_ONE_SHOT | CONNECT_DEFERRED)
timer.timeout.connect(timeout, CONNECT_ONE_SHOT)
filesystem.scan()
static func _take_scan() -> Dictionary:
var pending := _pending_scan
_pending_scan = {}
if pending.has("filesystem"):
if pending.filesystem.filesystem_changed.is_connected(pending.complete):
pending.filesystem.filesystem_changed.disconnect(pending.complete)
if pending.timer.timeout.is_connected(pending.timeout):
pending.timer.timeout.disconnect(pending.timeout)
return pending
static func _finish_scan(work: int, timed_out: bool) -> void:
# A queued callback from an earlier scan can never consume a later request.
if _pending_scan.get("work", 0) != work or not _pending_scan.has("filesystem"):
return
if timed_out:
var pending := _take_scan()
ScriptWork.finish(pending.work)
push_error(
"MCP | filesystem scan did not finish within %d s; plugin left unchanged, retry reload"
% int(_scan_timeout_seconds)
)
else:
reload_enabled_plugin()
static func reload_enabled_plugin() -> Error:
# Keep the existing reservation through teardown: toggling the plugin can
# itself pump frames. Direct callers also gate dispatch during the toggle.
if not is_reload_pending():
reserve_reload()
elif bool(_pending_scan.get("toggling", false)):
return ERR_BUSY
# Disconnect scan signals before a toggle pumps frames, but retain the
# work reservation so already-queued callbacks cannot start another toggle.
var pending := _take_scan()
_pending_scan = {"work": pending.work, "toggling": true}
var result := _toggle_enabled_plugin()
_take_scan()
ScriptWork.finish(pending.work)
return result
static func _toggle_enabled_plugin() -> Error:
if not EditorInterface.is_plugin_enabled(PLUGIN_CFG):
push_error("MCP | cannot reload a disabled plugin")
return ERR_UNAVAILABLE
EditorInterface.set_plugin_enabled(PLUGIN_CFG, false)
EditorInterface.set_plugin_enabled(PLUGIN_CFG, true)
if not EditorInterface.is_plugin_enabled(PLUGIN_CFG):
push_error("MCP | plugin could not be re-enabled after reload")
return FAILED
## Plugin callbacks save autoload changes while the temporary disabled state
## is still visible. Persist only after set_plugin_enabled has completed, or
## the next editor process can reopen with this working plugin disabled.
var error := ProjectSettings.save()
if error != OK:
push_error("MCP | reloaded plugin enablement could not be saved: %s" % error_string(error))
return error
@@ -0,0 +1 @@
uid://c287g7fv5n3en
+286
View File
@@ -0,0 +1,286 @@
@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.
## Also home to the shared write-a-text-file path + deferred import-settle
## completion used by both script_handler.create_script and
## filesystem_handler.write_file (#714).
# Bounded settle window for `ResourceLoader.exists(path)` after a fresh text
# write registers with the filesystem, so an agent calling
# create_script/write_file -> attach_script back-to-back doesn't race the
# editor's import pipeline (#261, extended to write_file by #714). Polled once
# per frame, with an elapsed-time cap below the dispatcher's deferred timeouts
# for both commands. If import is still not visible at the cap, we still
# return committed data instead of letting the already-written file surface
# as DEFERRED_TIMEOUT.
const IMPORT_SETTLE_MAX_FRAMES := 300
const IMPORT_SETTLE_MAX_MSEC := 3500
## 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]
)
# Captured BEFORE the overwrite below so a resave of an already-uid'd file
# (overwrite=true) can restore its own uid instead of losing it — see
# ensure_uid's doc comment.
var prior_uid := ResourceLoader.get_resource_uid(resource_path) if existed_before else ResourceUID.INVALID_ID
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 uid_err := ensure_uid(resource_path, prior_uid)
if uid_err != OK:
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"%s saved to %s but failed to write its uid: %s" % [label, resource_path, error_string(uid_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}
## Save `res` to `resource_path` with the same `pause_processing` re-entrancy
## guard as `save_to_disk` (see its doc for the #288 SIGSEGV background), for
## call sites that need to pick their own error handling / overwrite policy
## instead of `save_to_disk`'s full validate+mkdir+overwrite-guard bundle
## (undo/redo callables reloading-mutating-resaving an existing resource,
## `apply_to_node`'s inline-then-save branch). Returns the raw
## `ResourceSaver.save` error code.
static func guarded_save(res: Resource, resource_path: String, pause_target: McpConnection) -> int:
var prior_uid := ResourceLoader.get_resource_uid(resource_path) if FileAccess.file_exists(resource_path) else ResourceUID.INVALID_ID
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 save_err
return ensure_uid(resource_path, prior_uid)
## Make `resource_path` carry a stable uid after a successful
## `ResourceSaver.save()`, matching what Godot's own "New Scene"/"New
## Resource" editor flows always embed. A bare `ResourceSaver.save()` call
## does neither on its own: a brand-new file gets no `uid=` at all, and
## resaving a file that already had one silently drops it (#737). Call this
## immediately after every successful save.
##
## `prior_uid` is whatever `ResourceLoader.get_resource_uid(resource_path)`
## returned BEFORE this save overwrote the file (pass `ResourceUID.INVALID_ID`
## for a brand-new path). Reusing the prior id — instead of always minting a
## fresh one — keeps any `uid://...` references elsewhere in the project
## resolving to the same file.
##
## Returns the `Error` from `ResourceSaver.set_uid()` so callers can surface a
## uid-write failure instead of silently reporting success on a file that
## didn't end up with the uid it was supposed to get.
static func ensure_uid(resource_path: String, prior_uid: int) -> Error:
var id := prior_uid
if id == ResourceUID.INVALID_ID:
id = ResourceUID.create_id()
return ResourceSaver.set_uid(resource_path, id)
## 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}
## Shared write-a-text-file path (#714): parent-directory mkdir, write +
## flush with an explicit error check so a truncated write (disk full,
## permission flip mid-write) surfaces as an error instead of plain success.
## Deliberately does NOT call `EditorFileSystem.update_file()` — callers
## register the file themselves after assembling their response fields, so
## the registration comment (the dsarno/godot#6 scan-stacking rationale)
## stays next to the call. Returns null on success or an error dict ready
## to return from the handler.
static func write_text_to_disk(path: String, content: String) -> Variant:
var dir_path := path.get_base_dir()
if not DirAccess.dir_exists_absolute(dir_path):
var err := DirAccess.make_dir_recursive_absolute(dir_path)
if err != OK:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to create directory: %s" % dir_path)
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to open file for writing: %s" % path)
file.store_string(content)
file.flush()
var write_err := file.get_error()
file.close()
if write_err != OK:
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Write failed for %s (%s); file may be truncated" % [path, error_string(write_err)]
)
return null
# `static` is load-bearing: the deferred completion captures no `self`, so the
# coroutine survives even if the calling handler RefCounted is freed mid-await.
# Under concurrent create storms with editor_reload_plugin fired during the
# burst, an instance-method coroutine is otherwise GC'd between `await` and
# resume, producing "Resumed function ... after await, but class instance is
# gone" errors and dropping the response. Keep this function static and
# parameterise everything it needs explicitly — do not reference instance
# state. Shared by create_script and write_file's fresh-`.gd` path (#714).
static func finish_text_write_deferred(
connection: McpConnection,
request_id: String,
path: String,
data: Dictionary,
) -> void:
var ledger := preload("res://addons/godot_ai/utils/script_work.gd")
var work: int = ledger.begin("write_file")
await _settle_text_write(connection, request_id, path, data)
ledger.finish(work)
static func _settle_text_write(
connection: McpConnection, request_id: String, path: String, data: Dictionary,
) -> void:
if not is_instance_valid(connection):
return
var tree := connection.get_tree()
if tree == null:
return
var deadline_ms := Time.get_ticks_msec() + IMPORT_SETTLE_MAX_MSEC
# Let _dispatch() return DEFERRED_RESPONSE and register the request before
# this coroutine can send a committed result. ResourceLoader.exists(path)
# may already be true on fast imports; without this handoff the connection
# treats the response as late/unregistered and drops it, then the dispatcher
# times out a file that was already written (#324). The deadline starts
# before this await so a slow handoff frame is counted against the bounded
# settle window.
await tree.process_frame
var frames := 0
while (
frames < IMPORT_SETTLE_MAX_FRAMES
and Time.get_ticks_msec() < deadline_ms
and not ResourceLoader.exists(path)
):
await tree.process_frame
frames += 1
# If the plugin tears down (_exit_tree frees the connection) during the
# await, is_instance_valid() goes false and we drop the response silently —
# the server's request timeout will surface the failure to the caller.
if not is_instance_valid(connection):
return
var payload := data.duplicate()
var settled := ResourceLoader.exists(path)
payload["import_settled"] = settled
payload["import_settle"] = "settled" if settled else "timeout"
payload["import_pending"] = not settled
connection.send_deferred_response(request_id, {"data": payload})
+1
View File
@@ -0,0 +1 @@
uid://de2rwdoa4wabf
+155
View File
@@ -0,0 +1,155 @@
@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
## Bare "/" alias: the most natural first guess for "the scene root".
## There is exactly one edited-scene root, so the alias is unambiguous;
## without it, "/" falls through to get_node_or_null("/") → null and
## every call costs the agent a NODE_NOT_FOUND round trip (issue #624).
if scene_path == "/":
return scene_root
## /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.) carry sub_code + retryable
# via ErrorCodes.make_not_ready (#651 stage 1) but intentionally
# omit the hint — there's no useful caller action to name.
var err := ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "No scene open")
err["error"]["data"] = {
"sub_code": ErrorCodes.SUB_EDITOR_NO_SCENE,
"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]
+1
View File
@@ -0,0 +1 @@
uid://c1irdrss0amex
@@ -0,0 +1,38 @@
@tool
class_name McpScreenshotEncode
extends RefCounted
## Shared downscale + PNG + base64 block for the screenshot paths (#716).
##
## Two call sites straddle the editor/game process boundary — the editor's
## take_screenshot (editor_handler) and the game-process autoload
## (runtime/game_helper) — and were maintained as manually synchronized
## copies. Pure static, no editor APIs, so it loads safely in the game
## process too.
## Downscale `image` in place so its longest edge is at most
## `max_resolution` (0 = no cap), then PNG-encode. Returns
## {base64, width, height, original_width, original_height}.
static func downscale_and_encode(image: Image, max_resolution: int) -> Dictionary:
var original_width := image.get_width()
var original_height := image.get_height()
if max_resolution > 0:
var longest := maxi(original_width, original_height)
if longest > max_resolution:
var scale := float(max_resolution) / float(longest)
## Clamp to 1px min: extreme aspect ratios at very small
## max_resolution could otherwise compute a zero dimension and
## crash image.resize().
var new_w := maxi(1, int(original_width * scale))
var new_h := maxi(1, int(original_height * scale))
image.resize(new_w, new_h, Image.INTERPOLATE_LANCZOS)
return {
"base64": Marshalls.raw_to_base64(image.save_png_to_buffer()),
"width": image.get_width(),
"height": image.get_height(),
"original_width": original_width,
"original_height": original_height,
}
@@ -0,0 +1 @@
uid://cl0xhoxwsbmow
+29
View File
@@ -0,0 +1,29 @@
@tool
extends RefCounted
## A response timeout is not proof that its coroutine has returned. Track
## actual script execution across frame yields, independently of transport
## bookkeeping. This is process-wide deliberately: an older composition may
## still own a coroutine in the same scripts after an ordinary plugin reload.
## Only the main thread may use this ledger; OS workers have separate joins.
static var _next_id := 0
static var _active: Dictionary = {}
static func begin(label: String) -> int:
_next_id += 1
_active[_next_id] = label
return _next_id
static func finish(id: int) -> void:
_active.erase(id)
static func quiescence() -> Dictionary:
if not _active.is_empty():
return {
"ok": false,
"error": "Wait for active editor work before updating: %s" % str(_active.values()),
}
return {"ok": true}
+1
View File
@@ -0,0 +1 @@
uid://ghcyiy3v3nsk
+171
View File
@@ -0,0 +1,171 @@
@tool
extends RefCounted
const TransportCapability := preload("res://addons/godot_ai/utils/transport_capability.gd")
## Capability and process-control values used by the lifecycle owner. None of
## these objects performs work or retains another owner. Keeping the three
## grants distinct makes it impossible to turn "I can talk to this server"
## into "I may kill or replace it" by accident.
class TransportAuthority:
extends RefCounted
var _http_port: int
var _ws_port: int
var _server_instance_id: String
var _http_capability: String
var _ws_capability: String
func _init(
p_http_port: int,
p_ws_port: int,
p_server_instance_id: String,
p_http_capability: String,
p_ws_capability: String,
) -> void:
_http_port = p_http_port
_ws_port = p_ws_port
_server_instance_id = p_server_instance_id
_http_capability = p_http_capability
_ws_capability = p_ws_capability
func is_valid() -> bool:
return (
_http_port > 0
and _http_port <= 65535
and _ws_port > 0
and _ws_port <= 65535
and TransportCapability.is_lower_hex(_server_instance_id, 32)
and TransportCapability.is_http_capability(_http_capability)
and TransportCapability.is_lower_hex(_ws_capability, 64)
and _http_capability != _ws_capability
)
func http_capability() -> String:
return _http_capability
func ws_capability() -> String:
return _ws_capability
func http_port() -> int:
return _http_port
func ws_port() -> int:
return _ws_port
func server_instance_id() -> String:
return _server_instance_id
## Safe for UI/status fanout: secrets never leave the lifecycle/connection
## boundary through a generic Dictionary snapshot.
func public_snapshot() -> Dictionary:
return {
"http_port": _http_port,
"ws_port": _ws_port,
"server_instance_id": _server_instance_id,
}
class OwnedProcessGrant:
extends RefCounted
var _pid: int
var _process_fingerprint: String
var _issued_at_msec: int
func _init(
p_pid: int,
p_process_fingerprint: String,
p_issued_at_msec: int,
) -> void:
_pid = p_pid
_process_fingerprint = p_process_fingerprint
_issued_at_msec = p_issued_at_msec
func is_valid() -> bool:
return (
_pid > 1
and _pid != OS.get_process_id()
and not _process_fingerprint.is_empty()
and _issued_at_msec > 0
)
func process_id() -> int:
return _pid
func matches(p_pid: int, p_fingerprint: String) -> bool:
return (
is_valid()
and _pid == p_pid
and _process_fingerprint == p_fingerprint
)
class ReplacementAuthorization:
extends RefCounted
var _target_instance_id: String
var _target_version: String
var _target_port: int
var _expires_at_msec: int
var _spent := false
func _init(
p_target_instance_id: String,
p_target_version: String,
p_target_port: int,
p_expires_at_msec: int,
) -> void:
_target_instance_id = p_target_instance_id
_target_version = p_target_version
_target_port = p_target_port
_expires_at_msec = p_expires_at_msec
func is_available(now_msec: int) -> bool:
return (
not _spent
and now_msec <= _expires_at_msec
and _target_port > 0
and not _target_instance_id.is_empty()
and not _target_version.is_empty()
)
## Spend is intentionally binding and destructive. A caller cannot probe
## one target and then reuse the same click-authority against another.
func spend(
now_msec: int,
p_target_instance_id: String,
p_target_version: String,
p_target_port: int,
) -> bool:
if not is_available(now_msec):
return false
if (
_target_instance_id != p_target_instance_id
or _target_version != p_target_version
or _target_port != p_target_port
):
return false
_spent = true
return true
func is_spent() -> bool:
return _spent
@@ -0,0 +1 @@
uid://2gx74pk3lpq2
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
uid://bwfx8b0w2mgf6
@@ -0,0 +1,64 @@
@tool
class_name McpServerVersionCheck
extends RefCounted
## Published v4 compatibility value. Lifecycle owns the authenticated
## handshake and episode transition; this class intentionally owns no timer,
## connection, or manager reference.
## First release whose attached bridge follows servers of the same major.
const FIRST_BRIDGE_TOLERANT_VERSION := "4.0.4"
## Whether clients attached at `from_version` can reconnect after updating
## to `to_version` without restarting their bridge (#1024).
static func attached_bridges_follow(from_version: String, to_version: String) -> bool:
var from_tuple := version_tuple(from_version)
var to_tuple := version_tuple(to_version)
if from_tuple.is_empty() or to_tuple.is_empty():
return false
if int(from_tuple[0]) != int(to_tuple[0]):
return false
return compare(from_tuple, version_tuple(FIRST_BRIDGE_TOLERANT_VERSION)) >= 0
## Leading numeric `major.minor.patch` of a version as `[major, minor, patch]`,
## or `[]` when it does not start that way (a dev build, a malformed pin).
## A suffix after the patch (`4.0.3+local.1`, `4.1.0-rc1`) is ignored; a
## dangling separator (`4.0.3+`) is not a version.
static func version_tuple(version: String) -> Array:
var regex := RegEx.create_from_string("^(\\d+)\\.(\\d+)\\.(\\d+)(?:$|[.+-][0-9A-Za-z][0-9A-Za-z.+-]*$)")
var found := regex.search(version.strip_edges())
if found == null:
return []
return [int(found.get_string(1)), int(found.get_string(2)), int(found.get_string(3))]
static func compare(a: Array, b: Array) -> int:
for index in range(3):
if int(a[index]) != int(b[index]):
return -1 if int(a[index]) < int(b[index]) else 1
return 0
## `candidate` is a parseable version older than `reference` within the same
## major version. Unparseable, equal, newer, or another major all read false:
## a caller may only replace what it can prove is older than itself.
static func is_older_same_major(candidate: String, reference: String) -> bool:
var candidate_tuple := version_tuple(candidate)
var reference_tuple := version_tuple(reference)
if candidate_tuple.is_empty() or reference_tuple.is_empty():
return false
if int(candidate_tuple[0]) != int(reference_tuple[0]):
return false
return compare(candidate_tuple, reference_tuple) < 0
static func evaluate(actual_version: String, expected_version: String) -> Dictionary:
if actual_version.is_empty():
return {"compatible": false, "reason": "missing_version"}
var compatible := actual_version == expected_version
return {
"compatible": compatible,
"reason": "" if compatible else "version_mismatch",
}
@@ -0,0 +1 @@
uid://ciqldbuaq8i8u
+135
View File
@@ -0,0 +1,135 @@
@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"
## Comma-separated CIDRs / bare IPs passed to the server as `--allow-host`
## at spawn time (#507, server core #421). Empty means loopback-only.
const SETTING_ALLOW_HOSTS := "godot_ai/allow_remote_hosts"
## Whether MCP log lines echo to the Godot console (dock "Log" toggle).
## The dock's ring-buffer log panel keeps recording regardless.
const SETTING_MCP_LOGGING := "godot_ai/mcp_logging"
## Which scope CLI-configured clients register the server in. Claude Code's
## `--scope user` writes the global block of ~/.claude.json, which that client
## loads in EVERY workspace — so the server gets spawned in unrelated,
## non-Godot projects. `project` writes <project>/.mcp.json instead, so the
## entry travels with the project. Defaults to `user` to preserve the
## historical behaviour for existing installs.
const SETTING_CLIENT_SCOPE := "godot_ai/mcp_client_scope"
## Scopes accepted for SETTING_CLIENT_SCOPE. These are the values the Claude
## Code CLI's `--scope` flag takes; an unrecognised setting falls back to
## DEFAULT_CLIENT_SCOPE rather than passing junk to the CLI.
const CLIENT_SCOPES := ["user", "project", "local"]
const DEFAULT_CLIENT_SCOPE := "user"
## 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))
## #691: EditorInterface / EditorSettings are not thread-safe, and the dock
## runs Configure / Remove on a worker Thread (mcp_dock.gd
## `_run_client_action_worker` -> McpClientConfigurator -> McpCliStrategy
## `_format_args` -> client_scope()). Same contract as
## McpClientConfigurator._editor_setting_lookup: the main thread does a live
## read and refreshes the snapshot; worker threads read the snapshot only, so
## a never-warmed value reads as null (unset) rather than touching
## EditorInterface off-thread. Warmed on the main thread by
## McpClientConfigurator.warm_env_snapshot() before each worker dispatch.
##
## Kept here rather than reusing the configurator's `_editor_setting_lookup`
## so utils/settings.gd stays free of the client_configurator.gd dep tree —
## see this file's header.
static var _client_scope_snapshot: Variant = null
static var _client_scope_mutex := Mutex.new()
static func _client_scope_setting() -> Variant:
if OS.get_thread_caller_id() == OS.get_main_thread_id():
var live: Variant = null
if Engine.is_editor_hint():
var es := EditorInterface.get_editor_settings()
if es != null and es.has_setting(SETTING_CLIENT_SCOPE):
live = es.get_setting(SETTING_CLIENT_SCOPE)
_client_scope_mutex.lock()
_client_scope_snapshot = live
_client_scope_mutex.unlock()
return live
_client_scope_mutex.lock()
var cached: Variant = _client_scope_snapshot
_client_scope_mutex.unlock()
return cached
## Main-thread pre-warm of the scope snapshot so the dock's Configure/Remove
## worker resolves `{scope}` from a value captured while EditorSettings access
## was safe. Idempotent; called from warm_env_snapshot().
static func warm_client_scope() -> void:
_client_scope_setting()
## Returns the scope CLI clients should register the MCP server in, as
## substituted into `{scope}` in a descriptor's cli_register_template /
## cli_unregister_template. Safe to call from either thread (see
## _client_scope_setting). Unrecognised values fall back to
## DEFAULT_CLIENT_SCOPE so a hand-edited editor_settings-4.tres can never make
## the plugin shell out with a bad flag.
static func client_scope() -> String:
var value: Variant = _client_scope_setting()
## String(null) is a hard error (#850) — an unset/never-warmed key must
## short-circuit before the cast.
if value == null:
return DEFAULT_CLIENT_SCOPE
var raw := String(value).strip_edges().to_lower()
if raw in CLIENT_SCOPES:
return raw
return DEFAULT_CLIENT_SCOPE
## 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
## Returns whether MCP log lines should echo to the Godot console. Read at
## plugin startup (to apply the persisted choice to the log buffer and
## dispatcher) and by the dock's LogViewer toggle for its initial state.
## Defaults to true when the user has never touched the toggle.
static func mcp_logging_enabled() -> bool:
var es := EditorInterface.get_editor_settings()
if es != null and es.has_setting(SETTING_MCP_LOGGING):
return bool(es.get_setting(SETTING_MCP_LOGGING))
return true
## Persist the dock "Log" toggle so the choice survives editor restarts (#626).
static func set_mcp_logging_enabled(enabled: bool) -> void:
var es := EditorInterface.get_editor_settings()
if es != null:
es.set_setting(SETTING_MCP_LOGGING, enabled)
+1
View File
@@ -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,642 @@
@tool
class_name McpSurfacedErrorTracker
extends RefCounted
## Central source for "errors the agent should know exist".
##
## Editor log cursors only cover McpEditorLogBuffer. Runtime errors from the
## game subprocess can land solely in the Debugger Errors tab, so this tracker
## promotes visible Debugger-tab rows into a monotonic sequence before the
## dispatcher stamps a watermark on each response envelope.
const MAX_PROMOTED_DEBUGGER_ENTRIES := 500
const MAX_PROMOTED_DEBUGGER_KEYS := 5000
const DEBUGGER_REFRESH_MIN_INTERVAL_MS := 250
const DEBUGGER_SCAN_AFTER_STOP_MS := 5000
## #641: delays for the self-scheduled forced scans armed on run stop (and on
## game-helper hello, via McpDebuggerPlugin). Two ticks: an early one for rows
## the remote debugger delivers right around the event, and a late one past
## Godot's per-frame Errors-tab insertion throttle for error floods.
const DEFERRED_SCAN_DELAYS_SEC: Array[float] = [1.0, 5.0]
## #635: cap on accounted per-key row-time signatures. The live Errors tab is
## itself bounded, so this only guards a pathological flood of same-keyed rows
## with distinct time texts; past the cap the set resets to the current scan.
const MAX_ACCOUNTED_ROW_TIMES_PER_KEY := 512
var _editor_log_buffer
var _game_log_buffer
var _debugger_errors_root: Node
var _debugger_search_root_cache: Node
var _promoted_debugger_keys: Dictionary = {}
## #635: per-key set of Errors-tab row time texts already promoted, so a row
## observed after a clear+repopulate that no scan saw as empty still counts as
## new (see the re-promotion comment in refresh_debugger_errors).
var _promoted_debugger_row_times: Dictionary = {}
var _promoted_debugger_key_order: Array[String] = []
var _promoted_debugger_entries: Array[Dictionary] = []
var _debugger_promoted_total := 0
var _run_seq := 0
var _oldest_retained_debugger_sequence := 1
var _last_debugger_refresh_msec := -DEBUGGER_REFRESH_MIN_INTERVAL_MS
var _debugger_scan_active := false
var _debugger_scan_until_msec := 0
var _deferred_scans_scheduled_total := 0
func _init(editor_log_buffer = null, game_log_buffer = null, debugger_errors_root: Node = null) -> void:
_editor_log_buffer = editor_log_buffer
_game_log_buffer = game_log_buffer
_debugger_errors_root = debugger_errors_root
func note_game_run_started(sticky_scan: bool = true) -> void:
_run_seq += 1
_debugger_scan_active = sticky_scan
_debugger_scan_until_msec = 0
if not sticky_scan:
_debugger_scan_until_msec = Time.get_ticks_msec() + DEBUGGER_SCAN_AFTER_STOP_MS
refresh_debugger_errors(true)
func note_game_run_stopped() -> void:
_debugger_scan_active = false
_debugger_scan_until_msec = Time.get_ticks_msec() + DEBUGGER_SCAN_AFTER_STOP_MS
schedule_deferred_scans()
## #641: promotion into the watermark used to depend on a tool call arriving
## while the scan gate was open (run active, or within DEBUGGER_SCAN_AFTER_STOP_MS
## of stop). Boot parse errors that landed in the Errors tab with no tool call
## in that window were never promoted, so the agent never got the
## new_errors_since_last_call hint. These editor-side timers force a scan
## regardless of tool-call cadence; the next stamped response then carries the
## already-promoted count even after the gate closes. Scans are content-keyed
## and idempotent, so a timer firing after an unrelated new run is harmless.
func schedule_deferred_scans(delays: Array = DEFERRED_SCAN_DELAYS_SEC) -> void:
var tree := Engine.get_main_loop() as SceneTree
if tree == null:
return
for delay in delays:
var timer := tree.create_timer(maxf(0.05, float(delay)))
timer.timeout.connect(_on_deferred_scan_timeout)
_deferred_scans_scheduled_total += 1
func deferred_scans_scheduled_total() -> int:
return _deferred_scans_scheduled_total
func _on_deferred_scan_timeout() -> void:
refresh_debugger_errors(true)
func refresh_debugger_errors(force: bool = true) -> void:
var now := Time.get_ticks_msec()
if not force and not _should_scan_debugger_for_cached_watermark(now):
return
_last_debugger_refresh_msec = now
var current_by_key: Dictionary = {}
for entry in _raw_debugger_error_entries():
if str(entry.get("level", "")) != "error":
continue
var key := _log_entry_key(entry)
var info: Dictionary = current_by_key.get(key, {"count": 0, "entry": entry, "times": {}})
info["count"] = int(info.get("count", 0)) + 1
var time_text := _row_time_text(entry)
if not time_text.is_empty():
(info["times"] as Dictionary)[time_text] = true
current_by_key[key] = info
for key in _promoted_debugger_keys.keys():
if not current_by_key.has(key):
_promoted_debugger_keys[key] = 0
for key in current_by_key.keys():
var info: Dictionary = current_by_key[key]
var current := int(info.get("count", 0))
var stored := int(_promoted_debugger_keys.get(key, 0))
## #635: a count increase alone misses rows observed after a run
## boundary. Godot clears the Errors tab at run start; when the new run
## re-fires an error identical to one promoted before the clear, and no
## scan happened to observe the tab empty in between, the per-key count
## never dips — so the row kept its pre-run sequence and run-scoping
## (editor_entries_since against the run-start cursor) misclassified an
## in-run error as retained_recent. Each Errors-tab row carries its own
## time text; an unaccounted (key, time) signature is a row we have not
## promoted yet, so it earns a fresh sequence even at an equal or lower
## count. Boundary condition: rows with an empty time text, or a
## repopulated row whose time text is byte-identical to a pre-clear row,
## fall back to count-only dedup and can still be missed.
var unseen_times := _unaccounted_row_times(key, info.get("times", {}))
var delta := current - stored
if delta <= 0 and not unseen_times.is_empty():
delta = mini(unseen_times.size(), current)
if delta <= 0:
if current != stored:
_promoted_debugger_keys[key] = current
continue
if not _promoted_debugger_keys.has(key):
_promoted_debugger_key_order.append(key)
_promoted_debugger_keys[key] = current
_account_row_times(key, info.get("times", {}))
_debugger_promoted_total += delta
var source_entry: Dictionary = info.get("entry", {})
var promoted := source_entry.duplicate(true)
promoted["_debugger_key"] = key
promoted["_debugger_occurrences"] = current
promoted["_debugger_sequence"] = _debugger_promoted_total
_remove_promoted_debugger_entry(key)
_promoted_debugger_entries.append(promoted)
_trim_promoted_debugger_entries()
_trim_promoted_debugger_key_counts()
## #645: promote an error record that has no Errors-tab row to scrape — e.g. a
## boot-time parse error that parked the game in a remote-debugger break before
## any surface got a record. The entry joins the same promoted sequence as
## scraped Debugger rows, so run-scoping (editor_entries_since), the retained
## fallback, and the response watermark all see it with no extra plumbing.
## Re-recording the same key later (the same script still broken on the next
## run) re-promotes it with a fresh sequence, mirroring how re-appearing
## Errors-tab rows behave; scan reconciliation zeroes the key's count once the
## break ends since the row never exists in the live tab.
func record_synthetic_error(entry: Dictionary) -> void:
var key := _log_entry_key(entry)
var occurrences := int(_promoted_debugger_keys.get(key, 0)) + 1
if not _promoted_debugger_keys.has(key):
_promoted_debugger_key_order.append(key)
_promoted_debugger_keys[key] = occurrences
_debugger_promoted_total += 1
var promoted := entry.duplicate(true)
promoted["_debugger_key"] = key
promoted["_debugger_occurrences"] = occurrences
promoted["_debugger_sequence"] = _debugger_promoted_total
promoted["_debugger_synthetic"] = true
_remove_promoted_debugger_entry(key)
_promoted_debugger_entries.append(promoted)
_trim_promoted_debugger_entries()
_trim_promoted_debugger_key_counts()
## Monotonicity contract (#767): run_seq and the session-scoped components
## (editor_ring, debugger_promoted, editor_ring_warn) must NEVER decrease
## within an editor session, and the per-run components (game_error_warn,
## game_warn) must never decrease within a run — they may reset only when
## run_seq increments in the same stamp (the run boundary that rotates the
## game buffer's counters). Released servers diff consecutive stamps
## (websocket.py::_sync_error_watermark_for_session) and treat any other
## decrease as a counter reset, counting the FULL current value as new — one
## dip makes every old server out there over-report errors. Any future
## hold/classification feature must therefore DEFER an increment until its
## entry is released, never subtract an already-stamped one: a stamp like
## `raw_total - currently_held_entries` is exactly the regression this
## guards against. Producer-side coverage:
## test_editor.gd::test_surfaced_error_tracker_watermark_components_never_decrease.
func watermark(force_debugger_scan: bool = false) -> Dictionary:
refresh_debugger_errors(force_debugger_scan)
return {
"run_seq": _run_seq,
"editor_ring": _error_appended_total(),
"debugger_promoted": _debugger_promoted_total,
## Historically misnamed: carries game-process ERROR counts only.
"game_error_warn": _game_error_total(),
## Warn-level components, parallel to the error counts above. The server
## diffs these into `new_warnings_since_last_call` so a warning-only run
## surfaces instead of reading as clean. Debugger Errors-tab warning rows
## are not promoted here yet (buffers cover push_warning from the game and
## editor parse/@tool warnings) — tracked as a follow-up.
"editor_ring_warn": _warn_appended_total(),
"game_warn": _game_warn_total(),
}
static func stamp_watermark(response: Dictionary, tracker) -> void:
if tracker == null:
return
if not tracker.has_method("watermark"):
return
response["error_watermark"] = tracker.watermark()
func debugger_promoted_total(force_debugger_scan: bool = true) -> int:
refresh_debugger_errors(force_debugger_scan)
return _debugger_promoted_total
func collect_editor_log_entries() -> Array[Dictionary]:
refresh_debugger_errors(true)
var entries: Array[Dictionary] = []
var seen_keys: Dictionary = {}
if _editor_log_buffer != null:
for entry in _editor_log_buffer.get_range(0, _editor_log_buffer.total_count()):
seen_keys[_log_entry_key(entry)] = true
entries.append(entry)
for entry in read_debugger_error_entries():
var key := _log_entry_key(entry)
if seen_keys.has(key):
continue
seen_keys[key] = true
entries.append(entry)
## #645: synthesized break records have no live Errors-tab row to scrape —
## merge them from the promoted list so logs_read(source="editor") shows
## the record that run/game responses point at.
for entry in _promoted_debugger_entries:
if not bool(entry.get("_debugger_synthetic", false)):
continue
var key := _log_entry_key(entry)
if seen_keys.has(key):
continue
seen_keys[key] = true
entries.append(_strip_promotion_bookkeeping(entry))
return entries
static func _strip_promotion_bookkeeping(entry: Dictionary) -> Dictionary:
var clean := entry.duplicate(true)
for key in ["_debugger_key", "_debugger_occurrences", "_debugger_sequence", "_debugger_synthetic"]:
clean.erase(key)
return clean
func editor_entries_since(editor_cursor: int, debugger_cursor: int, force_debugger_scan: bool = true) -> Dictionary:
refresh_debugger_errors(force_debugger_scan)
var entries: Array[Dictionary] = []
var seen_keys: Dictionary = {}
var truncated := false
if _editor_log_buffer != null:
var captured: Dictionary = _editor_log_buffer.get_since(maxi(0, editor_cursor), -1)
truncated = bool(captured.get("truncated", false))
for entry in captured.get("entries", []):
seen_keys[_log_entry_key(entry)] = true
entries.append(entry)
if debugger_cursor < _oldest_retained_debugger_sequence - 1:
truncated = true
for entry in _promoted_debugger_entries:
if int(entry.get("_debugger_sequence", 0)) <= debugger_cursor:
continue
var key := _log_entry_key(entry)
if seen_keys.has(key):
continue
seen_keys[key] = true
entries.append(entry)
return {
"entries": entries,
"truncated": truncated,
}
func retained_recent_editor_entries() -> Array[Dictionary]:
## There is no shared timestamp across the editor logger ring and Godot's
## Debugger Errors tree. Preserve the pre-PR fallback contract: newest
## buffered editor entries first, then debugger-only rows that were not in
## the ring, so stale Debugger rows cannot outrank newer ring entries.
var entries: Array[Dictionary] = []
var seen_keys: Dictionary = {}
if _editor_log_buffer != null:
entries = _editor_log_buffer.get_recent(_editor_log_buffer.total_count())
entries.reverse()
for entry in entries:
seen_keys[_log_entry_key(entry)] = true
for entry in collect_editor_log_entries():
var key := _log_entry_key(entry)
if seen_keys.has(key):
continue
seen_keys[key] = true
entries.append(entry)
return entries
func read_debugger_error_entries() -> Array[Dictionary]:
var entries: Array[Dictionary] = []
var seen_keys: Dictionary = {}
for entry in _raw_debugger_error_entries():
var key := _log_entry_key(entry)
if seen_keys.has(key):
continue
seen_keys[key] = true
entries.append(entry)
return entries
func locate_debugger_error_trees() -> Array[Tree]:
var trees: Array[Tree] = []
var root: Node = _debugger_errors_root
## #641: a deferred-scan timer can outlive an injected root (tests,
## teardown). A freed root must not fall through to the live editor UI —
## that would promote unrelated real errors into a tracker scoped to the
## dead root — so treat it as "nothing to scan".
if root != null and not is_instance_valid(root):
return trees
if root == null:
root = _debugger_search_root()
if root == null:
return trees
_collect_debugger_error_trees(root, trees)
return trees
func clear_debugger_error_trees() -> int:
var cleared := 0
for tree in locate_debugger_error_trees():
cleared += entries_from_debugger_error_tree(tree).size()
if not _press_debugger_clear_button(tree):
## Synthetic roots in tests do not have Godot's Clear button.
tree.clear()
return cleared
func _debugger_search_root() -> Node:
if is_instance_valid(_debugger_search_root_cache):
return _debugger_search_root_cache
_debugger_search_root_cache = null
var base := EditorInterface.get_base_control()
if base == null:
return null
_debugger_search_root_cache = _find_first_of_class(base, "EditorDebuggerNode")
if _debugger_search_root_cache == null:
return base
return _debugger_search_root_cache
static func _find_first_of_class(node: Node, klass: String) -> Node:
if node.get_class() == klass:
return node
for child in node.get_children():
var found := _find_first_of_class(child, klass)
if found != null:
return found
return null
static func _collect_debugger_error_trees(node: Node, out: Array[Tree]) -> void:
if node is Tree and _tree_has_debugger_errors(node as Tree):
out.append(node as Tree)
for child in node.get_children():
if child is Node:
_collect_debugger_error_trees(child as Node, out)
static func _tree_has_debugger_errors(tree: Tree) -> bool:
var root := tree.get_root()
if root == null:
return false
var item := root.get_first_child()
while item != null:
if _is_debugger_error_item(item):
return true
item = item.get_next()
return false
static func _press_debugger_clear_button(tree: Tree) -> bool:
var parent := tree.get_parent()
if parent == null:
return false
var stack: Array[Node] = [parent]
while not stack.is_empty():
var node: Node = stack.pop_back()
if node is BaseButton:
for conn in node.get_signal_connection_list("pressed"):
if str(conn.get("callable", "")).contains("_clear_errors_list"):
node.emit_signal("pressed")
return true
for child in node.get_children():
stack.push_back(child)
return false
static func entries_from_debugger_error_tree(tree: Tree) -> Array[Dictionary]:
var entries: Array[Dictionary] = []
var root := tree.get_root()
if root == null:
return entries
var item := root.get_first_child()
while item != null:
if _is_debugger_error_item(item):
entries.append(_entry_from_debugger_error_item(item))
item = item.get_next()
return entries
static func _entry_from_debugger_error_item(item: TreeItem) -> Dictionary:
var title := item.get_text(1)
var loc := _location_from_metadata(item.get_metadata(0))
var function := _function_from_title(title)
return {
"source": "editor",
"level": "warn" if item.has_meta("_is_warning") else "error",
"text": title,
"path": str(loc.get("path", "")),
"line": int(loc.get("line", 0)),
"function": function,
"details": _details_from_debugger_error_item(item, loc, function),
}
static func _details_from_debugger_error_item(item: TreeItem, loc: Dictionary, function: String) -> Dictionary:
var children: Array[Dictionary] = []
var child := item.get_first_child()
while child != null:
var child_loc := _location_from_metadata(child.get_metadata(0))
children.append({
"label": child.get_text(0),
"text": child.get_text(1),
"path": str(child_loc.get("path", "")),
"line": int(child_loc.get("line", 0)),
})
child = child.get_next()
return {
"debugger_tab": "Errors",
"time": item.get_text(0),
"message": item.get_text(1),
"error_type_name": "warning" if item.has_meta("_is_warning") else "error",
"source": {
"path": str(loc.get("path", "")),
"line": int(loc.get("line", 0)),
"function": function,
},
"resolved": {
"path": str(loc.get("path", "")),
"line": int(loc.get("line", 0)),
"function": function,
},
"children": children,
"frames": _frames_from_error_children(children),
}
static func _is_debugger_error_item(item: TreeItem) -> bool:
return item.has_meta("_is_warning") or item.has_meta("_is_error")
static func _frames_from_error_children(children: Array[Dictionary]) -> Array[Dictionary]:
var start := -1
for i in children.size():
if str(children[i].label).contains("Stack Trace"):
start = i
break
if start < 0:
for i in children.size():
if str(children[i].label).is_empty() and not str(children[i].path).is_empty():
start = maxi(i - 1, 0)
break
if start < 0:
return []
var frames: Array[Dictionary] = []
for i in range(start, children.size()):
if str(children[i].path).is_empty():
continue
frames.append({
"path": children[i].path,
"line": children[i].line,
"function": _function_from_frame_text(children[i].text),
})
return frames
static func _location_from_metadata(meta: Variant) -> Dictionary:
if meta is Array and meta.size() >= 2:
return {"path": str(meta[0]), "line": int(meta[1])}
return {"path": "", "line": 0}
static func _function_from_title(title: String) -> String:
var colon := title.find(": ")
if colon <= 0:
return ""
return title.substr(0, colon)
static func _function_from_frame_text(text: String) -> String:
var marker := text.find(" @ ")
if marker < 0:
return ""
var fn := text.substr(marker + 3).strip_edges()
if fn.ends_with("()"):
fn = fn.substr(0, fn.length() - 2)
return fn
## Shared one-line rendering of a compact editor-error entry for messages and
## hints ("text (path:line)"). Single home so the debugger plugin, project
## handler, and editor handler can't drift apart.
static func format_editor_error_summary(entry: Dictionary) -> String:
var text := str(entry.get("text", "editor error"))
var path := str(entry.get("path", ""))
var line := int(entry.get("line", 0))
if not path.is_empty() and line > 0:
return "%s (%s:%d)" % [text, path, line]
if not path.is_empty():
return "%s (%s)" % [text, path]
return text
static func _log_entry_key(entry: Dictionary) -> String:
return "%s|%s|%s|%s" % [
str(entry.get("level", "")),
str(entry.get("text", "")),
str(entry.get("path", "")),
str(entry.get("line", 0)),
]
func _error_appended_total() -> int:
if _editor_log_buffer == null:
return 0
if _editor_log_buffer.has_method("error_appended_total"):
return int(_editor_log_buffer.call("error_appended_total"))
return 0
func _game_error_total() -> int:
if _game_log_buffer == null:
return 0
if _game_log_buffer.has_method("error_total"):
return int(_game_log_buffer.call("error_total"))
return 0
func _warn_appended_total() -> int:
if _editor_log_buffer == null:
return 0
if _editor_log_buffer.has_method("warn_appended_total"):
return int(_editor_log_buffer.call("warn_appended_total"))
return 0
func _game_warn_total() -> int:
if _game_log_buffer == null:
return 0
if _game_log_buffer.has_method("warn_total"):
return int(_game_log_buffer.call("warn_total"))
return 0
func _should_scan_debugger_for_cached_watermark(now_msec: int) -> bool:
if not _debugger_scan_active and now_msec > _debugger_scan_until_msec:
return false
return now_msec - _last_debugger_refresh_msec >= DEBUGGER_REFRESH_MIN_INTERVAL_MS
func _trim_promoted_debugger_entries() -> void:
while _promoted_debugger_entries.size() > MAX_PROMOTED_DEBUGGER_ENTRIES:
_promoted_debugger_entries.pop_front()
if _promoted_debugger_entries.is_empty():
_oldest_retained_debugger_sequence = _debugger_promoted_total + 1
else:
_oldest_retained_debugger_sequence = int(_promoted_debugger_entries[0].get("_debugger_sequence", 1))
func _trim_promoted_debugger_key_counts() -> void:
while _promoted_debugger_key_order.size() > MAX_PROMOTED_DEBUGGER_KEYS:
var key := _promoted_debugger_key_order.pop_front()
_promoted_debugger_keys.erase(key)
_promoted_debugger_row_times.erase(key)
## #635: per-row time text from a scraped Errors-tab entry (column 0 of the
## row, carried in details.time). Empty when the entry has no details — e.g.
## synthetic records — which keeps those on count-only dedup.
static func _row_time_text(entry: Dictionary) -> String:
var details: Variant = entry.get("details", {})
if details is Dictionary:
return str((details as Dictionary).get("time", ""))
return ""
func _unaccounted_row_times(key: String, times: Dictionary) -> Array:
var accounted: Dictionary = _promoted_debugger_row_times.get(key, {})
var unseen := []
for time_text in times.keys():
if not accounted.has(time_text):
unseen.append(time_text)
return unseen
func _account_row_times(key: String, times: Dictionary) -> void:
if times.is_empty():
return
var accounted: Dictionary = _promoted_debugger_row_times.get(key, {})
for time_text in times.keys():
accounted[time_text] = true
## Enforce the bound AFTER merging: a pre-merge `>` check let the set
## reach the cap and keep growing (and a batch of new times could jump
## past it). Past the cap, reset to just this scan's times — the live
## Errors tab is itself bounded, so this only fires under a pathological
## same-key flood, where "recent scan only" is an acceptable memory of
## what was promoted (worst case: a re-observed ancient row re-promotes).
if accounted.size() > MAX_ACCOUNTED_ROW_TIMES_PER_KEY:
accounted = times.duplicate()
_promoted_debugger_row_times[key] = accounted
func _remove_promoted_debugger_entry(key: String) -> void:
for i in range(_promoted_debugger_entries.size() - 1, -1, -1):
if str(_promoted_debugger_entries[i].get("_debugger_key", "")) == key:
_promoted_debugger_entries.remove_at(i)
return
func _raw_debugger_error_entries() -> Array[Dictionary]:
var entries: Array[Dictionary] = []
for tree in locate_debugger_error_trees():
entries.append_array(entries_from_debugger_error_tree(tree))
return entries
@@ -0,0 +1 @@
uid://o0ulahkt83re
@@ -0,0 +1,305 @@
@tool
class_name McpTransportCapability
extends RefCounted
## Reads Python's atomic, private capability record as one indivisible value.
## This is a static boundary, not another state owner.
const CAPABILITY_DIR_ENV := "GODOT_AI_CAPABILITY_DIR"
const RECORD_VERSION := 1
const MAX_RECORD_BYTES := 1024
const _POSIX_PERMISSION_MASK := 0x1ff ## 0777
const _GROUP_OTHER_PERMISSION_MASK := 0x3f ## 0077
const _GROUP_OTHER_WRITE_MASK := 0x12 ## 0022
const _MAX_LINK_HOPS := 8
const _SYSTEM_TEMP_ROOTS: Array[String] = ["/tmp", "/private/tmp", "/var/tmp"]
const _KEYS: Array[String] = [
"version", "http", "websocket", "instance_nonce",
]
static func read_for_http_port(http_port: int, captured_path := "") -> Dictionary:
var path := str(captured_path)
if not path.is_empty() and path.get_file() != "http-%d.json" % http_port:
return {}
return _read_path(path if not path.is_empty() else path_for_http_port(http_port))
static func _read_path(path: String) -> Dictionary:
if path.is_empty() or not path.is_absolute_path():
return {}
var resolved := _resolve_trusted_path(path)
if resolved.is_empty():
return {}
var directory := resolved.get_base_dir()
if (
not _safe_posix_ancestors(directory)
or not _private_path(directory, true)
or not _private_path(resolved, false)
):
return {}
var file := FileAccess.open(resolved, FileAccess.READ)
if file == null:
return {}
var length := file.get_length()
if length < 1 or length > MAX_RECORD_BYTES + 1:
return {}
var bytes := file.get_buffer(length)
if bytes.size() != length:
return {}
if bytes[bytes.size() - 1] == 10:
bytes.resize(bytes.size() - 1)
if bytes.is_empty() or bytes.size() > MAX_RECORD_BYTES:
return {}
for byte in bytes:
if byte > 127:
return {}
var raw := bytes.get_string_from_ascii()
## Godot resolves duplicate JSON keys. Tokens need no escapes, so reject
## escaping and require each literal key exactly once before parsing.
if raw.contains("\\"):
return {}
for key in _KEYS:
if raw.count("\"%s\"" % key) != 1:
return {}
var parsed: Variant = JSON.parse_string(raw)
if not (parsed is Dictionary) or parsed.size() != _KEYS.size():
return {}
for key in _KEYS:
if not parsed.has(key):
return {}
var version: Variant = parsed["version"]
if not (version is int or version is float) or float(version) != RECORD_VERSION:
return {}
if (
not (parsed["http"] is String)
or not (parsed["websocket"] is String)
or not (parsed["instance_nonce"] is String)
):
return {}
var http: String = parsed["http"]
var websocket: String = parsed["websocket"]
var nonce: String = parsed["instance_nonce"]
if not is_http_capability(http) or not is_lower_hex(websocket, 64):
return {}
if not is_lower_hex(nonce, 32) or http == websocket:
return {}
return {
"http": http,
"websocket": websocket,
"instance_nonce": nonce.to_lower(),
}
static func _private_path(path: String, directory: bool) -> bool:
if directory:
if DirAccess.open(path) == null:
return false
elif not FileAccess.file_exists(path):
return false
if OS.get_name() == "Windows":
## Godot's detectable link/reparse surface was checked by _path_has_link.
## v4 deliberately makes no Windows DACL secrecy/integrity claim.
return true
## Godot exposes POSIX mode bits but not the owning UID. This proves only
## that group/other access is closed; it is not an owner-identity proof.
var mode := FileAccess.get_unix_permissions(path) & _POSIX_PERMISSION_MASK
return mode != 0 and (mode & _GROUP_OTHER_PERMISSION_MASK) == 0
## Walk `path` from its root and return it with every accepted link component
## replaced by its target, or "" when the path must not be trusted. A link is
## followed only when the directory holding it is closed to group and other
## writes, so only that directory's owner or root could have placed it; ostree
## distributions need this for `/home -> /var/home` (#993). Godot exposes no
## owning UID, so this is the strongest proof available here; Python, which
## publishes the record, additionally requires such a link to be root-owned.
## The record file itself is never followed, and a chain longer than
## `_MAX_LINK_HOPS` fails closed. Windows keeps rejecting every detectable
## link or reparse point.
static func _resolve_trusted_path(path: String) -> String:
if OS.get_name() == "Windows":
return "" if _path_has_link(path) else path
var remaining := path.simplify_path().split("/", false)
var current := "/"
var hops := 0
while not remaining.is_empty():
var part := remaining[0]
remaining.remove_at(0)
var candidate := current.path_join(part)
var parent := DirAccess.open(current)
if parent == null:
return ""
if parent.is_link(candidate):
if (
remaining.is_empty()
or hops >= _MAX_LINK_HOPS
or not _closed_to_group_and_other(current)
):
return ""
hops += 1
var target := parent.read_link(candidate)
if target.is_empty():
return ""
if not target.is_absolute_path():
target = current.path_join(target)
var target_parts := target.simplify_path().split("/", false)
target_parts.append_array(remaining)
remaining = target_parts
current = "/"
continue
current = candidate
return current
static func _closed_to_group_and_other(directory: String) -> bool:
var mode := FileAccess.get_unix_permissions(directory) & _POSIX_PERMISSION_MASK
return mode != 0 and (mode & _GROUP_OTHER_WRITE_MASK) == 0
static func _path_has_link(path: String) -> bool:
var current := path
while not current.is_empty():
var parent := current.get_base_dir()
if parent.is_empty() or parent == current:
return false
var directory := DirAccess.open(parent)
if directory == null or directory.is_link(current):
return true
current = parent
return false
static func _safe_posix_ancestors(path: String) -> bool:
if OS.get_name() == "Windows":
return true
var current := path.simplify_path()
while not current.is_empty():
if DirAccess.open(current) != null:
var permissions := FileAccess.get_unix_permissions(current)
if not _safe_posix_ancestor_mode(current, permissions):
return false
var parent := current.get_base_dir()
if parent.is_empty() or parent == current:
break
current = parent
return true
static func _safe_posix_ancestor_mode(path: String, permissions: int) -> bool:
var mode := permissions & _POSIX_PERMISSION_MASK
if mode == 0:
return false
if (mode & _GROUP_OTHER_WRITE_MASK) == 0:
return true
## Godot exposes no UID. Accept the conventional system temp boundary only
## when its sticky bit is present; Python additionally proves root ownership
## before it publishes a record below the same canonical roots.
return (
path.simplify_path() in _SYSTEM_TEMP_ROOTS
and (permissions & FileAccess.UNIX_RESTRICTED_DELETE) != 0
)
static func is_http_capability(value: String) -> bool:
var bytes := value.to_ascii_buffer()
if bytes.size() < 32 or bytes.size() > 128:
return false
for byte in bytes:
if not (
(byte >= 48 and byte <= 57)
or (byte >= 65 and byte <= 90)
or (byte >= 97 and byte <= 122)
or byte in [43, 45, 46, 47, 61, 95, 126]
):
return false
return true
static func is_hex(value: String, length: int) -> bool:
if value.length() != length:
return false
for index in range(length):
var code := value.unicode_at(index)
if not (
(code >= 48 and code <= 57)
or (code >= 65 and code <= 70)
or (code >= 97 and code <= 102)
):
return false
return true
static func is_lower_hex(value: String, length: int) -> bool:
return is_hex(value, length) and value == value.to_lower()
## Windows only: "" when this account can create and write the capability
## directory, else a repair message. Python creates the directory with
## ``mode=0o700`` on POSIX, but on Windows that mode yields a DACL of SYSTEM,
## Administrators and OWNER RIGHTS alone; a directory first created by an
## elevated process is then owned by Administrators and unusable from the
## user's own unelevated editor, server and bridge (#988). Creating it here
## first inherits the per-user %LOCALAPPDATA% DACL, and probing it before the
## spawn turns a silent "proof timed out at capability_record" into a message
## that names the directory and the fix.
static func directory_write_problem(http_port: int) -> String:
if OS.get_name() != "Windows":
return ""
var record := path_for_http_port(http_port)
if record.is_empty():
return ""
return directory_write_problem_for(record.get_base_dir())
static func directory_write_problem_for(directory: String) -> String:
if DirAccess.open(directory) == null:
var created := DirAccess.make_dir_recursive_absolute(directory)
if created != OK:
return windows_repair_hint(directory, error_string(created))
var probe := directory.path_join(".access-probe-%d-%s" % [
OS.get_process_id(), Crypto.new().generate_random_bytes(4).hex_encode(),
])
var file := FileAccess.open(probe, FileAccess.WRITE)
if file == null:
return windows_repair_hint(directory, error_string(FileAccess.get_open_error()))
file.close()
DirAccess.remove_absolute(probe)
return ""
static func windows_repair_hint(directory: String, detail: String) -> String:
return (
"Godot AI cannot use the directory %s (%s): this Windows account cannot "
+ "access it. This can happen when an elevated (Run as administrator) "
+ "process created it. Check this directory's permissions and grant your "
+ "Windows account access, then reopen Godot and your AI clients without "
+ "Run as administrator."
) % [directory, detail]
static func path_for_http_port(http_port: int) -> String:
if http_port < 1 or http_port > 65535:
return ""
var override := OS.get_environment(CAPABILITY_DIR_ENV).strip_edges()
if OS.get_name() == "Windows" and not override.is_empty():
return ""
var directory := override
if directory.is_empty():
if OS.get_name() == "Windows":
directory = OS.get_environment("LOCALAPPDATA").strip_edges()
if directory.is_empty():
return ""
directory = directory.path_join("godot-ai/capabilities")
elif OS.get_name() == "macOS":
directory = OS.get_environment("HOME").path_join(
"Library/Application Support/godot-ai/capabilities"
)
else:
directory = OS.get_environment("XDG_CONFIG_HOME").strip_edges()
if directory.is_empty():
directory = OS.get_environment("HOME").path_join(".config")
directory = directory.path_join("godot-ai/capabilities")
if directory.is_empty() or not directory.is_absolute_path():
return ""
return directory.simplify_path().path_join("http-%d.json" % http_port)
@@ -0,0 +1 @@
uid://b7j8n63he8ljy
+533
View File
@@ -0,0 +1,533 @@
@tool
class_name McpUpdateManager
extends Node
## v4-only release discovery and download preparation. Python authenticates
## and extracts the three canonical assets before this manager asks the root to
## quiesce or disable anything; the transaction actor alone mutates live code.
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 REPOSITORY := "hi-godot/godot-ai"
const ASSET_NAME := "godot-ai-v4-plugin.zip"
const MANIFEST_NAME := "godot-ai-v4-plugin.manifest.json"
const SIGNATURE_NAME := "godot-ai-v4-plugin.manifest.sig"
const LEGACY_ASSET_NAME := "godot-ai-plugin.zip"
const LEGACY_CHECKSUM_NAME := "godot-ai-plugin.zip.sha256"
const LEGACY_SIGNATURE_NAME := "godot-ai-plugin.zip.sha256.sig"
const MAX_RELEASE_METADATA_BYTES := 1024 * 1024
const MAX_ARCHIVE_SIZE_BYTES := 64 * 1024 * 1024
const MAX_MANIFEST_SIZE_BYTES := 1024 * 1024
const MAX_REDIRECTS := 5
const QUALIFICATION_SWITCH_ENV := "GODOT_AI_QUALIFICATION_RELEASE"
const QUALIFICATION_URL_ENV := "GODOT_AI_QUALIFICATION_RELEASE_URL"
const QUALIFICATION_ASSET_PREFIX_ENV := "GODOT_AI_QUALIFICATION_ASSET_PREFIX"
const QUALIFICATION_TOKEN_ENV := "GODOT_AI_QUALIFICATION_TOKEN"
const _ASSET_LIMITS := {
ASSET_NAME: MAX_ARCHIVE_SIZE_BYTES,
MANIFEST_NAME: MAX_MANIFEST_SIZE_BYTES,
SIGNATURE_NAME: 512,
}
## Stable releases also carry one signed, temporary v3 migration capsule.
## v4 never downloads it, but requires the exact six-name release envelope so
## an unexpected extra executable asset remains a fail-closed condition.
const _RELEASE_ASSET_LIMITS := {
ASSET_NAME: MAX_ARCHIVE_SIZE_BYTES,
MANIFEST_NAME: MAX_MANIFEST_SIZE_BYTES,
SIGNATURE_NAME: 512,
LEGACY_ASSET_NAME: 66 * 1024 * 1024,
LEGACY_CHECKSUM_NAME: 1024,
LEGACY_SIGNATURE_NAME: 512,
}
const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd")
const TransportCapability := preload("res://addons/godot_ai/utils/transport_capability.gd")
## Kept byte-for-byte aligned with src/godot_ai/release_verify.py and the
## standalone verifier. Python performs the mandatory strict verification;
## embedding it here also lets release packaging fail on trust-anchor drift.
const RELEASE_SIGNING_PUBLIC_KEY_PEM := """-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAr4OmbONFTONGFcXSUQ2p
e54YaUhWDA75wxeDWhOc476vsdo53YnXEFT7EPr2hUKqeNxv++LqKOkFuAsxSNZy
wBe6P1tmQA4Og6Ezv4CGnZdEj1uhlDJFK9ShQ29oWfC6bf/84625SvvBxZos2Br9
yPKl7h5wzqDoeUSpv+f0ynTiC0i/HAUo/NQBlkgGwkomK2Fr3pP1VDxxq2xvgHSk
lU6Qcomr9WjJxI+HkDN5tRPPn0pDrg6YFx2J18OfD8KIa/kMGxuXOcHlPyRYpjyu
qTtg2oL0NyUIG+1TmJ3DcN4GlKC55eOrkfJ04vudS5pxdnUIFRmkGBXZLdaetoPc
ixtlD4w6gi8KIH1CTG+/TtHP1KVdOogCWDcjRCAmMJPFZe6eEKXmGQUZDb9wfnbx
h++XiVe5tq83BTLWmaFTy+fZbNo12uhNCNS1LJ42/yj+S1xvo0yMbkkNr1hIYk0P
584XnBQeBSVJDf3667NZXaxnWv94K9zbb+1OvOvPwhbOdgi2Ymcw5QEOQIavtg86
XLLcWzG+SJsycz1imikjv6sStWh8WHneKSTMq6A7V6PBj7oJyEJp10696BDw287k
YlH+9VGqowPEMXpWX57wOBKiWb4K1kw1LfxjT8W1e/pcX9pJqiv0DkjTXUxo9CDG
1X1+ZXBBR3MkGuFAOCjy0x8CAwEAAQ==
-----END PUBLIC KEY-----
"""
const _TRUSTED_PATHS := {
"github.com": "/hi-godot/godot-ai/releases/download/",
"www.github.com": "/hi-godot/godot-ai/releases/download/",
"api.github.com": "/repos/hi-godot/godot-ai/releases/assets/",
"objects.githubusercontent.com": "/github-production-release-asset-",
"release-assets.githubusercontent.com": "/github-production-release-asset-",
}
signal update_check_completed(result: Dictionary)
signal install_state_changed(state: Dictionary)
signal activation_requested(package: Dictionary)
var _check_request: HTTPRequest
var _asset_request: HTTPRequest
var _release: Dictionary = {}
var _queue: Array[String] = []
var _active_asset := ""
var _redirect_count := 0
var _qualification: Dictionary = {}
var _download_root := ""
func check_for_updates() -> void:
_release.clear()
_qualification = _qualification_from_environment()
if bool(_qualification.get("invalid", false)):
_qualification.clear()
push_error("MCP | invalid private qualification release capability")
return
if ClientConfigurator.is_dev_checkout():
return
if _check_request != null:
_check_request.queue_free()
_check_request = HTTPRequest.new()
_check_request.max_redirects = 0
_check_request.body_size_limit = MAX_RELEASE_METADATA_BYTES
_check_request.request_completed.connect(_on_check_completed)
add_child(_check_request)
var url := str(_qualification.get("release_url", RELEASES_URL))
if _check_request.request(url, _request_headers(url, _qualification, true)) != OK:
_check_request.queue_free()
_check_request = null
func start_install(preflight: Dictionary) -> void:
if is_install_in_flight():
install_state_changed.emit({
"status_text": "Update already in progress",
"button_disabled": true,
})
return
if _release.is_empty():
OS.shell_open(RELEASES_PAGE)
return
if not bool(preflight.get("ok", false)):
install_state_changed.emit({
"install_in_flight": false,
"status_text": "Update blocked — resolve recovery state",
"button_disabled": false,
})
return
var directory := str(preflight.get("download_root", ""))
if (
directory.is_empty()
or not directory.is_absolute_path()
or not _directory_is_empty(directory)
):
install_state_changed.emit({
"install_in_flight": false,
"status_text": "Update blocked — private download directory unavailable",
"button_disabled": false,
})
return
_download_root = directory
_queue.assign([ASSET_NAME, MANIFEST_NAME, SIGNATURE_NAME])
install_state_changed.emit({
"install_in_flight": true,
"status_text": "Downloading…",
"button_disabled": true,
})
_download_next()
func is_install_in_flight() -> bool:
return _asset_request != null or not _download_root.is_empty()
func has_install_candidate() -> bool:
return not is_install_in_flight() and not _release.is_empty()
static func _directory_is_empty(path: String) -> bool:
var directory := DirAccess.open(path)
if directory == null:
return false
directory.list_dir_begin()
var first := directory.get_next()
directory.list_dir_end()
return first.is_empty()
func cancel_install() -> void:
if _asset_request != null:
_asset_request.cancel_request()
_asset_request.queue_free()
_asset_request = null
_queue.clear()
_qualification.clear()
discard_downloads()
func _exit_tree() -> void:
cancel_install()
## Download roots are exclusively allocated by the Python transaction actor.
## This manager never creates, reuses, or recursively deletes a predictable
## user:// namespace; it removes only the three exact files it requested and
## then the now-empty transaction directory.
func discard_downloads() -> void:
if _download_root.is_empty():
return
for name in [ASSET_NAME, MANIFEST_NAME, SIGNATURE_NAME]:
DirAccess.remove_absolute(_download_root.path_join(name))
DirAccess.remove_absolute(_download_root)
_download_root = ""
func _download_path(name: String) -> String:
if _download_root.is_empty() or not _ASSET_LIMITS.has(name):
return ""
return _download_root.path_join(name)
static func parse_releases_response(
result: int,
response_code: int,
body: PackedByteArray,
local_version: String = "",
qualification: Dictionary = {},
) -> Dictionary:
var empty := {
"has_update": false,
"version": "",
"tag": "",
"channel": "",
"label_text": "",
"urls": {},
"sizes": {},
}
if (
result != HTTPRequest.RESULT_SUCCESS
or response_code != 200
or body.size() > MAX_RELEASE_METADATA_BYTES
):
return empty
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
if not parsed is Dictionary:
return empty
var tag := str(parsed.get("tag_name", ""))
var version := tag.trim_prefix("v")
var local := local_version if not local_version.is_empty() else ClientConfigurator.get_plugin_version()
if not tag.begins_with("v4.") or not _is_newer(version, local):
return empty
var assets: Variant = parsed.get("assets", [])
if not assets is Array or assets.size() != _RELEASE_ASSET_LIMITS.size():
return empty
var urls := {}
var sizes := {}
for value in assets:
if not value is Dictionary:
return empty
var name := str(value.get("name", ""))
if not _RELEASE_ASSET_LIMITS.has(name) or sizes.has(name):
return empty
var size := int(value.get("size", -1))
if size <= 0 or size > int(_RELEASE_ASSET_LIMITS[name]):
return empty
if name in [SIGNATURE_NAME, LEGACY_SIGNATURE_NAME] and size != 512:
return empty
var url := str(value.get("browser_download_url", ""))
if not _is_trusted_download_url(url, qualification):
return empty
sizes[name] = size
if _ASSET_LIMITS.has(name):
urls[name] = url
if urls.size() != 3:
return empty
return {
"has_update": true,
"version": version,
"tag": tag,
"channel": "stable",
"label_text": "Update available: %s" % tag,
"urls": urls,
"sizes": sizes,
}
static func _is_newer(remote: String, local: String) -> bool:
var remote_parts := _version_parts(remote)
var local_parts := _version_parts(local)
if remote_parts.is_empty() or local_parts.is_empty():
return false
for index in 3:
if remote_parts[index] != local_parts[index]:
return remote_parts[index] > local_parts[index]
return false
static func _version_parts(version: String) -> Array[int]:
var expression := RegEx.new()
if expression.compile("^4\\.(\\d+)\\.(\\d+)$") != OK:
return []
var found := expression.search(version)
if found == null:
return []
return [4, int(found.get_string(1)), int(found.get_string(2))]
static func _https_url_parts(url: String) -> Dictionary:
const SCHEME := "https://"
if not url.begins_with(SCHEME) or url.contains("\\") or url.contains("#"):
return {}
for index in url.length():
var code: int = url.unicode_at(index)
if code <= 0x20 or code == 0x7f:
return {}
var rest := url.substr(SCHEME.length())
var slash := rest.find("/")
if slash < 0:
return {}
var authority := rest.substr(0, slash)
var path := rest.substr(slash)
if authority.is_empty() or authority.contains("@") or authority.count(":") > 1:
return {}
var host := authority
var colon := authority.find(":")
if colon >= 0:
if authority.substr(colon + 1) != "443":
return {}
host = authority.substr(0, colon)
if (
host.is_empty()
or host != host.to_lower()
or host.begins_with(".")
or host.ends_with(".")
or host.contains("..")
):
return {}
for index in host.length():
var code := host.unicode_at(index)
if not (code >= 97 and code <= 122) and not (code >= 48 and code <= 57) and code not in [45, 46]:
return {}
var query := path.find("?")
if query >= 0:
path = path.substr(0, query)
var lower := path.to_lower()
for needle in ["/../", "/..", "%2e", "%2f", "%5c"]:
if lower.contains(needle):
return {}
return {"host": host, "origin": SCHEME + host, "path": path}
static func _is_trusted_download_url(url: String, qualification: Dictionary = {}) -> bool:
var parts := _https_url_parts(url)
if parts.is_empty():
return false
## Current GitHub release assets use a slash-delimited repository ID;
## retain the older CDN namespace below for existing release URLs.
if (
parts.host == "release-assets.githubusercontent.com"
and str(parts.path).begins_with("/github-production-release-asset/1208239711/")
):
return true
if (
str(parts.origin) == str(qualification.get("asset_origin", ""))
and str(parts.path).begins_with(str(qualification.get("asset_path", "")))
and not str(qualification.get("asset_path", "")).is_empty()
):
return true
return _TRUSTED_PATHS.has(parts.host) and str(parts.path).begins_with(
str(_TRUSTED_PATHS[parts.host])
)
static func _qualification_config(
enabled: String, release_url: String, asset_prefix: String, token: String
) -> Dictionary:
if enabled.is_empty() and release_url.is_empty() and asset_prefix.is_empty() and token.is_empty():
return {}
var release := _https_url_parts(release_url)
var assets := _https_url_parts(asset_prefix)
if (
enabled != "1"
or release.is_empty()
or assets.is_empty()
or not asset_prefix.ends_with("/")
or asset_prefix.contains("?")
or release.origin != assets.origin
or not TransportCapability.is_http_capability(token)
):
return {"invalid": true}
return {
"asset_origin": str(assets.origin),
"asset_path": str(assets.path),
"release_url": release_url,
"token": token,
}
static func _qualification_from_environment() -> Dictionary:
return _qualification_config(
OS.get_environment(QUALIFICATION_SWITCH_ENV),
OS.get_environment(QUALIFICATION_URL_ENV),
OS.get_environment(QUALIFICATION_ASSET_PREFIX_ENV),
OS.get_environment(QUALIFICATION_TOKEN_ENV),
)
static func _request_headers(
url: String, qualification: Dictionary, metadata: bool = false
) -> PackedStringArray:
var headers := PackedStringArray(["Accept: application/vnd.github+json"] if metadata else [])
var authorized := url == str(qualification.get("release_url", ""))
if not authorized:
var parts := _https_url_parts(url)
authorized = (
not parts.is_empty()
and parts.origin == qualification.get("asset_origin", "")
and str(parts.path).begins_with(str(qualification.get("asset_path", "")))
and not str(qualification.get("asset_path", "")).is_empty()
)
if authorized:
headers.append("Authorization: Bearer " + str(qualification.token))
return headers
static func _redirect_url(headers: PackedStringArray) -> String:
var location := ""
for header in headers:
var colon := header.find(":")
if colon < 0 or header.substr(0, colon).strip_edges().to_lower() != "location":
continue
if not location.is_empty():
return ""
location = header.substr(colon + 1).strip_edges()
return location
func _on_check_completed(
result: int,
response_code: int,
_headers: PackedStringArray,
body: PackedByteArray
) -> void:
if _check_request != null:
_check_request.queue_free()
_check_request = null
var release := parse_releases_response(result, response_code, body, "", _qualification)
if not bool(release.get("has_update", false)):
_release.clear()
_qualification.clear()
return
_release = release.duplicate(true)
update_check_completed.emit(release.duplicate(true))
func _download_next() -> void:
if _queue.is_empty():
_finish_downloads()
return
_active_asset = _queue.pop_front()
_redirect_count = 0
_request_active_asset(str((_release.get("urls", {}) as Dictionary).get(_active_asset, "")))
func _request_active_asset(url: String) -> void:
_asset_request = HTTPRequest.new()
## Follow redirects ourselves so every hop remains inside the pinned GitHub
## release/CDN namespace; HTTPRequest's automatic mode exposes no hop hook.
_asset_request.max_redirects = 0
_asset_request.body_size_limit = int((_release.get("sizes", {}) as Dictionary).get(_active_asset, 0))
_asset_request.download_file = _download_path(_active_asset)
_asset_request.request_completed.connect(_on_asset_completed)
add_child(_asset_request)
if _asset_request.request(url, _request_headers(url, _qualification)) != OK:
_fail_download("request failed")
func _on_asset_completed(
result: int,
response_code: int,
headers: PackedStringArray,
_body: PackedByteArray
) -> void:
if _asset_request != null:
_asset_request.queue_free()
_asset_request = null
## With max_redirects = 0 Godot returns REDIRECT_LIMIT_REACHED on the
## first redirect, including its status and Location. Validate that hop
## ourselves; other transport failures must still fail closed.
if (
result in [HTTPRequest.RESULT_SUCCESS, HTTPRequest.RESULT_REDIRECT_LIMIT_REACHED]
and response_code in [301, 302, 303, 307, 308]
):
var redirect := _redirect_url(headers)
DirAccess.remove_absolute(_download_path(_active_asset))
if (
_redirect_count >= MAX_REDIRECTS
or not _is_trusted_download_url(redirect, _qualification)
):
_fail_download("untrusted or excessive redirect")
return
_redirect_count += 1
_request_active_asset(redirect)
return
if result != HTTPRequest.RESULT_SUCCESS or response_code != 200:
_fail_download("download failed (%d)" % response_code)
return
var path := _download_path(_active_asset)
var file := FileAccess.open(path, FileAccess.READ)
var expected := int((_release.get("sizes", {}) as Dictionary).get(_active_asset, -1))
if file == null:
_fail_download("downloaded asset size differs from release metadata")
return
var actual_size := file.get_length()
file.close()
if actual_size != expected:
_fail_download("downloaded asset size differs from release metadata")
return
_download_next()
func _finish_downloads() -> void:
var directory := _download_root
var manifest_path := directory.path_join(MANIFEST_NAME)
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(manifest_path))
if not parsed is Dictionary or str(parsed.get("source_commit", "")).is_empty():
_fail_download("manifest is unreadable")
return
activation_requested.emit({
"archive": directory.path_join(ASSET_NAME),
"manifest": manifest_path,
"signature": directory.path_join(SIGNATURE_NAME),
"repository": REPOSITORY,
"channel": str(_release.channel),
"tag": str(_release.tag),
"version": str(_release.version),
"source": str(parsed.source_commit),
"download_root": directory,
})
_qualification.clear()
func _fail_download(reason: String) -> void:
if _asset_request != null:
_asset_request.queue_free()
_asset_request = null
_queue.clear()
_qualification.clear()
discard_downloads()
push_error("MCP | v4 update preparation failed: %s" % reason)
## The click-time lock and any quiesced client work are the plugin's to
## release, and it only does so when it hears the install is over.
install_state_changed.emit({
"install_in_flight": false,
"status_text": "Update preparation failed",
"button_disabled": false,
})
@@ -0,0 +1 @@
uid://cegiyw3fjcwev
+141
View File
@@ -0,0 +1,141 @@
@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]
var child_path := current.path_join(entry["name"])
if entry["is_dir"] and not d.is_link(child_path):
stack.append(child_path)
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
+161
View File
@@ -0,0 +1,161 @@
@tool
class_name McpUvCacheCleanup
extends RefCounted
## Sweeps stale `.tmp*` build venvs out of `%LOCALAPPDATA%\uv\cache\builds-v0`.
##
## Background
## ----------
## When an MCP client's attach launcher invokes
## `uvx --from godot-ai==VERSION godot-ai attach ...`, 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,97 @@
@tool
extends RefCounted
## One policy for every production uvx launch. Package identity JSON is a
## compatibility check, not authentication: production code is resolved only
## from the canonical PyPI index, with config files and installed tool envs
## disabled. The explicit process-local qualification switch is the sole path
## that leaves index selection to the qualification environment.
const PathTemplate := preload("res://addons/godot_ai/clients/_path_template.gd")
const PUBLIC_INDEX := "https://pypi.org/simple"
const PUBLIC_FLAT_INDEX := "https://pypi.org/simple/godot-ai/"
const QUALIFICATION_INDEX_ENV := "GODOT_AI_QUALIFICATION_PYTHON_INDEX"
const _BASE_ARGS := [
"--isolated",
"--no-config",
"--no-env-file",
"--no-sources",
"--no-build",
"--index-strategy", "first-index",
"--keyring-provider", "disabled",
]
## Known uv inputs capable of changing package source, candidate selection,
## interpreter, or cache. Production processes spawned by Godot temporarily
## clear them under the global process-spawn mutex; command-line policy remains
## pinned as a second boundary and for client-owned attach launches.
const _RESOLUTION_ENVIRONMENT := [
"UV_INDEX", "UV_DEFAULT_INDEX", "UV_INDEX_URL", "UV_EXTRA_INDEX_URL",
"UV_FIND_LINKS", "UV_INDEX_STRATEGY", "UV_KEYRING_PROVIDER",
"UV_CONFIG_FILE", "UV_CONSTRAINT", "UV_BUILD_CONSTRAINT", "UV_OVERRIDE",
"UV_NO_CONFIG", "UV_NO_SOURCES", "UV_NO_BUILD", "UV_NO_BINARY",
"UV_NO_BINARY_PACKAGE", "UV_NO_VERIFY_HASHES", "UV_PRERELEASE",
"UV_RESOLUTION", "UV_FORK_STRATEGY", "UV_EXCLUDE_NEWER",
"UV_EXCLUDE_NEWER_PACKAGE", "UV_INSECURE_HOST", "UV_SYSTEM_CERTS",
"UV_NATIVE_TLS", "UV_PYTHON", "UV_PYTHON_DOWNLOADS", "UV_PROJECT",
"UV_WORKING_DIR", "UV_ENV_FILE", "UV_CACHE_DIR", "UV_TOOL_DIR",
"UV_TOOL_BIN_DIR", "UV_LINK_MODE", "UV_REFRESH", "UV_REFRESH_PACKAGE",
"UV_REINSTALL", "UV_REINSTALL_PACKAGE",
]
static func warm_environment() -> void:
PathTemplate.warm_env_snapshot(PackedStringArray([QUALIFICATION_INDEX_ENV]))
static func qualification_authorized() -> bool:
return PathTemplate.env_lookup(QUALIFICATION_INDEX_ENV) == "1"
static func args() -> Array[String]:
var result: Array[String] = []
result.assign(_BASE_ARGS)
if not qualification_authorized():
result.append_array([
"--index", PUBLIC_INDEX,
"--default-index", PUBLIC_INDEX,
"--find-links", PUBLIC_FLAT_INDEX,
])
return result
static func is_production_command(command: Array) -> bool:
if command.is_empty() or not str(command[0]).get_file().begins_with("uvx"):
return false
return (
_option_equals(command, "--index", PUBLIC_INDEX)
and _option_equals(command, "--default-index", PUBLIC_INDEX)
and _option_equals(command, "--find-links", PUBLIC_FLAT_INDEX)
)
## Call only while the process-spawn mutex is held. Qualification commands do
## not reach this function: their explicitly authorized UV_INDEX and credentials
## must remain process-local and are never rendered into persisted argv.
static func isolate_environment() -> Dictionary:
var previous := {}
for key in _RESOLUTION_ENVIRONMENT:
previous[key] = {
"present": OS.has_environment(key),
"value": OS.get_environment(key),
}
OS.unset_environment(key)
return previous
static func restore_environment(previous: Dictionary) -> void:
for key in previous:
if bool(previous[key].present):
OS.set_environment(key, str(previous[key].value))
else:
OS.unset_environment(key)
static func _option_equals(command: Array, option: String, expected: String) -> bool:
var index := command.find(option)
return index >= 0 and index + 1 < command.size() and str(command[index + 1]) == expected
@@ -0,0 +1 @@
uid://b3y73hby0e7j8
+102
View File
@@ -0,0 +1,102 @@
@tool
extends RefCounted
## Converts Godot Variants into values that can be encoded as JSON.
## Non-finite floats (NaN/INF) have no JSON representation: JSON.stringify
## emits them as the bare tokens `inf`/`nan`, which are invalid JSON — the
## server drops the whole frame and the pending request times out (#688).
## Serialize them as null instead (the same choice web JSON.stringify makes),
## applied uniformly across the v4 support floor — no version gate, so wire
## output is identical on every supported engine.
static func _safe_float(f: float) -> Variant:
return f if is_finite(f) else null
static func serialize(value: Variant) -> Variant:
if value == null:
return null
match typeof(value):
TYPE_BOOL, TYPE_INT, TYPE_STRING:
return value
TYPE_FLOAT:
return _safe_float(value)
TYPE_STRING_NAME:
return str(value)
# Integer vector types are listed separately from their float twins so
# int components stay ints on the wire (no float coercion via
# _safe_float's typed parameter).
TYPE_VECTOR2I:
return {"x": value.x, "y": value.y}
TYPE_VECTOR2:
return {"x": _safe_float(value.x), "y": _safe_float(value.y)}
TYPE_VECTOR3I:
return {"x": value.x, "y": value.y, "z": value.z}
TYPE_VECTOR3:
return {"x": _safe_float(value.x), "y": _safe_float(value.y), "z": _safe_float(value.z)}
TYPE_VECTOR4I:
return {"x": value.x, "y": value.y, "z": value.z, "w": value.w}
TYPE_VECTOR4, TYPE_QUATERNION:
return {
"x": _safe_float(value.x),
"y": _safe_float(value.y),
"z": _safe_float(value.z),
"w": _safe_float(value.w),
}
TYPE_COLOR:
return {
"r": _safe_float(value.r),
"g": _safe_float(value.g),
"b": _safe_float(value.b),
"a": _safe_float(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": _safe_float(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