@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 = {}, ) -> Dictionary: 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, 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, launch) 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)} static func check_status( client: McpClient, server_name: String, server_url: String, launch: Dictionary = {}, ) -> McpClient.Status: return check_status_details(client, server_name, server_url, launch).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 = {}, ) -> Dictionary: 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, client.server_key_path) if not (holder is Dictionary) or not holder.has(server_name): return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""} var entry = holder[server_name] 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} ## 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. 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) -> Dictionary: 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"} 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 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 ## 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