godot ai 更新
This commit is contained in:
@@ -79,10 +79,21 @@ var path_template: Dictionary = {}
|
||||
## a fallback path that may become invisible after copy-on-write. When
|
||||
## that private leaf is new, Configure seeds it from the first later
|
||||
## existing candidate so read-through content is not shadowed.
|
||||
## 2. If no file or wildcard package match exists, the first non-wildcard
|
||||
## template is the deterministic create target.
|
||||
## 2. If no file or wildcard package match exists, the create target is the
|
||||
## non-wildcard template whose own directories already exist, because the
|
||||
## client has run from there (`_evident_create_path` gives the order),
|
||||
## and otherwise the first non-wildcard template. This is what sends a
|
||||
## Flatpak-only IDE's entry to `~/.var/app/<id>/config/...` instead of a
|
||||
## `~/.config` it never reads.
|
||||
## 3. Multiple matches within any wildcard group are ambiguous and fail
|
||||
## closed instead of choosing an arbitrary package.
|
||||
## 4. A template inside another Flatpak app's `~/.var/app/<id>` is never a
|
||||
## create target while this editor's own Flatpak sandbox hides that
|
||||
## directory (`McpPathTemplate.hidden_flatpak_app_dir`). When rule 2 then
|
||||
## finds no existing directory among the rest, the resolution carries
|
||||
## `create_error` and the unseen `hidden_paths`: status still reads the
|
||||
## first template, and Configure refuses rather than create a file the
|
||||
## client may never read.
|
||||
##
|
||||
## Exact-file and config-home environment overrides still have higher
|
||||
## priority. When this map has no entry for the current platform,
|
||||
@@ -331,6 +342,57 @@ func resolved_config_path_details() -> Dictionary:
|
||||
return {"path": "", "error": unresolved_config_path_error(display_name, path)}
|
||||
|
||||
|
||||
## Why Configure and Remove must leave this client's resolved file alone; ""
|
||||
## when they may write it. See `unshared_flatpak_config_error`.
|
||||
func config_write_error() -> String:
|
||||
var path := resolved_config_path()
|
||||
return "" if path.is_empty() else unshared_flatpak_config_error(display_name, path)
|
||||
|
||||
|
||||
## Why Configure and Remove must leave the file at `path` alone when this
|
||||
## editor runs in a Flatpak sandbox that does not share it read-write with the
|
||||
## host; "" when it does, and outside Flatpak. A file written there is read
|
||||
## back and verified by this editor and never seen by the client.
|
||||
##
|
||||
## Every strategy asks this before it writes, ahead of `create_error`: where a
|
||||
## Flatpak build of the client keeps its settings is beside the point until
|
||||
## the sandbox can write the ordinary location at all. Status does not ask.
|
||||
## It keeps reading the path, and under a read-only grant that is the
|
||||
## client's real file.
|
||||
static func unshared_flatpak_config_error(client_name: String, path: String) -> String:
|
||||
var block := McpPathTemplate.flatpak_write_block(path)
|
||||
if block.is_empty():
|
||||
return ""
|
||||
var refused := (
|
||||
"Godot runs in a Flatpak sandbox that cannot write %s where %s reads "
|
||||
+ "it, so nothing was changed."
|
||||
) % [path, client_name]
|
||||
var by_hand := "or edit that file by hand (docs/steam-capability-directory.md)."
|
||||
if block.has("unmounted"):
|
||||
return refused + (
|
||||
" Flatpak was asked to share %s but mounted nothing there, which "
|
||||
+ "is what it does when the directory does not exist as Godot "
|
||||
+ "starts. Create it, restart Godot, and try again, %s"
|
||||
) % [block["unmounted"], by_hand]
|
||||
if block.has("hidden"):
|
||||
return refused + (
|
||||
" Flatpak hides %s from this sandbox, as a --nofilesystem rule "
|
||||
+ "does. Remove that rule, restart Godot, and try again, %s"
|
||||
) % [block["hidden"], by_hand]
|
||||
if block.has("read_only"):
|
||||
return refused + (
|
||||
" %s is mounted read-only. Make it writable, restart Godot, and "
|
||||
+ "try again, %s"
|
||||
) % [block["read_only"], by_hand]
|
||||
var app_id := McpPathTemplate.flatpak_app_id()
|
||||
return refused + (
|
||||
" Run `flatpak override --user --filesystem=%s %s`, restart Godot, "
|
||||
+ "and try again, or edit that file by hand: "
|
||||
+ "docs/steam-capability-directory.md covers a sandbox without that "
|
||||
+ "grant."
|
||||
) % [block["needs"], app_id if not app_id.is_empty() else "<Godot's Flatpak ID>"]
|
||||
|
||||
|
||||
## Shared wording for a path template `McpPathTemplate.expand` could not fully
|
||||
## resolve. Used by the guard above and by the merge-tier loader in
|
||||
## `_json_strategy.gd`, which resolves its own templates and never passes
|
||||
@@ -404,7 +466,11 @@ func _resolve_ordered_config_path_candidates(templates: Variant) -> Dictionary:
|
||||
var ordered_templates: Array = []
|
||||
for template_variant in templates:
|
||||
ordered_templates.append(str(template_variant))
|
||||
var fallback_create_path := ""
|
||||
## Candidates whose file does not exist yet and that Configure could create.
|
||||
var create_templates := PackedStringArray()
|
||||
var create_paths := PackedStringArray()
|
||||
var hidden_app_dirs := PackedStringArray()
|
||||
var hidden_paths := PackedStringArray()
|
||||
for index in range(ordered_templates.size()):
|
||||
var template := str(ordered_templates[index])
|
||||
var expanded_template := McpPathTemplate.expand(template)
|
||||
@@ -444,10 +510,113 @@ func _resolve_ordered_config_path_candidates(templates: Variant) -> Dictionary:
|
||||
return {"path": "", "error": seed["error"]}
|
||||
_clear_config_path_warning()
|
||||
return {"path": path, "error": "", "seed_path": seed.get("path", "")}
|
||||
if fallback_create_path.is_empty():
|
||||
fallback_create_path = path
|
||||
# Creating below a directory the sandbox hides would land on its
|
||||
# private tmpfs and vanish with the editor, so a hidden candidate can
|
||||
# be neither evidence nor a create target.
|
||||
var hidden_app_dir := McpPathTemplate.hidden_flatpak_app_dir(path)
|
||||
if not hidden_app_dir.is_empty():
|
||||
hidden_paths.append(path)
|
||||
if not hidden_app_dirs.has(hidden_app_dir):
|
||||
hidden_app_dirs.append(hidden_app_dir)
|
||||
continue
|
||||
create_templates.append(template)
|
||||
create_paths.append(path)
|
||||
_clear_config_path_warning()
|
||||
return {"path": fallback_create_path, "error": ""}
|
||||
var evident_create_path := _evident_create_path(create_templates, create_paths)
|
||||
if not evident_create_path.is_empty():
|
||||
return {"path": evident_create_path, "error": ""}
|
||||
var fallback_create_path := "" if create_paths.is_empty() else create_paths[0]
|
||||
if hidden_app_dirs.is_empty():
|
||||
return {"path": fallback_create_path, "error": ""}
|
||||
var hidden_error := hidden_flatpak_config_error(
|
||||
display_name, fallback_create_path.get_base_dir(), hidden_app_dirs
|
||||
)
|
||||
if fallback_create_path.is_empty():
|
||||
return {"path": "", "error": hidden_error}
|
||||
return {
|
||||
"path": fallback_create_path,
|
||||
"error": "",
|
||||
"create_error": hidden_error,
|
||||
"hidden_paths": hidden_paths,
|
||||
}
|
||||
|
||||
|
||||
## The location the client has demonstrably run from, among candidates with no
|
||||
## config file yet; "" when nothing says. Candidates are compared level by
|
||||
## level, from the directory that would hold the file upward, and the first
|
||||
## level at which one of those directories exists decides, in descriptor order.
|
||||
## A client nested in its host app's settings (a VS Code extension) therefore
|
||||
## follows the host app when the extension's own directory exists nowhere,
|
||||
## instead of creating a second, unread copy of the host's settings tree.
|
||||
##
|
||||
## Only directories the templates name below their shared root are compared,
|
||||
## and only as many levels as the shortest template has. Those belong to the
|
||||
## client; a container such as ~/.config exists whether or not the client does.
|
||||
static func _evident_create_path(templates: PackedStringArray, paths: PackedStringArray) -> String:
|
||||
if paths.is_empty():
|
||||
return ""
|
||||
var shared := _shared_root_length(templates)
|
||||
var levels := -1
|
||||
for template in templates:
|
||||
var own_directories := template.split("/").size() - 1 - shared
|
||||
levels = own_directories if levels < 0 else mini(levels, own_directories)
|
||||
var directories := PackedStringArray()
|
||||
for path in paths:
|
||||
directories.append(path.get_base_dir())
|
||||
for _level in range(maxi(levels, 0)):
|
||||
for index in range(directories.size()):
|
||||
if DirAccess.dir_exists_absolute(directories[index]):
|
||||
return paths[index]
|
||||
for index in range(directories.size()):
|
||||
directories[index] = directories[index].get_base_dir()
|
||||
return ""
|
||||
|
||||
|
||||
## How many leading path elements every template shares, counting the root
|
||||
## element (`$XDG_CONFIG_HOME`, `~`, a drive) and never the file name. At least
|
||||
## one: the root is never the client's own directory.
|
||||
static func _shared_root_length(templates: PackedStringArray) -> int:
|
||||
if templates.size() < 2:
|
||||
return 1
|
||||
var first := templates[0].split("/")
|
||||
var shared := first.size() - 1
|
||||
for index in range(1, templates.size()):
|
||||
var other := templates[index].split("/")
|
||||
var limit := mini(shared, other.size() - 1)
|
||||
var same := 0
|
||||
while same < limit and first[same] == other[same]:
|
||||
same += 1
|
||||
shared = same
|
||||
return maxi(shared, 1)
|
||||
|
||||
|
||||
## Why Configure will not create a client's settings file from inside a
|
||||
## Flatpak editor: nothing shows the client runs from the ordinary location,
|
||||
## and the location a Flatpak build of it uses cannot be inspected. Names the
|
||||
## one grant that lets this editor see it.
|
||||
static func hidden_flatpak_config_error(
|
||||
client_name: String, settings_dir: String, hidden_app_dirs: PackedStringArray
|
||||
) -> String:
|
||||
var app_id := McpPathTemplate.flatpak_app_id()
|
||||
var grants := PackedStringArray()
|
||||
for app_dir in hidden_app_dirs:
|
||||
grants.append("--filesystem=%s" % app_dir)
|
||||
var missing := (
|
||||
"%s does not exist" % settings_dir
|
||||
if not settings_dir.is_empty()
|
||||
else "it has no settings location outside Flatpak"
|
||||
)
|
||||
return (
|
||||
"Could not find where %s keeps its settings: %s, and a Flatpak build "
|
||||
+ "keeps them under %s, which Godot's own Flatpak sandbox cannot see. "
|
||||
+ "If you use the Flatpak build, run `flatpak override --user %s %s`, "
|
||||
+ "restart Godot, and configure again. If it is installed outside "
|
||||
+ "Flatpak, start it once so it creates its settings, then configure "
|
||||
+ "again."
|
||||
) % [
|
||||
client_name, missing, ", ".join(hidden_app_dirs),
|
||||
" ".join(grants), app_id if not app_id.is_empty() else "<Godot's Flatpak ID>",
|
||||
]
|
||||
|
||||
|
||||
## Find a readable seed for a newly-created authoritative wildcard target.
|
||||
|
||||
@@ -25,6 +25,8 @@ static var _cache: Dictionary = {} # exe_name -> resolved path (or "")
|
||||
static var _searched: Dictionary = {}
|
||||
|
||||
const _LOOKUP_TIMEOUT_MS := 3000
|
||||
const _TRACE_PATH_LIMIT := 16
|
||||
const _TRACE_PATH_MAX_CHARS := 512
|
||||
|
||||
|
||||
## Find any of the supplied exe names; returns the first hit.
|
||||
@@ -56,7 +58,15 @@ static func _find_one(exe_name: String, trace: Callable = Callable()) -> String:
|
||||
var cached: String = _cache.get(exe_name, "")
|
||||
_mutex.unlock()
|
||||
if already_searched:
|
||||
_trace_lookup(trace, "cache", "resolved" if not cached.is_empty() else "cached_miss", started, {}, true)
|
||||
_trace_lookup(
|
||||
trace,
|
||||
"cache",
|
||||
"resolved" if not cached.is_empty() else "cached_miss",
|
||||
started,
|
||||
{},
|
||||
true,
|
||||
cached,
|
||||
)
|
||||
return cached
|
||||
# `_resolve()` does FileAccess + bounded subprocess lookup (forks
|
||||
# `bash -lc` / `which`), which can take 100ms-1s. Holding the mutex across that
|
||||
@@ -76,12 +86,14 @@ static func _resolve(exe_name: String, trace: Callable = Callable()) -> String:
|
||||
var is_windows := OS.get_name() == "Windows"
|
||||
|
||||
# 1. Well-known locations
|
||||
var well_known_paths := PackedStringArray()
|
||||
for dir in _well_known_dirs():
|
||||
var full := dir.path_join(exe_name)
|
||||
well_known_paths.append(full)
|
||||
if FileAccess.file_exists(full):
|
||||
_trace_lookup(trace, "well_known", "resolved", started)
|
||||
_trace_lookup(trace, "well_known", "resolved", started, {}, false, full, well_known_paths)
|
||||
return full
|
||||
_trace_lookup(trace, "well_known", "no_match", started)
|
||||
_trace_lookup(trace, "well_known", "no_match", started, {}, false, "", well_known_paths)
|
||||
|
||||
# 2. Login shell lookup (Unix only)
|
||||
if not is_windows:
|
||||
@@ -97,22 +109,50 @@ static func _resolve(exe_name: String, trace: Callable = Callable()) -> String:
|
||||
if int(login_result.get("exit_code", -1)) == 0:
|
||||
var login_found: String = str(login_result.get("stdout", "")).strip_edges()
|
||||
if not login_found.is_empty() and FileAccess.file_exists(login_found):
|
||||
_trace_lookup(trace, "login_shell", "resolved", started, login_result)
|
||||
_trace_lookup(
|
||||
trace,
|
||||
"login_shell",
|
||||
"resolved",
|
||||
started,
|
||||
login_result,
|
||||
false,
|
||||
login_found,
|
||||
PackedStringArray([login_found]),
|
||||
)
|
||||
return login_found
|
||||
_trace_lookup(trace, "login_shell", _lookup_failure(login_result, false), started, login_result)
|
||||
|
||||
# 3. which / where with inherited PATH
|
||||
return _resolve_inherited_path(exe_name, is_windows, trace)
|
||||
|
||||
|
||||
## Resolve from the inherited PATH through a single lookup command. The
|
||||
## optional runner keeps the Windows/where failure modes deterministic in unit
|
||||
## tests without changing the production invocation.
|
||||
static func _resolve_inherited_path(
|
||||
exe_name: String,
|
||||
is_windows: bool,
|
||||
trace: Callable = Callable(),
|
||||
lookup_runner: Callable = Callable(),
|
||||
) -> String:
|
||||
var lookup := "where" if is_windows else "which"
|
||||
started = Time.get_ticks_msec()
|
||||
var result := McpCliExec.run(lookup, [exe_name], _LOOKUP_TIMEOUT_MS, false)
|
||||
var started := Time.get_ticks_msec()
|
||||
var result: Dictionary = {}
|
||||
if lookup_runner.is_valid():
|
||||
var injected: Variant = lookup_runner.call(lookup, exe_name)
|
||||
if injected is Dictionary:
|
||||
result = injected
|
||||
else:
|
||||
result = McpCliExec.run(lookup, [exe_name], _LOOKUP_TIMEOUT_MS, false)
|
||||
var lines := PackedStringArray(str(result.get("stdout", "")).split("\n"))
|
||||
if int(result.get("exit_code", -1)) == 0:
|
||||
var output := str(result.get("stdout", ""))
|
||||
var lines := PackedStringArray(output.split("\n"))
|
||||
var found := _pick_best_path(lines) if is_windows else lines[0].strip_edges()
|
||||
if not found.is_empty():
|
||||
_trace_lookup(trace, "inherited_path", "resolved", started, result)
|
||||
_trace_lookup(trace, "inherited_path", "resolved", started, result, false, found, lines)
|
||||
return found
|
||||
_trace_lookup(trace, "inherited_path", _lookup_failure(result, true), started, result)
|
||||
_trace_lookup(
|
||||
trace, "inherited_path", _lookup_failure(result, true), started, result, false, "", lines
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
@@ -128,7 +168,8 @@ static func _lookup_failure(result: Dictionary, absence_exit: bool) -> String:
|
||||
|
||||
static func _trace_lookup(
|
||||
trace: Callable, tier: String, status: String, started: int,
|
||||
result: Dictionary = {}, cache_hit := false,
|
||||
result: Dictionary = {}, cache_hit := false, resolved_path := "",
|
||||
candidate_paths: PackedStringArray = PackedStringArray(),
|
||||
) -> void:
|
||||
if not trace.is_valid():
|
||||
return
|
||||
@@ -141,9 +182,44 @@ static func _trace_lookup(
|
||||
"spawn_failed": bool(result.get("spawn_failed", false)),
|
||||
"cancelled": bool(result.get("cancelled", false)),
|
||||
"termination_failed": bool(result.get("termination_failed", false)),
|
||||
"resolved_path": _bounded_trace_path(resolved_path),
|
||||
"candidate_paths": _bounded_trace_paths(candidate_paths),
|
||||
})
|
||||
|
||||
|
||||
## Keep lookup diagnostics useful without retaining arbitrary command output or
|
||||
## environment values. Only absolute executable paths cross this boundary.
|
||||
static func _bounded_trace_paths(paths: PackedStringArray) -> Array[String]:
|
||||
var bounded: Array[String] = []
|
||||
for raw in paths:
|
||||
var path := _bounded_trace_path(raw)
|
||||
if path.is_empty() or bounded.has(path):
|
||||
continue
|
||||
bounded.append(path)
|
||||
if bounded.size() >= _TRACE_PATH_LIMIT:
|
||||
break
|
||||
return bounded
|
||||
|
||||
|
||||
static func _bounded_trace_path(raw: String) -> String:
|
||||
var path := raw.strip_edges()
|
||||
if not _is_traceable_path(path):
|
||||
return ""
|
||||
return path.left(_TRACE_PATH_MAX_CHARS)
|
||||
|
||||
|
||||
static func _is_traceable_path(path: String) -> bool:
|
||||
if path.is_empty() or path.contains("\n") or path.contains("\r"):
|
||||
return false
|
||||
if path.begins_with("/") or path.begins_with("\\\\"):
|
||||
return true
|
||||
return (
|
||||
path.length() >= 3
|
||||
and path.substr(1, 1) == ":"
|
||||
and (path.substr(2, 1) == "\\" or path.substr(2, 1) == "/")
|
||||
)
|
||||
|
||||
|
||||
## Executable extensions Windows' CreateProcessW can launch from a path
|
||||
## (after the cmd.exe wrap in `_cli_exec.gd`). Order is preference: `.exe`
|
||||
## is a native PE binary; `.cmd` / `.bat` go through the shell; `.com` is
|
||||
|
||||
@@ -30,6 +30,9 @@ static func configure(
|
||||
server_url: String,
|
||||
launch: Dictionary = {},
|
||||
) -> Dictionary:
|
||||
var write_error := configure_write_error(client)
|
||||
if not write_error.is_empty():
|
||||
return {"status": "error", "message": write_error}
|
||||
## Fail closed before any subprocess runs: a command-shape client without a
|
||||
## verified attach launcher must not register anything (see
|
||||
## docs/client-configuration.md — an ERROR beats an entry known to be broken).
|
||||
@@ -184,6 +187,9 @@ static func _status_details(
|
||||
|
||||
|
||||
static func remove(client: McpClient, server_name: String) -> Dictionary:
|
||||
var write_error := remove_write_error(client)
|
||||
if not write_error.is_empty():
|
||||
return {"status": "error", "message": write_error}
|
||||
var cli := _resolve_cli(client)
|
||||
if cli.is_empty():
|
||||
return {"status": "error", "message": "%s not found" % client.display_name}
|
||||
@@ -440,6 +446,33 @@ static func uses_scope_token(client: McpClient) -> bool:
|
||||
return client.cli_register_template.has(SCOPE_TOKEN)
|
||||
|
||||
|
||||
## Why Configure may not run this client's CLI. The CLI runs inside this
|
||||
## editor's Flatpak sandbox, so what it puts in the file the client keeps its
|
||||
## servers in (the JSON-fallback file) lands where the client outside never
|
||||
## reads it. That file takes the `user` scope's entry and the `local` scope's
|
||||
## per-project block, and the pre-cleanup sweep (#872) removes from it at
|
||||
## every scope, so the selected scope makes no difference here: at `project`
|
||||
## the sweep would miss a stale entry in the host's file and Configure would
|
||||
## still report success. A descriptor that names no such file has nothing to
|
||||
## check.
|
||||
static func configure_write_error(client: McpClient) -> String:
|
||||
if not client.has_json_fallback():
|
||||
return ""
|
||||
return client.config_write_error()
|
||||
|
||||
|
||||
## Why Remove may not. It removes from the selected scope only: `user` and
|
||||
## `local` keep their entries in the JSON-fallback file, and `project` in the
|
||||
## CLI's working directory, which this layer does not resolve and leaves to
|
||||
## the CLI.
|
||||
static func remove_write_error(client: McpClient) -> String:
|
||||
if not client.has_json_fallback():
|
||||
return ""
|
||||
if uses_scope_token(client) and McpSettings.client_scope() == "project":
|
||||
return ""
|
||||
return client.config_write_error()
|
||||
|
||||
|
||||
## Public view of the pre-cleanup sweep, for the manual-command text: what the
|
||||
## dock tells the user to run has to match what Configure actually runs (#877).
|
||||
static func cleanup_scopes(client: McpClient) -> Array[String]:
|
||||
|
||||
@@ -64,10 +64,16 @@ static func configure(
|
||||
var resolution := client.resolved_config_path_details()
|
||||
var path := str(resolution.get("path", ""))
|
||||
var path_error := str(resolution.get("error", ""))
|
||||
if path_error.is_empty() and not path.is_empty():
|
||||
path_error = McpClient.unshared_flatpak_config_error(client.display_name, path)
|
||||
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}
|
||||
## Set only while no candidate file exists, so this never blocks an update.
|
||||
var create_error := str(resolution.get("create_error", ""))
|
||||
if not create_error.is_empty():
|
||||
return {"status": "error", "message": create_error}
|
||||
## 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():
|
||||
@@ -191,6 +197,8 @@ 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 path_error.is_empty() and not path.is_empty():
|
||||
path_error = McpClient.unshared_flatpak_config_error(client.display_name, path)
|
||||
if not path_error.is_empty():
|
||||
return {"status": "error", "message": path_error}
|
||||
if path.is_empty() or not FileAccess.file_exists(path):
|
||||
|
||||
@@ -19,10 +19,16 @@ static func configure(
|
||||
var resolution := client.resolved_config_path_details()
|
||||
var path := str(resolution.get("path", ""))
|
||||
var path_error := str(resolution.get("error", ""))
|
||||
if path_error.is_empty() and not path.is_empty():
|
||||
path_error = McpClient.unshared_flatpak_config_error(client.display_name, path)
|
||||
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}
|
||||
## Set only while no candidate file exists, so this never blocks an update.
|
||||
var create_error := str(resolution.get("create_error", ""))
|
||||
if not create_error.is_empty():
|
||||
return {"status": "error", "message": create_error}
|
||||
|
||||
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
|
||||
@@ -59,6 +65,9 @@ static func _configure_merged(
|
||||
launch: Dictionary,
|
||||
project_roots: PackedStringArray,
|
||||
) -> Dictionary:
|
||||
var write_error := _merge_write_error(client)
|
||||
if not write_error.is_empty():
|
||||
return {"status": "error", "message": write_error}
|
||||
var launch_error := command_launch_error(client, launch)
|
||||
if not launch_error.is_empty():
|
||||
return {"status": "error", "message": launch_error}
|
||||
@@ -259,6 +268,8 @@ static func remove(
|
||||
var resolution := client.resolved_config_path_details()
|
||||
var path := str(resolution.get("path", ""))
|
||||
var path_error := str(resolution.get("error", ""))
|
||||
if path_error.is_empty() and not path.is_empty():
|
||||
path_error = McpClient.unshared_flatpak_config_error(client.display_name, path)
|
||||
if not path_error.is_empty():
|
||||
return {"status": "error", "message": path_error}
|
||||
if path.is_empty() or not FileAccess.file_exists(path):
|
||||
@@ -284,6 +295,9 @@ static func remove(
|
||||
static func _remove_merged(
|
||||
client: McpClient, server_name: String, project_roots: PackedStringArray
|
||||
) -> Dictionary:
|
||||
var write_error := _merge_write_error(client)
|
||||
if not write_error.is_empty():
|
||||
return {"status": "error", "message": write_error}
|
||||
## Scope ambiguity (omp named profiles) blocks Remove too: clearing the
|
||||
## default files while a profile reads its own would report a success the
|
||||
## running client never observes (#1085).
|
||||
@@ -750,6 +764,21 @@ static func _merge_paths(client: McpClient) -> PackedStringArray:
|
||||
return result
|
||||
|
||||
|
||||
## The Flatpak write check `configure` and `remove` make on their one path,
|
||||
## made here on every tier. Any tier this sandbox does not share with the host
|
||||
## refuses the whole Configure or Remove: an unseen tier may hold the
|
||||
## definition the client actually uses. Status reads past it, as it does for
|
||||
## an ordinary client.
|
||||
static func _merge_write_error(client: McpClient) -> String:
|
||||
for path in _merge_paths(client):
|
||||
if not path.is_absolute_path():
|
||||
continue
|
||||
var write_error := McpClient.unshared_flatpak_config_error(client.display_name, path)
|
||||
if not write_error.is_empty():
|
||||
return write_error
|
||||
return ""
|
||||
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -23,6 +23,19 @@ extends RefCounted
|
||||
static var _env_snapshot := {}
|
||||
static var _env_snapshot_mutex := Mutex.new()
|
||||
|
||||
const LinuxProc := preload("res://addons/godot_ai/utils/linux_proc.gd")
|
||||
const _MOUNTINFO_PATH := "/proc/self/mountinfo"
|
||||
|
||||
## `/.flatpak-info` as Flatpak wrote it before this process started, or "" when
|
||||
## this is not a Flatpak sandbox. The sandbox it describes cannot change while
|
||||
## the process runs, so one read serves every thread. Guarded by
|
||||
## `_env_snapshot_mutex`, like the snapshot it is consulted alongside.
|
||||
static var _flatpak_info := ""
|
||||
static var _flatpak_info_read := false
|
||||
## What that sandbox mounted, from `/proc/self/mountinfo`: read with the file
|
||||
## above, and only inside a sandbox. Empty when it could not be read.
|
||||
static var _flatpak_mounts: Array[Dictionary] = []
|
||||
|
||||
## Every var this layer and its sibling consumers (`_base.gd`
|
||||
## `config_file_override_details`, `config_home_override_details`, `_cli_finder.gd` lookups,
|
||||
## `client_configurator.gd` mode/trace reads) can touch off-main.
|
||||
@@ -32,6 +45,10 @@ const _BASE_ENV_VARS: Array[String] = [
|
||||
"HOME",
|
||||
"USERPROFILE",
|
||||
"XDG_CONFIG_HOME",
|
||||
## What `$XDG_CONFIG_HOME` reads in a Flatpak editor — see `_env_source`.
|
||||
"HOST_XDG_CONFIG_HOME",
|
||||
## Read by `flatpak_hides_credentials` on the workers that run Configure.
|
||||
"GODOT_AI_CAPABILITY_DIR",
|
||||
"APPDATA",
|
||||
"LOCALAPPDATA",
|
||||
"SHELL",
|
||||
@@ -76,6 +93,120 @@ static func warm_env_snapshot(extra_vars: PackedStringArray = PackedStringArray(
|
||||
for var_name in extra_vars:
|
||||
if not String(var_name).is_empty():
|
||||
env_lookup(String(var_name))
|
||||
_flatpak_info_text()
|
||||
|
||||
|
||||
## Cached `/.flatpak-info`, read on first use from whichever thread asks. Only
|
||||
## Linux has Flatpak; elsewhere the leading slash would name a drive root.
|
||||
static func _flatpak_info_text() -> String:
|
||||
_env_snapshot_mutex.lock()
|
||||
_read_flatpak_sandbox()
|
||||
var info := _flatpak_info
|
||||
_env_snapshot_mutex.unlock()
|
||||
return info
|
||||
|
||||
|
||||
## The sandbox's mounts, cached with the description above. The list is built
|
||||
## once and never changed, so callers on any thread may read it.
|
||||
static func _flatpak_mount_list() -> Array[Dictionary]:
|
||||
_env_snapshot_mutex.lock()
|
||||
_read_flatpak_sandbox()
|
||||
var mounts := _flatpak_mounts
|
||||
_env_snapshot_mutex.unlock()
|
||||
return mounts
|
||||
|
||||
|
||||
## Caller holds `_env_snapshot_mutex`.
|
||||
static func _read_flatpak_sandbox() -> void:
|
||||
if _flatpak_info_read:
|
||||
return
|
||||
_flatpak_info = McpTransportCapability._flatpak_info() if _os_key() == "linux" else ""
|
||||
var mountinfo := ""
|
||||
if not _flatpak_info.is_empty():
|
||||
mountinfo = str(LinuxProc.read_text(_MOUNTINFO_PATH).text)
|
||||
_flatpak_mounts = McpTransportCapability.parse_mountinfo(mountinfo)
|
||||
_flatpak_info_read = true
|
||||
|
||||
|
||||
## Stand in for the sandbox description so tests can cover Flatpak resolution
|
||||
## on any host, with `mountinfo` as its `/proc/self/mountinfo`: by default no
|
||||
## mount evidence at all, never the real file's. Pass null to forget both; the
|
||||
## next read then asks the real files.
|
||||
static func _set_flatpak_info_for_test(info: Variant, mountinfo: String = "") -> void:
|
||||
_env_snapshot_mutex.lock()
|
||||
_flatpak_info = "" if info == null else str(info)
|
||||
_flatpak_mounts = McpTransportCapability.parse_mountinfo(mountinfo)
|
||||
_flatpak_info_read = info != null
|
||||
_env_snapshot_mutex.unlock()
|
||||
|
||||
|
||||
## This editor's Flatpak application ID, or "" outside Flatpak.
|
||||
static func flatpak_app_id() -> String:
|
||||
return McpTransportCapability.flatpak_application_id(_flatpak_info_text())
|
||||
|
||||
|
||||
## The host's config directory when this editor is a Flatpak that shares it
|
||||
## (through the home directory, or a grant for the whole of `xdg-config`); ""
|
||||
## anywhere else, and when it cannot be resolved. For callers whose own
|
||||
## default is `OS.get_config_dir()`, which in that sandbox is the per-app
|
||||
## directory that nothing outside it reads.
|
||||
static func flatpak_host_config_home() -> String:
|
||||
if not McpTransportCapability.flatpak_shares_config_home(_flatpak_info_text()):
|
||||
return ""
|
||||
var config_home := expand("$XDG_CONFIG_HOME")
|
||||
return config_home if config_home.is_absolute_path() else ""
|
||||
|
||||
|
||||
## Whether this editor's Flatpak sandbox keeps Godot AI's credentials where a
|
||||
## client outside it does not look. The bridge a client starts reads the
|
||||
## host's `<config home>/godot-ai/capabilities`, so an entry Configure writes
|
||||
## from such a sandbox cannot connect, however right its file is.
|
||||
##
|
||||
## The server publishes in the host's config directory when the sandbox
|
||||
## shares it (`McpTransportCapability.linux_config_home_variable`), and
|
||||
## otherwise in the per-app one, which is the host's only where an
|
||||
## `xdg-config/` grant mounted the host's directory inside it. Either way the
|
||||
## host's directory has to be shared read-write, which `flatpak_write_block`
|
||||
## decides. An editor given `GODOT_AI_CAPABILITY_DIR` publishes somewhere this
|
||||
## cannot judge, and its client needs the same variable: never hidden here.
|
||||
static func flatpak_hides_credentials() -> bool:
|
||||
var info := _flatpak_info_text()
|
||||
if info.is_empty():
|
||||
return false
|
||||
if not env_lookup(McpTransportCapability.CAPABILITY_DIR_ENV).strip_edges().is_empty():
|
||||
return false
|
||||
var config_home := expand("$XDG_CONFIG_HOME")
|
||||
if not config_home.is_absolute_path():
|
||||
return false
|
||||
var host_directory := config_home.path_join("godot-ai/capabilities")
|
||||
if not flatpak_write_block(host_directory.path_join("http.json")).is_empty():
|
||||
return true
|
||||
if McpTransportCapability.flatpak_shares_config_home(info):
|
||||
return false
|
||||
var grant := McpTransportCapability.flatpak_grant_over(
|
||||
info, host_directory, _home(), config_home
|
||||
)
|
||||
return not str(grant.get("location", "")).begins_with("xdg-config/")
|
||||
|
||||
|
||||
## The `~/.var/app/<id>` directory of another Flatpak app that `path` lies in,
|
||||
## when this editor's own Flatpak sandbox does not show it; "" otherwise.
|
||||
## Flatpak mounts only the running app's directory under `~/.var/app`, and a
|
||||
## `host` or `home` grant does not widen that. From in here another app's
|
||||
## settings are therefore unknown rather than absent until the user grants
|
||||
## that directory by name, and anything created below it would land on the
|
||||
## sandbox's private tmpfs.
|
||||
static func hidden_flatpak_app_dir(path: String) -> String:
|
||||
if _flatpak_info_text().is_empty():
|
||||
return ""
|
||||
var home := _home()
|
||||
if home.is_empty():
|
||||
return ""
|
||||
var apps := home.path_join(".var/app") + "/"
|
||||
if not path.begins_with(apps):
|
||||
return ""
|
||||
var app_dir := apps + path.trim_prefix(apps).get_slice("/", 0)
|
||||
return "" if DirAccess.dir_exists_absolute(app_dir) else app_dir
|
||||
|
||||
|
||||
## Pick the right entry from a {"darwin": ..., "windows": ..., "linux": ...} map.
|
||||
@@ -183,7 +314,7 @@ static func expand(template: String) -> String:
|
||||
var token := "$%s" % var_name
|
||||
if out.find(token) < 0:
|
||||
continue
|
||||
var value := env_lookup(var_name)
|
||||
var value := env_lookup(_env_source(var_name))
|
||||
if value.is_empty():
|
||||
value = _home_fallback(var_name)
|
||||
if value.is_empty():
|
||||
@@ -192,6 +323,81 @@ static func expand(template: String) -> String:
|
||||
return out
|
||||
|
||||
|
||||
## The environment variable a token's value is read from. Flatpak points
|
||||
## `XDG_CONFIG_HOME` at the editor's own `~/.var/app/<id>/config`, where no
|
||||
## client looks: a path expanded from it is written, read back, and reported
|
||||
## as configured without the client ever seeing it. Inside a sandbox the token
|
||||
## therefore reads the host's variable, so a template always names the file
|
||||
## the client reads. Whether this sandbox can reach that file is a separate
|
||||
## question, answered by `flatpak_write_block`. Every grant that shares part
|
||||
## of the host's config directory mounts it at the host's path, an
|
||||
## `xdg-config/<dir>` grant included, so no path has to go through the per-app
|
||||
## directory, where a leftover from an earlier run would pass for the client's
|
||||
## own file.
|
||||
static func _env_source(var_name: String) -> String:
|
||||
if var_name == "XDG_CONFIG_HOME" and not _flatpak_info_text().is_empty():
|
||||
return "HOST_XDG_CONFIG_HOME"
|
||||
return var_name
|
||||
|
||||
|
||||
## Why a file this editor writes at `path` would not be the one a client
|
||||
## outside its Flatpak sandbox reads there. {} when it would be, and always
|
||||
## outside Flatpak. Otherwise one of:
|
||||
## - {"needs": grant}: no read-write grant covers the file's directory, and
|
||||
## this is the `--filesystem=` value that would. That is the read-only grant
|
||||
## that does cover it when there is one, because a wider grant does not
|
||||
## lift it.
|
||||
## - {"unmounted": directory}: a read-write grant names this directory, but
|
||||
## Flatpak mounted nothing there, which is what it does when the directory
|
||||
## does not exist as the app starts.
|
||||
## - {"hidden": directory}: inside a granted directory, this one is an empty
|
||||
## tmpfs, which is how Flatpak carries out a denial (`--nofilesystem`).
|
||||
## - {"read_only": directory}: granted read-write, mounted read-only.
|
||||
##
|
||||
## The directory is what has to be shared, not the file: `McpAtomicWrite`
|
||||
## stages a temporary file beside the target and renames it into place, which
|
||||
## cannot replace a file Flatpak mounted on its own. It also follows a
|
||||
## symlinked config file to its target, so that is the file tested.
|
||||
static func flatpak_write_block(path: String) -> Dictionary:
|
||||
var info := _flatpak_info_text()
|
||||
if info.is_empty():
|
||||
return {}
|
||||
var directory := McpAtomicWrite._resolve_symlink_target(path).get_base_dir()
|
||||
var home := _home()
|
||||
var inside_home := (
|
||||
not home.is_empty() and McpTransportCapability.path_is_within(home, directory)
|
||||
)
|
||||
var grant := McpTransportCapability.flatpak_grant_over(
|
||||
info, directory, home, expand("$XDG_CONFIG_HOME")
|
||||
)
|
||||
if not grant.get("writable", false):
|
||||
if not grant.is_empty():
|
||||
return {"needs": str(grant["location"])}
|
||||
## Nothing under ~/.var/app comes with the home, so there the app's
|
||||
## directory has to be granted by name.
|
||||
var apps := home.path_join(".var/app") + "/"
|
||||
if inside_home and directory.begins_with(apps):
|
||||
return {"needs": apps + directory.trim_prefix(apps).get_slice("/", 0)}
|
||||
return {"needs": "home" if inside_home else directory}
|
||||
var root := str(grant["root"])
|
||||
var real_root := McpTransportCapability.real_path(root)
|
||||
var mount := McpTransportCapability.flatpak_blocking_mount(
|
||||
_flatpak_mount_list(),
|
||||
McpTransportCapability.real_path(directory),
|
||||
McpTransportCapability.real_path(home) if inside_home else real_root,
|
||||
)
|
||||
if mount.is_empty():
|
||||
return {}
|
||||
var mount_point := str(mount["mount_point"])
|
||||
if mount["read_only"]:
|
||||
return {"read_only": mount_point}
|
||||
## A tmpfs above the granted directory is not there because of the grant:
|
||||
## the grant's own mount is what is missing.
|
||||
if not McpTransportCapability.path_is_within(real_root, mount_point):
|
||||
return {"unmounted": root}
|
||||
return {"hidden": mount_point}
|
||||
|
||||
|
||||
## Home-derived default for a var the environment does not define. Returns ""
|
||||
## when home itself is unresolvable so `expand` leaves the token alone: rooting
|
||||
## these at an empty home yields a RELATIVE path, not an absent one —
|
||||
|
||||
@@ -19,10 +19,16 @@ static func configure(
|
||||
var resolution := client.resolved_config_path_details()
|
||||
var path := str(resolution.get("path", ""))
|
||||
var path_error := str(resolution.get("error", ""))
|
||||
if path_error.is_empty() and not path.is_empty():
|
||||
path_error = McpClient.unshared_flatpak_config_error(client.display_name, path)
|
||||
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" % client.display_name}
|
||||
## Set only while no candidate file exists, so this never blocks an update.
|
||||
var create_error := str(resolution.get("create_error", ""))
|
||||
if not create_error.is_empty():
|
||||
return {"status": "error", "message": create_error}
|
||||
|
||||
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
|
||||
@@ -198,6 +204,8 @@ 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 path_error.is_empty() and not path.is_empty():
|
||||
path_error = McpClient.unshared_flatpak_config_error(client.display_name, path)
|
||||
if not path_error.is_empty():
|
||||
return {"status": "error", "message": path_error}
|
||||
if path.is_empty() or not FileAccess.file_exists(path):
|
||||
|
||||
@@ -24,10 +24,16 @@ static func configure(
|
||||
var resolution := client.resolved_config_path_details()
|
||||
var path := str(resolution.get("path", ""))
|
||||
var path_error := str(resolution.get("error", ""))
|
||||
if path_error.is_empty() and not path.is_empty():
|
||||
path_error = McpClient.unshared_flatpak_config_error(client.display_name, path)
|
||||
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}
|
||||
## Set only while no candidate file exists, so this never blocks an update.
|
||||
var create_error := str(resolution.get("create_error", ""))
|
||||
if not create_error.is_empty():
|
||||
return {"status": "error", "message": create_error}
|
||||
## Fail closed before touching the file — same contract as JSON/TOML.
|
||||
var launch_error := command_launch_error(client, launch)
|
||||
if not launch_error.is_empty():
|
||||
@@ -105,6 +111,8 @@ 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 path_error.is_empty() and not path.is_empty():
|
||||
path_error = McpClient.unshared_flatpak_config_error(client.display_name, path)
|
||||
if not path_error.is_empty():
|
||||
return {"status": "error", "message": path_error}
|
||||
if path.is_empty() or not FileAccess.file_exists(path):
|
||||
|
||||
@@ -14,6 +14,14 @@ func _init() -> void:
|
||||
"windows": "$APPDATA/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
|
||||
"linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
|
||||
}
|
||||
## globalStorage follows VS Code's user data, which the Flathub build keeps
|
||||
## in its own app directory (see vscode.gd).
|
||||
config_path_candidates = {
|
||||
"linux": [
|
||||
path_template["linux"],
|
||||
"~/.var/app/com.visualstudio.code/config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
|
||||
],
|
||||
}
|
||||
server_key_path = PackedStringArray(["mcpServers"])
|
||||
## Cline (like Roo) defaults a typeless entry to SSE transport, which
|
||||
## returns HTTP 400 against our streamable-http endpoint on `/mcp`. Pin
|
||||
|
||||
@@ -11,6 +11,14 @@ func _init() -> void:
|
||||
"windows": "$APPDATA/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json",
|
||||
"linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json",
|
||||
}
|
||||
## globalStorage follows VS Code's user data, which the Flathub build keeps
|
||||
## in its own app directory (see vscode.gd).
|
||||
config_path_candidates = {
|
||||
"linux": [
|
||||
path_template["linux"],
|
||||
"~/.var/app/com.visualstudio.code/config/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json",
|
||||
],
|
||||
}
|
||||
server_key_path = PackedStringArray(["mcpServers"])
|
||||
## Kilo Code (like Roo) defaults a typeless entry to SSE transport, which
|
||||
## returns HTTP 400 against our streamable-http endpoint on `/mcp`. Pin
|
||||
|
||||
@@ -11,6 +11,14 @@ func _init() -> void:
|
||||
"windows": "$APPDATA/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json",
|
||||
"linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json",
|
||||
}
|
||||
## globalStorage follows VS Code's user data, which the Flathub build keeps
|
||||
## in its own app directory (see vscode.gd).
|
||||
config_path_candidates = {
|
||||
"linux": [
|
||||
path_template["linux"],
|
||||
"~/.var/app/com.visualstudio.code/config/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json",
|
||||
],
|
||||
}
|
||||
server_key_path = PackedStringArray(["mcpServers"])
|
||||
## Roo defaults an entry with no "type" to SSE transport — which returns
|
||||
## HTTP 400 against our streamable-http endpoint on `/mcp`. Pin the type
|
||||
|
||||
@@ -14,6 +14,15 @@ func _init() -> void:
|
||||
"windows": "$APPDATA/Code/User/mcp.json",
|
||||
"linux": "$XDG_CONFIG_HOME/Code/User/mcp.json",
|
||||
}
|
||||
## The Flathub build keeps its user data in the app's own directory and
|
||||
## never reads ~/.config/Code. Verified against com.visualstudio.code
|
||||
## 1.139.1: `code --add-mcp` writes the second path, under `servers`.
|
||||
config_path_candidates = {
|
||||
"linux": [
|
||||
path_template["linux"],
|
||||
"~/.var/app/com.visualstudio.code/config/Code/User/mcp.json",
|
||||
],
|
||||
}
|
||||
server_key_path = PackedStringArray(["servers"])
|
||||
entry_extra_fields = {"type": "http"}
|
||||
## Attach migration (#838). VS Code stdio entries are flat command/args/env
|
||||
|
||||
@@ -21,6 +21,16 @@ func _init() -> void:
|
||||
"linux": "$XDG_CONFIG_HOME/zed/settings.json",
|
||||
"windows": "$APPDATA/Zed/settings.json",
|
||||
}
|
||||
## The Flathub build restarts itself on the host with
|
||||
## FLATPAK_XDG_CONFIG_HOME set to its own app directory, and reads
|
||||
## `$FLATPAK_XDG_CONFIG_HOME/zed` instead of ~/.config/zed
|
||||
## (crates/paths/src/paths.rs, crates/cli/src/main.rs).
|
||||
config_path_candidates = {
|
||||
"linux": [
|
||||
path_template["linux"],
|
||||
"~/.var/app/dev.zed.Zed/config/zed/settings.json",
|
||||
],
|
||||
}
|
||||
server_key_path = PackedStringArray(["context_servers"])
|
||||
## Attach migration (#838). Current Zed's context_servers entries are an
|
||||
## untagged serde enum discriminated by shape: `command` (string) + args/env
|
||||
|
||||
@@ -11,6 +11,14 @@ func _init() -> void:
|
||||
"windows": "$APPDATA/Code/User/globalStorage/zoocodeorganization.zoo-code/settings/mcp_settings.json",
|
||||
"linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/zoocodeorganization.zoo-code/settings/mcp_settings.json",
|
||||
}
|
||||
## globalStorage follows VS Code's user data, which the Flathub build keeps
|
||||
## in its own app directory (see vscode.gd).
|
||||
config_path_candidates = {
|
||||
"linux": [
|
||||
path_template["linux"],
|
||||
"~/.var/app/com.visualstudio.code/config/Code/User/globalStorage/zoocodeorganization.zoo-code/settings/mcp_settings.json",
|
||||
],
|
||||
}
|
||||
server_key_path = PackedStringArray(["mcpServers"])
|
||||
## Local validation against the installed extension shows Zoo stores MCP
|
||||
## entries in `settings/mcp_settings.json` under `mcpServers`, matching Roo's
|
||||
|
||||
Reference in New Issue
Block a user