@tool class_name McpDshStrategy extends RefCounted ## Configure / status / remove for DeepSeek Harness (dsh) MCP servers. ## ## DeepSeek Harness has no `mcp` CLI verb (verified against dsh ## 0.1.0-rc.6 — `dsh --help` exposes only `web` and `plugin`). MCP servers ## register as `@deepseek-ai/dsh-mcp-client` plugin entries in the HOME patch ## layer `$DSH_HOME/cordis.patch.yml` — the machine-local layer dsh applies ## over EVERY profile's own patch layer (the web GUI included), so one entry ## covers every profile the user boots. ## ## The patch file is a top-level YAML array of loader patch entries ## (`@deepseek-ai/cordis-plugin-include`'s `applyEntryPatches`). A non-insert ## row only OVERRIDES an existing bundle row (a new id is skipped with a ## warning), so a brand-new server must be added with an `insert` row: ## ## - insert: ## - id: mcp-godot-ai ## name: '@deepseek-ai/dsh-mcp-client' ## config: ## serverName: godot-ai ## transport: stdio ## command: "..." ## args: [...] ## ## Both shapes verified live against the dsh loader ## (`dsh --profile web --dump-config` composes the inserted entry; a plain ## row for a new id warns "patch: entry not found" and is skipped). We write ## one dedicated insert row per server, preserve every other row byte-for-byte ## (including other users' insert rows, overrides, comments, and `!!js` ## expressions), and status-verify the entry we wrote — the same file-read ## drift contract the JSON/TOML/YAML strategies use. ## ## The entry's `config` nests the launch under `serverName`/`transport`/ ## `command`/`args`; `serverName` is the model-facing tool namespace ## (`mcp__godot-ai__`), so it carries the server name like the ## `server_key_path` map key does for the other strategies. ## Loader entry id prefix inside the patch list. The full id is ## `` — "mcp-godot-ai" for the godot-ai server. const ENTRY_ID_PREFIX := "mcp-" ## The loader plugin that bridges MCP servers into the harness. Must be ## installed in the profile's node_modules (dsh ships it as a dependency of ## the CLI package). Quoted in the file: `@` is a reserved YAML indicator and ## cannot start a plain scalar. const PLUGIN_NAME := "@deepseek-ai/dsh-mcp-client" ## Indent of a nested entry header under `- insert:` (children step 2 spaces ## deeper), matching the shipped profile patch files. const ENTRY_INDENT := 4 static func entry_id(server_name: String) -> String: return ENTRY_ID_PREFIX + server_name 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} ## Fail closed before touching the file — same contract as JSON/TOML/YAML. var launch_error := command_launch_error(client, launch) if not launch_error.is_empty(): return {"status": "error", "message": launch_error} var read := _read(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"]]} ## Fail closed on content that is not a loader patch list: dsh's patch ## parser rejects a non-array file at boot, so appending our row to a ## top-level mapping would hand the user a broken file (#867 review). ## `[]` is a valid empty sequence meaning "no rows"; the literal must not ## survive an append (block rows after `[]` are malformed YAML), so only ## that line is dropped — comments around it belong to the user and stay. var text := String(read["data"]) text = _strip_empty_flow_sequence(text) if not _is_patch_sequence(text): return { "status": "error", "message": "Refusing to overwrite %s: it is not a top-level YAML list of loader entries. Fix or remove the file, then re-run Configure." % path, } var existing_config: Variant = null var lines := _split_lines(text) var found := _find_entry_block(lines, entry_id(server_name), true) if found.is_empty(): var plain := _find_entry_block(lines, entry_id(server_name), false) if not plain.is_empty(): ## A plain `- id: mcp-godot-ai` row is inert for a new id — the ## loader skips it with a warning and never registers the server. ## Replacing the whole row with the insert form migrates it. found = plain if not found.is_empty(): ## Pass the parsed config through so user-mutable fields (env, ## toolCallTimeoutMs, reconnect) survive a reconfigure. existing_config = _extract_config(lines, found) var new_entry := build_entry(client, server_name, server_url, existing_config, launch) ## Nested replacement must keep the sequence's existing header indent — ## items of one block sequence share indentation, so a hand-written ## 2-space file re-emitted at 4 spaces would no longer parse (#867 ## review). Fresh rows and plain-row migrations keep the 4-space style. var base_indent := ENTRY_INDENT if not found.is_empty(): var header_indent := int(found.get("indent", 0)) if header_indent > 0: base_indent = header_indent var rendered := render_nested_entry(entry_id(server_name), new_entry, base_indent) var out := "" if found.is_empty(): out = _append_row(lines, rendered) else: out = _replace_lines(lines, found, rendered) if not McpAtomicWrite.write(path, out): 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)["status"] ## Same contract as the other strategies (#711): {status, error_msg}. An ## existing-but-unreadable config is ERROR with the diagnostic — not ## NOT_CONFIGURED — so the dock row can tell "no config" from "config the ## editor can't read" instead of offering a Configure that would fail. 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(path) if not read["ok"]: return { "status": McpClient.Status.ERROR, "error_msg": "Cannot read %s: %s" % [path, read["error"]], } var text := String(read["data"]) if not _is_patch_sequence(text): ## A top-level mapping/scalar is not a dsh patch list: nothing in it ## can be "configured", and a nested `- id:` inside a mapping must ## not read as ours (#867 review). return { "status": McpClient.Status.ERROR, "error_msg": "%s is not a top-level YAML list of loader entries; fix or remove the file." % path, } var lines := _split_lines(text) var found := _find_entry_block(lines, entry_id(server_name), true) if found.is_empty(): ## A plain row for our id is inert (the loader skips unknown ids), so ## the server is NOT registered no matter what the file says. It only ## becomes registered once Configure rewrites it as an insert row. return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""} var config := _extract_config(lines, found) if config.is_empty(): return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""} ## An entry exists but no verified launcher does — mirror the other ## strategies: this is an environment ERROR, not entry drift. 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, config, server_name, 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(path) if not read["ok"]: return {"status": "error", "message": "Refusing to rewrite %s: %s." % [path, read["error"]]} var text := String(read["data"]) if not _is_patch_sequence(text): ## Same fail-closed contract as configure (#867 review): never modify ## a file that dsh cannot load as a patch list. return { "status": "error", "message": "Refusing to rewrite %s: it is not a top-level YAML list of loader entries. Fix or remove the file, then re-run Remove." % path, } var lines := _split_lines(text) var found := _find_entry_block(lines, entry_id(server_name), true) if found.is_empty(): found = _find_entry_block(lines, entry_id(server_name), false) if found.is_empty(): return {"status": "ok", "message": "%s configuration removed" % client.display_name} var out := _remove_lines(lines, found) if _is_blank_or_comment_only(out): ## dsh's patch parser requires a top-level YAML array: an empty or ## comment-only patch file FAILS BOOT (`loadOptionalPatches` throws on ## a non-array file), while an absent file means "no layer". After our ## row is gone, an all-blank file must therefore be deleted, not ## written back. if FileAccess.file_exists(path): var remove_err := DirAccess.remove_absolute(path) if remove_err != OK: return {"status": "error", "message": "Cannot remove %s (error %d)" % [path, remove_err]} return {"status": "ok", "message": "%s configuration removed" % client.display_name} if not McpAtomicWrite.write(path, out): return {"status": "error", "message": "Cannot write to %s" % path} return {"status": "ok", "message": "%s configuration removed" % client.display_name} ## Build the `config` dict written inside the loader entry. Command mode ## (FLAT, #838) nests `serverName`/`transport`/`command`/`args`; URL mode nests ## `serverName`/`transport`/`url`. Existing user state (env, timeouts, ## reconnect) survives a reconfigure via the deep copy. static func build_entry( client: McpClient, server_name: String, server_url: String, existing: Variant = null, launch: Dictionary = {}, ) -> Dictionary: var entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {} entry["serverName"] = server_name if client.command_shape == McpClient.CommandShape.FLAT: entry["command"] = str(launch.get("command", "")) entry["args"] = _array_copy(launch.get("args", [])) if not client.command_transport_key.is_empty(): entry[client.command_transport_key] = client.command_transport_value for key in client.command_initial_fields: if not entry.has(key): entry[key] = client.command_initial_fields[key] for key in client.command_legacy_keys: entry.erase(String(key)) return entry if client.command_shape == McpClient.CommandShape.NONE: ## Single URL-mode source of truth (#867 review): build_entry's URL ## branch delegates to build_url_entry so the two can never drift. return build_url_entry(client, server_name, server_url, existing) return {} ## URL-mode sibling for the manual-instruction fallback text and the ## build_entry URL branch. DeepSeek Harness' mcp-client bridge accepts ## `transport: streamable-http` with a `url`, so the entry keeps the same ## `serverName` namespace with an HTTP transport instead of the stdio attach ## bridge. Existing user state survives via the deep copy. static func build_url_entry( client: McpClient, server_name: String, server_url: String, existing: Variant = null ) -> Dictionary: var entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {} entry["serverName"] = server_name entry["transport"] = "streamable-http" if not client.entry_url_field.is_empty(): entry[client.entry_url_field] = server_url return entry ## Verify a stored `config` dict matches the current launch. Command mode ## checks every launch-affecting value exactly and requires legacy URL keys ## gone; serverName must match the pinned namespace. URL mode checks the url ## and the streamable-http pin. static func verify_entry( client: McpClient, config: Dictionary, server_name: String, server_url: String, launch: Dictionary = {}, ) -> bool: if config.get("serverName") != server_name: return false if client.command_shape != McpClient.CommandShape.NONE: if client.command_shape != McpClient.CommandShape.FLAT or not bool(launch.get("ok", false)): return false for key in client.command_legacy_keys: if config.has(String(key)): return false if config.get("command") != launch.get("command"): return false if not _arrays_equal(config.get("args", null), launch.get("args", null)): return false if not client.command_transport_key.is_empty(): if config.get(client.command_transport_key, null) != client.command_transport_value: return false return true if config.get(client.entry_url_field, "") != server_url: return false return config.get("transport", "") == "streamable-http" ## Empty string when this client's launch requirements are satisfied. ## Mirrors `McpYamlStrategy.command_launch_error`; the patch-list dialect ## supports FLAT (and the URL fallback) only. static func command_launch_error(client: McpClient, launch: Dictionary) -> String: if client.command_shape == McpClient.CommandShape.NONE: return "" if client.command_shape != McpClient.CommandShape.FLAT: return "%s uses a command shape not supported by DeepSeek Harness config yet" % client.display_name if not bool(launch.get("ok", false)): return str(launch.get("error", "No compatible attach launcher was found.")) return "" # --- Rendering -------------------------------------------------------------- ## Render a full `- insert:` row containing one nested loader entry. Public ## seam for the dock's manual-instruction text so the pasted YAML matches ## what Configure would write byte-for-byte. static func render_insert_row(entry_id_value: String, config: Dictionary) -> PackedStringArray: var lines: PackedStringArray = ["- insert:"] lines.append_array(render_nested_entry(entry_id_value, config)) return lines ## Render one nested loader entry (the `- id:` block inside an `insert` ## list). `base_indent` defaults to the 4-space style used by the shipped ## patch files; a nested replacement reuses the indent the existing entry was ## found at so the block sequence keeps a single indentation (#867 review). static func render_nested_entry( entry_id_value: String, config: Dictionary, base_indent: int = ENTRY_INDENT ) -> PackedStringArray: var lines: PackedStringArray = [] lines.append("%s- id: %s" % [_indent_str(base_indent), entry_id_value]) lines.append("%sname: '%s'" % [_indent_str(base_indent + 2), PLUGIN_NAME]) lines.append("%sconfig:" % _indent_str(base_indent + 2)) for key in config: lines.append_array(_emit_field(String(key), config[key], base_indent + 4)) return lines ## Emit one `key: value` line (or a nested block for Dictionary values) at ## the given indent. Arrays render in flow style; scalars through the shared ## YAML emitter so quoting rules stay identical across the two YAML dialects. static func _emit_field(key: String, value: Variant, indent: int) -> PackedStringArray: var lines: PackedStringArray = [] var prefix := _indent_str(indent) if value is Dictionary: lines.append("%s%s:" % [prefix, key]) for sub_key in value: lines.append_array(_emit_field(String(sub_key), value[sub_key], indent + 2)) elif value is Array or value is PackedStringArray: lines.append("%s%s: %s" % [prefix, key, McpYamlStrategy.emit_flow_array(_array_copy(value))]) else: lines.append("%s%s: %s" % [prefix, key, McpYamlStrategy.emit_scalar(value)]) return lines # --- Line-level parsing (no YAML parser in Godot) --------------------------- ## Split file text into lines, dropping only a single trailing newline so the ## remainder round-trips byte-for-byte. `text` is never split on the empty ## string, so an empty file yields an empty array. static func _split_lines(text: String) -> PackedStringArray: if text.is_empty(): return PackedStringArray() var lines := text.split("\n") if lines.size() > 0 and String(lines[lines.size() - 1]).is_empty(): lines.remove_at(lines.size() - 1) return lines ## Locate the block owned by our loader entry: ## require_nested=true — the `- id: ` line inside an `insert` ## list (indent > 0), plus its indented children. ## require_nested=false — a plain top-level `- id: ` row (indent ## 0), plus its indented children. ## Returns {"start": int, "end": int, "indent": int} (end exclusive) or {} ## when absent. The header indent is detected dynamically so a hand-written ## file using non-2-space indentation is still found; children are lines ## deeper than the header. `end` stops right AFTER the last content line: ## trailing blank and comment lines between our entry and the next sibling ## belong to the file, not to our block, so `_replace_lines` / `_remove_lines` ## (which splice [start, end)) never delete them (#867 review). static func _find_entry_block(lines: PackedStringArray, entry_id_value: String, require_nested: bool) -> Dictionary: for i in range(lines.size()): var indent := _indent_of(lines[i]) if require_nested and indent <= 0: continue if not require_nested and indent != 0: continue var parsed := _parse_entry_header(lines[i]) if parsed.is_empty() or parsed.get("id") != entry_id_value: continue ## A nested match must actually live inside an `- insert:` row: a ## `- id:` list under some other top-level row (an override row's own ## sequence, say) is not a loader entry and is not ours to touch. if require_nested and not _under_insert_row(lines, i): continue var j := i + 1 var last_content := i while j < lines.size(): var l := lines[j] if _is_blank_or_comment(l): j += 1 continue if _indent_of(l) <= indent: break last_content = j j += 1 return {"start": i, "end": last_content + 1, "indent": indent} return {} ## True when the nearest preceding 0-indent content line above `index` is an ## `- insert:` row header. Blank and comment lines are skipped; hitting the ## top of the file without a header means the nested line is orphaned and ## cannot be a loader entry. static func _under_insert_row(lines: PackedStringArray, index: int) -> bool: for i in range(index - 1, -1, -1): if _is_blank_or_comment(lines[i]): continue if _indent_of(lines[i]) == 0: return lines[i].strip_edges().begins_with("- insert:") return false ## Parse a `- id: ` header line into {"id": String}. Returns {} for ## any other line. The value may be plain or single/double-quoted. static func _parse_entry_header(line: String) -> Dictionary: var stripped := line.strip_edges() if not stripped.begins_with("- "): return {} var body := stripped.substr(2).strip_edges() if not body.begins_with("id:"): return {} var value := body.substr(3).strip_edges() if value.begins_with("\"") and value.ends_with("\"") and value.length() >= 2: var parsed: Variant = JSON.parse_string(value) if parsed is String: value = parsed elif value.begins_with("'") and value.ends_with("'") and value.length() >= 2: value = value.substr(1, value.length() - 2) return {"id": value} ## Extract the `config:` sub-dict from a found entry block. Fields may nest ## (env, reconnect); deeper values are parsed with the same scalar coercion ## the YAML strategy uses. Returns {} when the block has no config block. static func _extract_config(lines: PackedStringArray, block: Dictionary) -> Dictionary: var start := int(block.get("start", -1)) var end := int(block.get("end", -1)) if start < 0: return {} var config_indent := -1 var config_start := -1 for i in range(start, end): var stripped := lines[i].strip_edges() if _is_blank_or_comment(lines[i]): continue if stripped == "config:": config_indent = _indent_of(lines[i]) config_start = i break if config_start < 0: return {} return _parse_block_lines(lines, config_start + 1, end, config_indent) ## Parse sibling `key: value` lines at a fixed minimum indent into a ## Dictionary. Nested blocks (a key with no inline value) recurse and their ## child lines are consumed by the recursion — the parent index advances past ## them so they are never re-parsed as top-level config fields (#867 review). ## Flow arrays parse back into Arrays via JSON. Quoted and plain scalars ## strip via the YAML strategy's shared `coerce_scalar`. static func _parse_block_lines(lines: PackedStringArray, start: int, end: int, min_indent: int) -> Dictionary: var out: Dictionary = {} var i := start while i < end: var l := lines[i] if _is_blank_or_comment(l): i += 1 continue var indent := _indent_of(l) if indent <= min_indent: break var stripped := l.strip_edges() var colon := stripped.find(":") if colon < 0: i += 1 continue var key := stripped.substr(0, colon).strip_edges() var value := stripped.substr(colon + 1).strip_edges() if value.is_empty(): ## Nested block: find where its children end (a line at or above ## the block key's indent), parse only that span, and jump past it. var child_end := i + 1 while child_end < end: if not _is_blank_or_comment(lines[child_end]) and _indent_of(lines[child_end]) <= indent: break child_end += 1 out[key] = _parse_block_lines(lines, i + 1, child_end, indent) i = child_end else: ## Shared with the YAML strategy so the two dialects can never ## drift on quoting/typing rules (#867 review). out[key] = McpYamlStrategy.coerce_scalar(value) i += 1 return out # --- Rewrites --------------------------------------------------------------- ## Append a new `- insert:` row at the end of the file. A blank separator ## line keeps rows visually distinct; the file always ends with a newline. static func _append_row(lines: PackedStringArray, nested_lines: PackedStringArray) -> String: var parts: Array[String] = [] for l in lines: parts.append(l) ## Collapse trailing blank lines so the appended row is not orphaned ## below stray empties; one separator blank keeps rows readable. while parts.size() > 0 and String(parts[parts.size() - 1]).strip_edges().is_empty(): parts.remove_at(parts.size() - 1) if parts.size() > 0: parts.append("") parts.append("- insert:") for l in nested_lines: parts.append(l) return _join_lines(parts) ## Replace the lines owned by our entry. When the found block is a plain ## top-level row (indent 0 header), the whole row — including its children — ## is replaced by the insert form. When it is a nested block inside an ## insert row, only that block's lines are replaced, leaving sibling entries ## and comments in the same row byte-for-byte intact. static func _replace_lines(lines: PackedStringArray, block: Dictionary, nested_lines: PackedStringArray) -> String: var start := int(block.get("start", -1)) var end := int(block.get("end", -1)) var parts: Array[String] = [] for i in range(lines.size()): if i == start: ## A nested block keeps the surrounding `- insert:` row, so only ## the nested entry lines (indent > 0) are spliced in. if _indent_of(lines[start]) > 0: for l in nested_lines: parts.append(l) else: parts.append("- insert:") for l in nested_lines: parts.append(l) if i >= start and i < end: continue parts.append(lines[i]) return _join_lines(parts) ## Remove our entry. A nested block is spliced out; if its enclosing insert ## row is left without any nested entries (only the bare `- insert:` header, ## blanks, and comments), the row header goes too so the file stays a valid ## patch list. A plain row for our id is removed wholesale. static func _remove_lines(lines: PackedStringArray, block: Dictionary) -> String: var start := int(block.get("start", -1)) var end := int(block.get("end", -1)) var nested := _indent_of(lines[start]) > 0 var parts: Array[String] = [] if nested: ## Find the enclosing `- insert:` row header: the nearest preceding ## 0-indent `- ` line. var row_start := -1 for i in range(start - 1, -1, -1): if _indent_of(lines[i]) == 0 and lines[i].strip_edges().begins_with("- "): row_start = i break ## Row end: first 0-indent non-blank/comment line after the block. var row_end := lines.size() for i in range(end, lines.size()): if _indent_of(lines[i]) == 0 and not _is_blank_or_comment(lines[i]): row_end = i break var has_other_entries := false for i in range(row_start + 1, row_end): if i >= start and i < end: continue if _is_blank_or_comment(lines[i]): continue if _indent_of(lines[i]) > 0 and lines[i].strip_edges().begins_with("- "): has_other_entries = true break if has_other_entries or row_start < 0: ## Only our block goes; sibling entries and any trailing ## comments in the same row survive. for i in range(lines.size()): if i >= start and i < end: continue parts.append(lines[i]) else: ## The row held only our entry: drop the `- insert:` header and ## our block, but keep trailing blank/comment lines — a comment ## the user wrote above the next row must survive (#867 review). for i in range(lines.size()): if i >= row_start and i < end: continue parts.append(lines[i]) parts = _trim_trailing_blanks(parts) else: ## Plain top-level row for our id: drop the whole block. for i in range(lines.size()): if i >= start and i < end: continue parts.append(lines[i]) parts = _trim_trailing_blanks(parts) return _join_lines(parts) ## Remove trailing blank lines left behind by a removal. static func _trim_trailing_blanks(parts: Array[String]) -> Array[String]: while parts.size() > 0 and String(parts[parts.size() - 1]).strip_edges().is_empty(): parts.remove_at(parts.size() - 1) return parts ## True when the text holds no patch rows at all — blank lines and comments ## only. Such a file cannot be written back (see `remove`), because dsh's ## patch parser rejects a non-array file at boot. static func _is_blank_or_comment_only(text: String) -> bool: for line in text.split("\n"): var stripped := line.strip_edges() if not stripped.is_empty() and not stripped.begins_with("#"): return false return true ## When the document's only content line is the empty flow sequence `[]`, ## return the text with that one line removed — blanks and comments stay, ## because they belong to the user. Any other document comes back unchanged; ## a `[]` mixed with real rows is left for `_is_patch_sequence` to refuse. static func _strip_empty_flow_sequence(text: String) -> String: var lines := _split_lines(text) var bracket_line := -1 for i in range(lines.size()): if _is_blank_or_comment(lines[i]): continue if bracket_line < 0 and lines[i].strip_edges() == "[]": bracket_line = i continue return text if bracket_line < 0: return text var parts: Array[String] = [] for i in range(lines.size()): if i != bracket_line: parts.append(lines[i]) if parts.is_empty(): return "" return _join_lines(parts) ## True when the text is a valid loader patch list (or empty/comment-only / ## the empty flow sequence `[]`, which all mean "no layer"). Anything else — ## a top-level mapping or scalar — would make dsh fail boot, so Configure ## refuses to write into it instead of producing a broken file (#867 review). ## Only 0-indent content lines define the top-level structure: they must all ## be `- ` rows. Deeper lines are row children and are not inspected. `[]` is ## valid only as the COMPLETE document — appending rows after the empty flow ## sequence would produce malformed YAML, so a mixed `[]` + rows file fails ## the check too. static func _is_patch_sequence(text: String) -> bool: if text.strip_edges().is_empty(): return true var saw_brackets := false var content_count := 0 for line in text.split("\n"): var stripped := line.strip_edges() if stripped.is_empty() or stripped.begins_with("#"): continue content_count += 1 if stripped == "[]": saw_brackets = true continue if _indent_of(line) == 0 and not stripped.begins_with("- "): return false if saw_brackets and content_count > 1: return false return true static func _join_lines(parts: Array) -> String: if parts.is_empty(): return "" var lines: Array[String] = [] for p in parts: lines.append(String(p)) return "\n".join(lines) + "\n" # --- Small helpers ---------------------------------------------------------- static func _indent_str(n: int) -> String: var out := "" for i in range(n): out += " " return out static func _indent_of(line: String) -> int: var n := 0 while n < line.length() and (line[n] == " " or line[n] == "\t"): n += 1 return n static func _is_blank_or_comment(line: String) -> bool: var stripped := line.strip_edges() return stripped.is_empty() or stripped.begins_with("#") 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": String} when the file is absent or readable, ## and {"ok": false, "error": String} when unreadable. Callers must NOT fall ## back to an empty string on the error path — doing so blows away the user's ## other patch rows on the next write. static func _read(path: String) -> Dictionary: if not FileAccess.file_exists(path): return {"ok": true, "data": ""} var f := FileAccess.open(path, FileAccess.READ) if f == null: var err := FileAccess.get_open_error() return {"ok": false, "error": "could not open for reading (%s)" % error_string(err)} var t := f.get_as_text() f.close() return {"ok": true, "data": t}