Files
Brachistochrone/addons/godot_ai/clients/_json_strategy.gd
T
scorpion21211-cmd e24bbbda5d Initial commit
2026-10-03 23:29:41 -04:00

978 lines
42 KiB
GDScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@tool
class_name McpJsonStrategy
extends RefCounted
## Read–merge–write strategy for JSON-backed MCP clients.
## All knobs come from the McpClient descriptor as plain data — no Callables.
## See `_base.gd` for why descriptors are data-only.
static func configure(
client: McpClient,
server_name: String,
server_url: String,
launch: Dictionary = {},
project_roots: PackedStringArray = PackedStringArray(),
) -> Dictionary:
if _uses_merge_tiers(client):
return _configure_merged(client, server_name, server_url, launch, project_roots)
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if not path_error.is_empty():
return {"status": "error", "message": path_error}
if path.is_empty():
return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name}
var seed_path := str(resolution.get("seed_path", ""))
var read_path := seed_path if not FileAccess.file_exists(path) and not seed_path.is_empty() else path
var read := _read_or_init(read_path)
if not read["ok"]:
return {"status": "error", "message": "Refusing to overwrite %s: %s. Fix or move the file, then re-run Configure." % [read_path, read["error"]]}
var launch_error := command_launch_error(client, launch)
if not launch_error.is_empty():
return {"status": "error", "message": launch_error}
var config: Dictionary = read["data"]
var holder := _ensure_path(config, select_server_key_path(config, client))
## Pass the existing entry through so `build_entry` can preserve user-mutable
## state (auto-approval lists, `disabled` toggles) instead of resetting it
## to descriptor defaults on every Configure click. See `entry_initial_fields`
## docs in `_base.gd`.
var existing: Variant = holder.get(server_name, null)
holder[server_name] = build_entry(client, server_url, existing, launch)
# F5: refuse to re-serialize a file whose parsed integers above 2^53 would
# lose precision. Editing by hand keeps those values intact.
if _has_lossy_numbers(config):
return {"status": "error", "message": "Refusing to rewrite %s: contains integers above 2^53 that Godot's JSON parser would re-emit imprecise. Edit the file by hand." % path}
if not McpAtomicWrite.write(path, JSON.stringify(_narrow_integral_numbers(config), "\t", false)):
return {"status": "error", "message": "Cannot write to %s" % path}
return {"status": "ok", "message": McpClient.configured_message(client, server_url)}
## Pi-style clients merge several global config files. Update the effective
## highest-precedence definition; fail closed when a project override exists
## because Pi's external working directory cannot be inferred safely here.
static func _configure_merged(
client: McpClient,
server_name: String,
server_url: String,
launch: Dictionary,
project_roots: PackedStringArray,
) -> Dictionary:
var launch_error := command_launch_error(client, launch)
if not launch_error.is_empty():
return {"status": "error", "message": launch_error}
var project := _load_project_definitions(client, server_name, project_roots)
if not project.get("ok", false):
return {"status": "error", "message": str(project.get("error", "Cannot inspect project config tiers"))}
var project_tiers: Array = project.get("tiers", [])
if not project_tiers.is_empty():
return {"status": "error", "message": _project_override_message(project_tiers, "update or remove", client.display_name, server_name)}
var loaded := _load_merge_tiers(client)
if not loaded.get("ok", false):
return {"status": "error", "message": str(loaded.get("error", "Cannot read merged config tiers"))}
var tiers: Array = loaded.get("tiers", [])
if tiers.is_empty():
return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name}
var target_index := 0
for index in range(tiers.size()):
var config: Dictionary = tiers[index]["data"]
var holder := _walk_path(config, select_server_key_path(config, client))
if holder is Dictionary and holder.has(server_name):
target_index = index
var tier: Dictionary = tiers[target_index]
var config: Dictionary = tier["data"]
var holder := _ensure_path(config, select_server_key_path(config, client))
var existing: Variant = holder.get(server_name, null)
holder[server_name] = build_entry(client, server_url, existing, launch)
var path := str(tier["path"])
# F5: refuse to re-serialize a tier whose parsed integers above 2^53 would
# lose precision. The same check guards the simple `configure` path.
if _has_lossy_numbers(config):
return {"status": "error", "message": "Refusing to rewrite %s: contains integers above 2^53 that Godot's JSON parser would re-emit imprecise. Edit the file by hand." % path}
if not McpAtomicWrite.write(path, JSON.stringify(_narrow_integral_numbers(config), "\t", false)):
return {"status": "error", "message": "Cannot write to %s" % path}
# Codex F1 caveat: when we wrote a global tier but couldn't find any project
# override in the roots we probed AND the user hasn't told us where Pi (or any
# external cwd client) actually runs from, we can't prove this write is the
# effective config. Surface the actionable hint instead of silently passing.
# The early `not project_tiers.is_empty()` return above already covers the
# "project override exists in probed roots" case (we fail closed there), so
# reaching this point with `project_tiers.is_empty()` means: no probed
# override was found.
var message := McpClient.configured_message(client, server_url)
var external_cwd := str(McpClientConfigurator._editor_setting_lookup(McpClientConfigurator.SETTING_EXTERNAL_CLIENT_CWD))
if external_cwd.is_empty() and project_tiers.is_empty():
message += " If %s is launched from a cwd this editor cannot see, the effective entry may still be in a project-tier file — set godot_ai/external_client_cwd to that cwd to verify." % client.display_name
return {"status": "ok", "message": message}
static func check_status(
client: McpClient,
server_name: String,
server_url: String,
launch: Dictionary = {},
project_roots: PackedStringArray = PackedStringArray(),
) -> McpClient.Status:
return check_status_details(client, server_name, server_url, launch, project_roots).get("status", McpClient.Status.NOT_CONFIGURED)
## Detailed variant feeding the dock's error_msg plumbing (#711): a config
## file that EXISTS but can't be read or parsed is Status.ERROR carrying the
## read/parse error, not NOT_CONFIGURED — the write path refuses to touch
## such a file (see `_read_or_init`), so the status dot must say "broken
## file", not "click Configure".
static func check_status_details(
client: McpClient,
server_name: String,
server_url: String,
launch: Dictionary = {},
project_roots: PackedStringArray = PackedStringArray(),
) -> Dictionary:
if _uses_merge_tiers(client):
return _check_status_merged(client, server_name, server_url, launch, project_roots)
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if not path_error.is_empty():
return {"status": McpClient.Status.ERROR, "error_msg": path_error}
if path.is_empty() or not FileAccess.file_exists(path):
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
var read := _read_or_init(path)
if not read["ok"]:
return {"status": McpClient.Status.ERROR, "error_msg": String(read["error"])}
var config: Dictionary = read["data"]
var holder := _walk_path(config, select_server_key_path(config, client))
if not (holder is Dictionary) or not holder.has(server_name):
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
return _entry_status_details(client, holder[server_name], server_url, launch)
## Verify the effective last definition after applying the client's merge order.
static func _check_status_merged(
client: McpClient,
server_name: String,
server_url: String,
launch: Dictionary,
project_roots: PackedStringArray,
) -> Dictionary:
var loaded := _load_merge_tiers(client)
if not loaded.get("ok", false):
return {"status": McpClient.Status.ERROR, "error_msg": str(loaded.get("error", "Cannot read merged config tiers"))}
var effective: Variant = null
for tier in loaded.get("tiers", []):
var config: Dictionary = tier["data"]
var holder := _walk_path(config, select_server_key_path(config, client))
if holder is Dictionary and holder.has(server_name):
effective = holder[server_name]
var project := _load_project_definitions(client, server_name, project_roots)
if not project.get("ok", false):
return {"status": McpClient.Status.ERROR, "error_msg": str(project.get("error", "Cannot inspect project config tiers"))}
var project_tiers: Array = project.get("tiers", [])
if not project_tiers.is_empty():
# Last-definition-wins mirrors the global-tier fold above and how
# pi-codemode-mcp merges project tiers on disk. Earlier tiers are dead
# once a later one defines the same server, so an early-stale entry
# doesn't make Pi's effective config drift (codex-review finding F2).
# Pass `[latest]` to `_project_override_message` so the error names only
# the file the user actually has to edit.
var latest: Dictionary = project_tiers[project_tiers.size() - 1]
var details := _entry_status_details(client, latest["entry"], server_url, launch)
if details.get("status") != McpClient.Status.CONFIGURED:
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": _project_override_message([latest], "update or remove", client.display_name, server_name)}
return {"status": McpClient.Status.CONFIGURED, "error_msg": ""}
if effective == null:
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
return _entry_status_details(client, effective, server_url, launch)
static func _entry_status_details(
client: McpClient,
entry: Variant,
server_url: String,
launch: Dictionary,
) -> Dictionary:
if not (entry is Dictionary):
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
var launch_error := command_launch_error(client, launch)
if not launch_error.is_empty():
return {"status": McpClient.Status.ERROR, "error_msg": launch_error}
if verify_entry(client, entry, server_url, launch):
return {"status": McpClient.Status.CONFIGURED, "error_msg": ""}
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""}
static func remove(
client: McpClient,
server_name: String,
project_roots: PackedStringArray = PackedStringArray(),
) -> Dictionary:
if _uses_merge_tiers(client):
return _remove_merged(client, server_name, project_roots)
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if not path_error.is_empty():
return {"status": "error", "message": path_error}
if path.is_empty() or not FileAccess.file_exists(path):
return {"status": "ok", "message": "Not configured"}
# Token-preserving removal (F5): pull the original file text via
# `_read_file_text` and edit only the entry's bytes — keeps every other
# byte, including integers above 2^53, byte-for-byte identical to what
# the user wrote. The parsed-dict round-trip path is gone.
var file_read := _read_file_text(path)
if not file_read.get("ok", false):
return {"status": "error", "message": "Refusing to rewrite %s: %s." % [path, file_read.get("error", "")]}
var config: Dictionary = file_read.get("data", {})
var key_path := select_server_key_path(config, client)
var original_text: String = str(file_read.get("original_text", ""))
var updated: String = _text_remove_server_entry(original_text, key_path, server_name)
if updated == original_text:
return {"status": "ok", "message": "Not configured"}
if not McpAtomicWrite.write(path, updated):
return {"status": "error", "message": "Cannot write to %s" % path}
return {"status": "ok", "message": "%s configuration removed" % client.display_name}
static func _remove_merged(
client: McpClient, server_name: String, project_roots: PackedStringArray
) -> Dictionary:
var project := _load_project_definitions(client, server_name, project_roots)
if not project.get("ok", false):
return {"status": "error", "message": str(project.get("error", "Cannot inspect project config tiers"))}
var project_tiers: Array = project.get("tiers", [])
if not project_tiers.is_empty():
return {"status": "error", "message": _project_override_message(project_tiers, "remove", client.display_name, server_name)}
var loaded := _load_merge_tiers(client)
if not loaded.get("ok", false):
return {"status": "error", "message": str(loaded.get("error", "Cannot read merged config tiers"))}
var writes: Array[Dictionary] = []
for tier in loaded.get("tiers", []):
if not tier.get("exists", false):
continue
var key_path := select_server_key_path(tier["data"], client)
var original: String = str(tier.get("original_text", ""))
# Token-preserving removal (F5): edit `original` textually instead of
# re-serialising the parsed config. This preserves every byte outside
# the entry block, so unrelated integers (including those above the
# IEEE-754 exact-int range that `_narrow_integral_numbers` cannot
# round-trip) keep their original literals.
var updated: String = _text_remove_server_entry(original, key_path, server_name)
if updated == original:
continue # entry not in this tier; skip
writes.append({
"path": tier["path"],
"text": updated,
"original_text": original,
})
if writes.is_empty():
return {"status": "ok", "message": "Not configured"}
var written := _write_transaction(writes)
if not written.get("ok", false):
return {"status": "error", "message": written.get("error", "Cannot remove merged configuration")}
return {"status": "ok", "message": "%s configuration removed" % client.display_name}
## Synthesize the entry dict the strategy writes under
## `server_key_path[server_name]`. Both URL and command entries deep-copy the
## existing dict before overwriting strategy-owned fields, preserving unknown
## client additions as well as descriptor-documented user fields.
static func build_entry(
client: McpClient,
server_url: String,
existing: Variant = null,
launch: Dictionary = {},
) -> Dictionary:
if _is_supported_command_shape(client.command_shape):
var command_entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {}
if client.command_shape == McpClient.CommandShape.COMMAND_ARRAY:
## OpenCode-style: the entry's `command` field IS the argv array.
## A stale sibling `args` from a FLAT-style hand edit would be
## ambiguous next to it, so it is strategy-owned and removed.
command_entry["command"] = _launch_argv(launch)
command_entry.erase("args")
else:
command_entry["command"] = str(launch.get("command", ""))
command_entry["args"] = _array_copy(launch.get("args", []))
if not client.command_transport_key.is_empty():
command_entry[client.command_transport_key] = client.command_transport_value
for key in client.command_initial_fields:
if not command_entry.has(key):
command_entry[key] = client.command_initial_fields[key]
for key in client.command_legacy_keys:
command_entry.erase(String(key))
_remove_legacy_env_keys(command_entry, client.command_env_legacy_keys)
return command_entry
if client.command_shape != McpClient.CommandShape.NONE:
return {}
return build_url_entry(client, server_url, existing)
static func build_url_entry(client: McpClient, server_url: String, existing: Variant = null) -> Dictionary:
var entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {}
entry[client.entry_url_field] = server_url
for k in client.entry_extra_fields:
entry[k] = client.entry_extra_fields[k]
for k in client.entry_initial_fields:
if not entry.has(k):
entry[k] = client.entry_initial_fields[k]
return entry
## Default verifier for a stored entry. Command entries must match every
## launch-affecting value exactly; legacy URL or env keys are migration drift.
## For URL clients, assert `entry[entry_url_field] == url` AND every
## key in `entry_extra_fields` matches verbatim. Type-pinning for Cline /
## Roo / Kilo (`type: "streamable-http"` etc.) falls out of this — pre-fix
## entries that lack the type field fail verification and surface as drift.
static func verify_entry(
client: McpClient,
entry: Dictionary,
server_url: String,
launch: Dictionary = {},
) -> bool:
if client.command_shape != McpClient.CommandShape.NONE:
if not _is_supported_command_shape(client.command_shape) or not bool(launch.get("ok", false)):
return false
for key in client.command_legacy_keys:
if entry.has(String(key)):
return false
var env = entry.get("env", null)
if env is Dictionary:
for key in client.command_env_legacy_keys:
if env.has(String(key)):
return false
if client.command_shape == McpClient.CommandShape.COMMAND_ARRAY:
if not _arrays_equal(entry.get("command", null), _launch_argv(launch)):
return false
if entry.has("args"):
return false
else:
if entry.get("command") != launch.get("command"):
return false
if not _arrays_equal(entry.get("args", null), launch.get("args", null)):
return false
if not client.command_transport_key.is_empty():
if not entry.has(client.command_transport_key):
return false
if entry.get(client.command_transport_key) != client.command_transport_value:
return false
return true
if entry.get(client.entry_url_field, "") != server_url:
return false
for k in client.entry_extra_fields:
if entry.get(k) != client.entry_extra_fields[k]:
return false
return true
static func command_launch_error(client: McpClient, launch: Dictionary) -> String:
if client.command_shape == McpClient.CommandShape.NONE:
return ""
if not _is_supported_command_shape(client.command_shape):
return "%s uses a command shape not supported by JSON yet" % client.display_name
if not bool(launch.get("ok", false)):
return str(launch.get("error", "No compatible attach launcher was found."))
return ""
static func _is_supported_command_shape(shape: McpClient.CommandShape) -> bool:
return shape == McpClient.CommandShape.FLAT or shape == McpClient.CommandShape.COMMAND_ARRAY
## The full launch argv as one array: launcher path followed by every arg.
static func _launch_argv(launch: Dictionary) -> Array:
var argv: Array = [str(launch.get("command", ""))]
argv.append_array(_array_copy(launch.get("args", [])))
return argv
static func _remove_legacy_env_keys(entry: Dictionary, legacy_keys: PackedStringArray) -> void:
if legacy_keys.is_empty():
return
var existing_env = entry.get("env", null)
if not (existing_env is Dictionary):
return
var env: Dictionary = (existing_env as Dictionary).duplicate(true)
for key in legacy_keys:
env.erase(String(key))
if env.is_empty():
entry.erase("env")
else:
entry["env"] = env
static func _array_copy(value: Variant) -> Array:
if value is Array:
return (value as Array).duplicate(true)
if value is PackedStringArray:
return McpClient._array_from_packed(value)
return []
static func _arrays_equal(left: Variant, right: Variant) -> bool:
if not (left is Array or left is PackedStringArray):
return false
if not (right is Array or right is PackedStringArray):
return false
var left_array := _array_copy(left)
var right_array := _array_copy(right)
if left_array.size() != right_array.size():
return false
for i in range(left_array.size()):
if left_array[i] != right_array[i]:
return false
return true
## Read a config file once and parse the captured text. Returns
## {"exists": bool, "ok": bool, "data": Dictionary, "original_text": String}
## when the file is absent or parses cleanly, and
## {"exists": true, "ok": false, "error": String, "original_text": String}
## when the file exists with non-empty content we cannot safely round-trip.
## Callers must NOT treat the error path as an empty config — doing so blows
## away the user's other MCP entries on the next write. The `original_text`
## is the exact captured source so transactional rollback can restore
## byte-for-byte; the UTF-8 BOM is stripped only from the parsing copy.
static func _read_file_text(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
return {"exists": false, "ok": true, "data": {}, "original_text": ""}
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
var open_err := FileAccess.get_open_error()
return {"exists": true, "ok": false, "error": "could not open for reading (error %d)" % open_err, "original_text": ""}
var content := file.get_as_text()
file.close()
if content.strip_edges().is_empty():
return {"exists": true, "ok": true, "data": {}, "original_text": content}
var parse_copy := content
# Strip a UTF-8 BOM if present — some editors (notably on Windows) save
# JSON with a leading , which Godot's JSON.parse rejects outright.
# Previously this landed on the "unparseable → wipe" path.
if parse_copy.begins_with(""):
parse_copy = parse_copy.substr(1)
var json := JSON.new()
if json.parse(parse_copy) != OK:
var msg := "JSON parse error on line %d: %s" % [json.get_error_line(), json.get_error_message()]
push_warning("MCP | %s in %s" % [msg, path])
return {"exists": true, "ok": false, "error": msg, "original_text": content}
if not (json.data is Dictionary):
return {"exists": true, "ok": false, "error": "top-level value is %s, expected object" % type_string(typeof(json.data)), "original_text": content}
return {"exists": true, "ok": true, "data": json.data, "original_text": content}
## Returns {"ok": true, "data": Dictionary} when the file is absent or parses
## cleanly, and {"ok": false, "error": String} when the file exists with
## non-empty content we cannot safely round-trip. Callers must NOT fall back
## to an empty dict on the error path — doing so blows away the user's other
## MCP entries on the next write.
static func _read_or_init(path: String) -> Dictionary:
var read := _read_file_text(path)
var result: Dictionary = {"ok": read.get("ok", false), "data": read.get("data", {})}
if not result.get("ok", false):
result["error"] = read.get("error", "")
return result
## Walk a key path, creating intermediate Dicts as needed. Returns the leaf Dict.
static func _ensure_path(root: Dictionary, key_path: PackedStringArray) -> Dictionary:
var cur := root
for key in key_path:
var next = cur.get(key)
if not (next is Dictionary):
next = {}
cur[key] = next
cur = next
return cur
## Walk a key path, returning the leaf Dict if all hops exist; else null.
static func _walk_path(root: Dictionary, key_path: PackedStringArray) -> Variant:
var cur: Variant = root
for key in key_path:
if not (cur is Dictionary) or not cur.has(key):
return null
cur = cur[key]
return cur
## Match clients that accept multiple server-map keys without creating a
## higher-precedence canonical map that shadows an existing legacy map. A
## non-null scalar still wins, matching nullish-coalescing parsers; Configure's
## `_ensure_path` then repairs it using the strategy's existing behavior.
static func select_server_key_path(root: Dictionary, client: McpClient) -> PackedStringArray:
var candidates: Array[PackedStringArray] = [client.server_key_path]
# Dynamic access keeps mixed-snapshot self-updates parse-safe when a new
# strategy is briefly loaded with an older McpClient base (#398/#736).
var aliases = client.get("server_key_path_aliases")
if aliases is Array:
for alias in aliases:
if alias is PackedStringArray:
candidates.append(alias)
for key_path in candidates:
if _walk_path(root, key_path) != null:
return key_path
return client.server_key_path
static func _uses_merge_tiers(client: McpClient) -> bool:
var templates = client.get("config_merge_path_templates")
return templates is Dictionary and not templates.is_empty()
## Resolve the global config tiers in the client's documented merge order.
static func _merge_paths(client: McpClient) -> PackedStringArray:
var result := PackedStringArray()
var templates = client.get("config_merge_path_templates")
if templates is Dictionary:
var platform_key := McpPathTemplate.platform_key(templates)
if not platform_key.is_empty():
var raw_paths: Variant = templates.get(platform_key, [])
if raw_paths is Array or raw_paths is PackedStringArray:
for template in raw_paths:
var path := McpPathTemplate.expand(str(template)).simplify_path()
if not path.is_empty() and not result.has(path):
result.append(path)
return result
## Pi's project tiers are relative to Pi's process cwd, not Godot's. Inspect
## plausible roots only to detect overrides; callers fail closed rather than
## mutating both guesses.
static func _project_candidate_paths(
client: McpClient, roots: PackedStringArray
) -> PackedStringArray:
var result := PackedStringArray()
var project_paths = client.get("config_merge_project_paths")
if not (project_paths is PackedStringArray):
return result
for project_path in project_paths:
var absolute_path := String(project_path).simplify_path()
if absolute_path.is_absolute_path() and FileAccess.file_exists(absolute_path) and not result.has(absolute_path):
result.append(absolute_path)
for root in roots:
if String(root).is_empty():
continue
for relative_path in project_paths:
if String(relative_path).is_absolute_path():
continue
var path := String(root).path_join(String(relative_path)).simplify_path()
if FileAccess.file_exists(path) and not result.has(path):
result.append(path)
return result
static func _load_project_definitions(
client: McpClient, server_name: String, project_roots: PackedStringArray
) -> Dictionary:
var tiers: Array[Dictionary] = []
for path in _project_candidate_paths(client, project_roots):
var read := _read_or_init(path)
if not read.get("ok", false):
return {"ok": false, "error": "Cannot inspect project config %s: %s" % [path, read.get("error", "invalid JSON")]}
var config: Dictionary = read["data"]
var key_path := select_server_key_path(config, client)
var holder := _walk_path(config, key_path)
if holder is Dictionary and holder.has(server_name):
tiers.append({
"path": path,
"data": config,
"key_path": key_path,
"entry": holder[server_name],
})
return {"ok": true, "tiers": tiers}
static func _project_override_message(
tiers: Array, action: String, client_name: String, server_name: String
) -> String:
var paths := PackedStringArray()
for tier in tiers:
paths.append(str(tier["path"]))
return "%s project config overrides %s at %s. %s resolves project files from its own working directory, so the dock cannot safely choose one; %s the entry manually." % [client_name, server_name, ", ".join(paths), client_name, action]
static func _load_merge_tiers(client: McpClient) -> Dictionary:
var tiers: Array[Dictionary] = []
for path in _merge_paths(client):
var read := _read_file_text(path)
if not read.get("ok", false):
return {
"ok": false,
"error": "Refusing to rewrite %s: %s. Fix or move the file, then re-run Configure." % [path, read.get("error", "invalid JSON")],
}
tiers.append({
"path": path,
"exists": read.get("exists", false),
"original_text": read.get("original_text", ""),
"data": read.get("data", {}),
})
return {"ok": true, "tiers": tiers}
## Commit a multi-file removal with best-effort rollback. Each individual write
## is atomic; this layer restores earlier tiers if a later commit fails.
static func _write_transaction(writes: Array[Dictionary]) -> Dictionary:
var completed: Array[Dictionary] = []
for write in writes:
var path := str(write["path"])
if McpAtomicWrite.write(path, str(write["text"])):
completed.append(write)
continue
var rollback_failed := PackedStringArray()
completed.reverse()
for previous in completed:
if not McpAtomicWrite.write(str(previous["path"]), str(previous["original_text"])):
rollback_failed.append(str(previous["path"]))
var suffix := ""
if not rollback_failed.is_empty():
suffix = " Rollback also failed for: %s" % ", ".join(rollback_failed)
return {"ok": false, "error": "Cannot write to %s; earlier tier changes were rolled back.%s" % [path, suffix]}
return {"ok": true}
## Path that `_check_status_merged`'s last-wins logic would consider
## authoritative for the server entry, or "" when no entry is found in
## any tier. Mirrors the same project-tier / global-tier resolution the
## status check uses so callers (notably the dock Open/Reveal buttons)
## land on the file that actually drives Pi's effective config.
##
## Resolution order (codex round 3, F-3-4):
## 1. Latest project tier containing the entry — F2 last-wins means the
## latest of `.pi/mcp.json` and `.mcp.json` wins (matches `_check_status_merged`).
## 2. Latest global tier containing the entry — already iterated in
## merge order, last-iterated-wins (same loop pattern as
## `manual_target_details` lines 660-665).
## 3. "" when no tier has the entry — caller decides what fallback
## (`path_template`) to use.
##
## Returns "" (not `path_template`) on the "nothing anywhere" branch so
## the dock can distinguish "no entry yet" from "entry is at the
## template path" if it wants to.
static func authoritative_tier_path(
client: McpClient,
server_name: String,
project_roots: PackedStringArray,
) -> String:
if not _uses_merge_tiers(client):
return ""
var project := _load_project_definitions(client, server_name, project_roots)
if not bool(project.get("ok", false)):
return ""
var project_tiers: Array = project.get("tiers", [])
if not project_tiers.is_empty():
# F2 last-wins: latest project tier is authoritative. When there are
# multiple project tiers, the user-visible status is driven by the
# latest, so the Open/Reveal buttons should send them there too.
var latest: Dictionary = project_tiers[project_tiers.size() - 1]
return str(latest.get("path", ""))
var loaded := _load_merge_tiers(client)
if not bool(loaded.get("ok", false)):
return ""
var tiers: Array = loaded.get("tiers", [])
# Iterate in priority order, overwrite `selected_path` each time we
# find a tier containing the entry. With Pi's merge path order
# [mcp.json, .mcp.json] this leaves the higher-precedence `.mcp.json`.
var selected_path: String = ""
for tier in tiers:
var data: Dictionary = tier.get("data", {})
var holder: Variant = _walk_path(data, select_server_key_path(data, client))
if holder is Dictionary and holder.has(server_name):
selected_path = str(tier.get("path", ""))
return selected_path
## Pick the file and top-level map shown by the manual JSON instructions using
## the same merge and alias precedence as automatic Configure.
static func manual_target_details(
client: McpClient,
server_name: String,
fallback_path: String,
project_roots: PackedStringArray = PackedStringArray(),
) -> Dictionary:
if _uses_merge_tiers(client):
var project := _load_project_definitions(client, server_name, project_roots)
if not project.get("ok", false):
return {"ok": false, "error": project.get("error", "Cannot inspect project config tiers")}
var project_tiers: Array = project.get("tiers", [])
if project_tiers.size() > 1:
return {"ok": false, "error": _project_override_message(project_tiers, "update or remove", client.display_name, server_name)}
if project_tiers.size() == 1:
var selected_project: Dictionary = project_tiers[0]
return {
"ok": true,
"path": selected_project["path"],
"key_path": selected_project["key_path"],
}
var loaded := _load_merge_tiers(client)
if not loaded.get("ok", false):
return {"ok": false, "error": loaded.get("error", "Cannot read merged config tiers")}
var tiers: Array = loaded.get("tiers", [])
var selected: Variant = tiers[0] if not tiers.is_empty() else null
for tier in tiers:
var config: Dictionary = tier["data"]
var holder := _walk_path(config, select_server_key_path(config, client))
if holder is Dictionary and holder.has(server_name):
selected = tier
if selected != null:
var config: Dictionary = selected["data"]
return {
"ok": true,
"path": selected["path"],
"key_path": select_server_key_path(config, client),
}
var read := _read_or_init(fallback_path)
if not read.get("ok", false):
return {"ok": false, "error": "Cannot inspect %s: %s" % [fallback_path, read.get("error", "invalid JSON")]}
return {
"ok": true,
"path": fallback_path,
"key_path": select_server_key_path(read["data"], client),
}
## Godot's JSON.parse turns every JSON number into a float, so a later
## JSON.stringify re-emits the user's integer fields as "8080.0" — which strict
## consumers (Go's encoding/json into an int field, etc.) reject, and which
## needlessly rewrites every number across the user's *other* entries. Re-narrow
## exactly-representable integral floats back to int so they serialize without
## the ".0". Walks dicts/arrays in place and returns the (same) value.
##
## Integers above 2^53 already lost precision when Godot parsed them to double,
## so they're left as the float Godot produced rather than faking exactness —
## byte-perfect preservation would require not parsing the file at all, and such
## magnitudes don't occur in MCP client configs.
static func _narrow_integral_numbers(value: Variant) -> Variant:
match typeof(value):
TYPE_FLOAT:
if is_finite(value) and value == floor(value) and absf(value) <= 9007199254740992.0:
return int(value)
TYPE_DICTIONARY:
for k in value:
value[k] = _narrow_integral_numbers(value[k])
TYPE_ARRAY:
for i in value.size():
value[i] = _narrow_integral_numbers(value[i])
return value
## True when `value` contains any float outside the IEEE-754 exact-int range
## (`abs(f) > 2^53`) that would lose precision when JSON.stringify re-emits it.
## The configure path refuses such files instead of silently mutating unrelated
## integers; the remove path never needs this check because it does
## token-preserving surgery (F5).
static func _has_lossy_numbers(value: Variant) -> bool:
match typeof(value):
TYPE_FLOAT:
# `>=` not `>`: `2^53 + 1` rounds down to `2^53.0` when Godot parses
# the integer to double, so even a "clean" `2^53.0` after parse may
# have lost a least-significant bit we cannot recover. Refusing at
# the boundary is conservative but correct — and 2^53+ magnitudes
# don't appear in real MCP client configs anyway.
if is_finite(value) and absf(value) >= 9007199254740992.0:
return true
TYPE_DICTIONARY:
for k in value:
if _has_lossy_numbers(value[k]):
return true
TYPE_ARRAY:
for i in value.size():
if _has_lossy_numbers(value[i]):
return true
return false
## Token-preserving entry removal. Walks the JSON text with a brace-balanced
## scanner to locate the entry at `key_path[0]/.../[server_name]` and returns
## `text` with that entry's bytes deleted (along with the surrounding
## whitespace + trailing comma). Everything else — including integers above
## the IEEE-754 exact-int range that `_narrow_integral_numbers` would silently
## mutate — is preserved byte-for-byte. Returns `text` unchanged when the
## entry isn't found in `text` (callers treat this as a no-op write).
static func _text_remove_server_entry(text: String, key_path: PackedStringArray, server_name: String) -> String:
# Descend through `key_path`. Each step finds `"<key>":` directly inside the
# current container and moves the cursor past the value's opening `{` or `[`.
# After the loop, `cursor` is positioned just past the opening bracket of the
# container that holds our entry, and `container_depth` tracks how deeply
# nested that container is from the root (0 = root object/array).
var cursor := 0
# Skip past the root's opening `{` or `[` (and any leading whitespace or
# UTF-8 BOM) so `_find_key_at_container_depth` starts scanning from
# inside the root container with `inner_depth == 0`. Without this step,
# the first character encountered is the root's `{`, which bumps
# `inner_depth` to 1 and makes the top-level key check fail. The BOM
# skip mirrors `_read_file_text`'s parse_copy BOM-strip above so the
# scanner sees the same view of the file the parser does; the BOM
# itself stays in `text` so the byte-survival F5 contract still holds
# (codex round 3, F-3-6 — without it, files saved with a Windows BOM
# left the entry in place after Remove).
while cursor < text.length() and _is_json_ws(text[cursor]):
cursor += 1
if cursor < text.length() and text[cursor] == "":
cursor += 1
if cursor < text.length() and (text[cursor] == "{" or text[cursor] == "["):
cursor += 1
var container_depth := 0
for key in key_path:
var key_bytes := JSON.stringify(String(key))
var match := _find_key_at_container_depth(text, cursor, key_bytes)
if match.is_empty():
return text
var open_char := text[match["value_start"]]
if open_char != "{" and open_char != "[":
return text # leaf isn't an object/array — path can't reach our entry
container_depth += 1
cursor = match["value_start"] + 1
# Now find `"<server_name>":` at the top level of the container we descended
# into (the helper gates on `inner_depth == 0`).
var entry_bytes := JSON.stringify(server_name)
var entry_match := _find_key_at_container_depth(text, cursor, entry_bytes)
if entry_match.is_empty():
return text
var key_start: int = entry_match["key_start"]
var value_end: int = entry_match["value_end"]
# Trim the surrounding comma in a mutually exclusive way: the trailing comma
# (after the entry's value) carries the burden when the entry is NOT the last
# in its container, and the leading comma (before the entry's key) carries it
# only when the entry IS the last. Trimming BOTH unconditionally corrupts a
# middle-position entry into `"x"}"y"` with no separator — codex-review
# finding F5 (regression after the initial round).
var remove_end := value_end
# Skip JSON whitespace before checking for the trailing comma — valid
# (if unusual) formats like `"godot-ai":{} ,"other":{}` put whitespace
# between the value and its separator. codex round 4 finding.
var trailing_scan := remove_end
while trailing_scan < text.length() and _is_json_ws(text[trailing_scan]):
trailing_scan += 1
var had_trailing_comma := trailing_scan < text.length() and text[trailing_scan] == ","
if had_trailing_comma:
# Jump past the comma (and any whitespace before it we just skipped).
remove_end = trailing_scan + 1
while remove_end < text.length() and _is_json_ws(text[remove_end]):
remove_end += 1
var remove_start := key_start
if not had_trailing_comma:
while remove_start > 0 and _is_json_ws(text[remove_start - 1]):
remove_start -= 1
if remove_start > 0 and text[remove_start - 1] == ",":
remove_start -= 1
while remove_start > 0 and _is_json_ws(text[remove_start - 1]):
remove_start -= 1
return text.substr(0, remove_start) + text.substr(remove_end)
## Look for `key_bytes` (a JSON-encoded string literal including the surrounding
## quotes) at the TOP LEVEL of the container we're inside, scanning `text` from
## `start` onwards. The caller is expected to advance `start` just past the
## container's opening `{` or `[`, so we begin with `inner_depth == 0` and a
## key matches when we hit it while `inner_depth` is still 0. The scan exits
## immediately if `inner_depth` drops below 0 (the target container closed)
## to prevent same-named keys in later sibling containers from being picked
## up. Returns `{key_start, value_start, value_end}` on success or `{}` if
## the key isn't present in that container.
static func _find_key_at_container_depth(text: String, start: int, key_bytes: String) -> Dictionary:
var i := start
var in_string := false
var escape := false
var inner_depth := 0 # depth *inside* the current container
while i < text.length():
var c := text[i]
if in_string:
if escape:
escape = false
elif c == "\\":
escape = true
elif c == '"':
in_string = false
i += 1
continue
if c == '"':
if text.substr(i, key_bytes.length()) == key_bytes:
var after_key := i + key_bytes.length()
while after_key < text.length() and _is_json_ws(text[after_key]):
after_key += 1
if after_key < text.length() and text[after_key] == ":":
var value_start := after_key + 1
while value_start < text.length() and _is_json_ws(text[value_start]):
value_start += 1
var value_end := _json_value_span_end(text, value_start)
if value_end >= 0 and inner_depth == 0:
return {"key_start": i, "value_start": value_start, "value_end": value_end}
in_string = true
i += 1
continue
if c == "{" or c == "[":
inner_depth += 1
elif c == "}" or c == "]":
inner_depth -= 1
# Bail out the moment we leave the container we're scanning. Without
# this guard, a later sibling container at the same level (e.g. a
# top-level `extensions` block after `mcpServers` closes) re-balances
# `inner_depth` back to 0 and a same-named key in that sibling would
# match — silently deleting data we never intended to touch
# (codex-review finding F5, regression after the initial round).
if inner_depth < 0:
return {}
i += 1
return {}
## Given `value_start` pointing at the first char of a JSON value, return the
## position AFTER the value's last char. Handles objects, arrays, strings,
## and bare literals (numbers, true/false/null). Returns -1 if the value is
## unterminated.
static func _json_value_span_end(text: String, value_start: int) -> int:
if value_start >= text.length():
return -1
var c := text[value_start]
if c == "{" or c == "[":
var open_char := c
var close_char := "}" if c == "{" else "]"
var depth := 1
var i := value_start + 1
var in_string := false
var escape := false
while i < text.length() and depth > 0:
var ch := text[i]
if in_string:
if escape:
escape = false
elif ch == "\\":
escape = true
elif ch == '"':
in_string = false
i += 1
continue
if ch == '"':
in_string = true
elif ch == open_char:
depth += 1
elif ch == close_char:
depth -= 1
i += 1
return i if depth == 0 else -1
if c == '"':
var i := value_start + 1
var escape := false
while i < text.length():
var ch := text[i]
if escape:
escape = false
elif ch == "\\":
escape = true
elif ch == '"':
return i + 1
i += 1
return -1
# Bare literal: number / true / false / null. Walk until a JSON-significant
# delimiter (`,`, `}`, `]`, or whitespace).
var i := value_start
while i < text.length():
var ch := text[i]
if ch == "," or ch == "}" or ch == "]" or _is_json_ws(ch):
break
i += 1
return i
static func _is_json_ws(c: String) -> bool:
return c == " " or c == "\t" or c == "\n" or c == "\r"