Files
tekton/addons/godot_ai/clients/_json_strategy.gd
T

264 lines
11 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
## Readmergewrite 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) -> Dictionary:
var path := client.resolved_config_path()
if path.is_empty():
return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name}
var read := _read_or_init(path)
if not read["ok"]:
return {"status": "error", "message": "Refusing to overwrite %s: %s. Fix or move the file, then re-run Configure." % [path, read["error"]]}
var config: Dictionary = read["data"]
var holder := _ensure_path(config, client.server_key_path)
## 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)
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": "%s configured (HTTP: %s)" % [client.display_name, server_url]}
static func check_status(client: McpClient, server_name: String, server_url: String) -> McpClient.Status:
var path := client.resolved_config_path()
if path.is_empty() or not FileAccess.file_exists(path):
return McpClient.Status.NOT_CONFIGURED
var read := _read_or_init(path)
if not read["ok"]:
return McpClient.Status.NOT_CONFIGURED
var config: Dictionary = read["data"]
var holder := _walk_path(config, client.server_key_path)
if not (holder is Dictionary) or not holder.has(server_name):
return McpClient.Status.NOT_CONFIGURED
var entry = holder[server_name]
if not (entry is Dictionary):
return McpClient.Status.NOT_CONFIGURED
## An entry under `server_name` exists — if the URL doesn't match,
## that's drift (the user changed the port and the client config is stale),
## not "never configured". The dock surfaces that as an amber banner.
return McpClient.Status.CONFIGURED if verify_entry(client, entry, server_url) else McpClient.Status.CONFIGURED_MISMATCH
static func remove(client: McpClient, server_name: String) -> Dictionary:
var path := client.resolved_config_path()
if path.is_empty() or not FileAccess.file_exists(path):
return {"status": "ok", "message": "Not configured"}
var read := _read_or_init(path)
if not read["ok"]:
return {"status": "error", "message": "Refusing to rewrite %s: %s." % [path, read["error"]]}
var config: Dictionary = read["data"]
var holder := _walk_path(config, client.server_key_path)
if holder is Dictionary and holder.has(server_name):
holder.erase(server_name)
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": "%s configuration removed" % client.display_name}
## Synthesize the entry dict the strategy will write under
## `server_key_path[server_name]`. For non-bridge clients this is the
## existing entry (if any) with `entry_url_field` + every
## `entry_extra_fields` key force-set (the verified type pins) and every
## `entry_initial_fields` key set ONLY when absent (preserves user state
## like `alwaysAllow`/`autoApprove` arrays). For bridge clients (Claude
## Desktop) it composes the uvx + mcp-proxy command shape unconditionally
## — the bridge form has no user-mutable surface.
static func build_entry(client: McpClient, server_url: String, existing: Variant = null) -> Dictionary:
match client.entry_uvx_bridge:
McpClient.UvxBridge.FLAT:
return {
"command": McpClient.resolve_uvx_path(),
"args": McpClient.mcp_proxy_bridge_args(server_url),
"env": _merge_bridge_env(existing),
}
var entry: Dictionary = (existing as Dictionary).duplicate() 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. For bridge clients, recognise the
## bridge form (and, for `flat`, the future url-style form too — keeps the
## tolerance Claude Desktop has had since the npx-bridge migration).
##
## For non-bridge 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) -> bool:
match client.entry_uvx_bridge:
McpClient.UvxBridge.FLAT:
# Future url-style entry: accept if Claude Desktop ever speaks HTTP natively.
if entry.get(client.entry_url_field, "") == server_url:
return true
var cmd = entry.get("command", "")
if not (cmd is String and _command_is_uvx_like(cmd as String)):
return false
if not _bridge_args_are_valid(entry.get("args", []), server_url):
return false
return _bridge_env_matches(entry)
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
## Pre-fix entries lack `env.UV_LINK_MODE=copy` and hit the Windows uvx
## hard-link race documented in `utils/uv_cache_cleanup.gd`. Flag them as
## drift so the dock surfaces an amber banner and a Configure-click
## rewrites the entry with the env pin. Every key in `bridge_env_for_uvx()`
## must match verbatim — extra user keys are tolerated so a hand-added
## `PYTHONUNBUFFERED=1` etc. doesn't trigger drift forever.
static func _bridge_env_matches(entry: Dictionary) -> bool:
var env = entry.get("env", null)
if not (env is Dictionary):
return false
var pin := McpClient.bridge_env_for_uvx()
for k in pin:
if env.get(k) != pin[k]:
return false
return true
## Configure rewrites the bridge entry wholesale (the bridge form is
## identity-defined by command+args+env), but the verifier tolerates extra
## user-added env keys like `HTTP_PROXY` / `PYTHONUNBUFFERED`. Without
## merging, a Configure click on a CONFIGURED_MISMATCH entry would silently
## drop those keys — so layer the UV_LINK_MODE pin over whatever env block
## already exists on disk. New entries with no prior env get just the pin.
static func _merge_bridge_env(existing: Variant) -> Dictionary:
var pin := McpClient.bridge_env_for_uvx()
if not (existing is Dictionary):
return pin
var existing_env = (existing as Dictionary).get("env", null)
if not (existing_env is Dictionary):
return pin
var merged: Dictionary = (existing_env as Dictionary).duplicate()
for k in pin:
merged[k] = pin[k]
return merged
## Basename match for `uvx` / `uvx.exe`, accepting both the bare-name
## fallback and an absolute path resolved by `McpCliFinder`. Used by the
## FLAT bridge verifier — the only place we ever inspect a stored bridge
## command/path.
static func _command_is_uvx_like(cmd: String) -> bool:
var basename := cmd.get_file()
return basename == "uvx" or basename == "uvx.exe"
## Strict bridge-argv check: the args array must include the pinned
## `mcp-proxy` package spec, the `--transport streamablehttp` selector, and
## the expected URL. Pre-fix `args.has(url)` was lenient — entries with the
## wrong transport (`--transport sse`) or a different package would still
## verify CONFIGURED, hiding the broken bridge. Match `mcp-proxy` by prefix
## so a future MCP_PROXY_VERSION bump doesn't churn the verifier.
static func _bridge_args_are_valid(args: Variant, server_url: String) -> bool:
if not (args is Array):
return false
var has_mcp_proxy := false
for a in args:
if a is String and (a as String).begins_with("mcp-proxy"):
has_mcp_proxy = true
break
if not has_mcp_proxy:
return false
if not (args.has("--transport") and args.has("streamablehttp") and args.has(server_url)):
return false
return true
## 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:
if not FileAccess.file_exists(path):
return {"ok": true, "data": {}}
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
var err := FileAccess.get_open_error()
return {"ok": false, "error": "could not open for reading (error %d)" % err}
var content := file.get_as_text()
file.close()
# 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 content.begins_with(""):
content = content.substr(1)
if content.strip_edges().is_empty():
return {"ok": true, "data": {}}
var json := JSON.new()
if json.parse(content) != 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 {"ok": false, "error": msg}
if not (json.data is Dictionary):
return {"ok": false, "error": "top-level value is %s, expected object" % type_string(typeof(json.data))}
return {"ok": true, "data": json.data}
## 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
## 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