更新godot ai 到最新版本

This commit is contained in:
2026-09-22 16:07:50 +08:00
parent 9dc02dbb31
commit 020ab88606
46 changed files with 5816 additions and 400 deletions
+43 -4
View File
@@ -89,16 +89,55 @@ var path_template: Dictionary = {}
## `path_template` remains the fallback.
var config_path_candidates: Dictionary = {}
## Optional global JSON config files merged by the client from lowest to highest
## precedence. Unlike `config_path_candidates`, every existing file participates;
## Configure updates the effective last definition, status verifies it, and
## Remove clears every global definition transactionally.
## Optional global JSON config files merged by the client. Unlike
## `config_path_candidates`, every existing file participates. By default the
## client keeps the LAST definition: Configure updates the effective last
## definition, status verifies it, and Remove clears every global definition
## transactionally. Clients that keep the FIRST definition instead set
## `config_merge_first_wins` and declare tiers in the client's read order;
## the folds then target the first tier defining the server, so a write
## updates the file that is already effective and a fresh entry lands on
## tiers[0] — the client's own primary file.
var config_merge_path_templates: Dictionary = {}
## Project-relative tiers that may override the global files. Their root is the
## external client's working directory, which the Godot process cannot know.
## The strategy checks plausible roots and fails closed instead of mutating them.
var config_merge_project_paths: PackedStringArray = PackedStringArray()
## True when the client keeps the FIRST definition of a duplicated server and
## ignores later ones (omp-native: project `.omp/mcp.json` precedes
## `.omp/.mcp.json`, then the user scope's `mcp.json` precedes `.mcp.json`).
## Flips every merge-tier fold — Configure target, status verification, and
## the manual/Open-Reveal target resolution — from last-definition-wins to
## first. With tiers declared in read order, Configure updates the tier that
## already owns the server instead of creating a higher-priority entry that
## shadows a lower tier's user state (#1085).
var config_merge_first_wins: bool = false
## Directory-glob templates whose matches make the user-scope config
## destination unknowable from the editor (omp: `~/.omp/profiles/*`). One `*`
## per directory segment, expanded by `McpPathTemplate.expand_path_candidates`.
## Any existing match means a named profile may own the user scope: the active
## profile is chosen per client launch and never persisted where the editor can
## read it, so Configure and Remove fail closed with the matched paths and
## status reports the ambiguity instead of green-lighting the default file.
var config_scope_globs: PackedStringArray = PackedStringArray()
## Environment names that make the effective config destination unknown.
## Configure, Remove and status refuse while any is set; values are not reported.
var config_scope_envs: PackedStringArray = PackedStringArray()
## Top-level server-name denylist in the first declared merge tier (the default
## primary file), independent of which tier owns the entry. Configure and status
## refuse a suppressed name; Remove preserves this user state. Empty disables it.
var config_denylist_key: String = ""
## Effective-entry enable toggle and primary force-enable list. Boolean false
## and strings "false"/"0" suppress the entry unless the allowlist names it.
## The denylist still wins. Empty keys disable these checks.
var config_enabled_key: String = ""
var config_allowlist_key: String = ""
## De-duplicate persistent path-ambiguity warnings across recurring status
## refreshes. The actionable message still returns on every resolution; only
## the editor-console echo is single-shot until the ambiguity clears/changes.
+132 -23
View File
@@ -62,6 +62,12 @@ static func _configure_merged(
var launch_error := command_launch_error(client, launch)
if not launch_error.is_empty():
return {"status": "error", "message": launch_error}
## A matched `config_scope_globs` directory (omp named profiles) means the
## user scope may live elsewhere for the running client; writing the
## default tiers would false-succeed (#1085).
var scope_error := _scope_ambiguity_error(client)
if not scope_error.is_empty():
return {"status": "error", "message": scope_error}
var project := _load_project_definitions(client, server_name, project_roots)
if not project.get("ok", false):
return {"status": "error", "message": str(project.get("error", "Cannot inspect project config tiers"))}
@@ -74,16 +80,26 @@ static func _configure_merged(
var tiers: Array = loaded.get("tiers", [])
if tiers.is_empty():
return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name}
var denylist_error := _denylist_error(client, tiers, server_name)
if not denylist_error.is_empty():
return {"status": "error", "message": denylist_error}
var target_index := 0
for index in range(tiers.size()):
var config: Dictionary = tiers[index]["data"]
var holder := _walk_path(config, select_server_key_path(config, client))
if holder is Dictionary and holder.has(server_name):
target_index = index
## First-definition-wins clients keep the earliest defining tier;
## later tiers are dead, so updating one of those changes nothing.
if _first_wins(client):
break
var tier: Dictionary = tiers[target_index]
var config: Dictionary = tier["data"]
var holder := _ensure_path(config, select_server_key_path(config, client))
var existing: Variant = holder.get(server_name, null)
var disabled_error := _disabled_entry_error(client, tiers, server_name, existing, str(tier["path"]))
if not disabled_error.is_empty():
return {"status": "error", "message": disabled_error}
holder[server_name] = build_entry(client, server_url, existing, launch)
var path := str(tier["path"])
# F5: refuse to re-serialize a tier whose parsed integers above 2^53 would
@@ -148,7 +164,7 @@ static func check_status_details(
return _entry_status_details(client, holder[server_name], server_url, launch)
## Verify the effective last definition after applying the client's merge order.
## Verify the effective definition after applying the client's merge order.
static func _check_status_merged(
client: McpClient,
server_name: String,
@@ -156,39 +172,60 @@ static func _check_status_merged(
launch: Dictionary,
project_roots: PackedStringArray,
) -> Dictionary:
var scope_error := _scope_ambiguity_error(client)
if not scope_error.is_empty():
return {"status": McpClient.Status.ERROR, "error_msg": scope_error}
var allow_comments := _status_allows_comments(client)
var loaded := _load_merge_tiers(client, allow_comments)
if not loaded.get("ok", false):
return {"status": McpClient.Status.ERROR, "error_msg": str(loaded.get("error", "Cannot read merged config tiers"))}
var effective: Variant = null
var effective_path := ""
for tier in loaded.get("tiers", []):
var config: Dictionary = tier["data"]
var holder := _walk_path(config, select_server_key_path(config, client))
if holder is Dictionary and holder.has(server_name):
effective = holder[server_name]
effective_path = str(tier["path"])
## First-definition-wins: the earliest defining tier is the one the
## client actually reads; stop instead of overwriting with a dead one.
if _first_wins(client):
break
var project := _load_project_definitions(client, server_name, project_roots, allow_comments)
if not project.get("ok", false):
return {"status": McpClient.Status.ERROR, "error_msg": str(project.get("error", "Cannot inspect project config tiers"))}
var project_tiers: Array = project.get("tiers", [])
if effective != null or not project_tiers.is_empty():
var denylist_error := _denylist_error(client, loaded.get("tiers", []), server_name)
if not denylist_error.is_empty():
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": denylist_error}
if not project_tiers.is_empty():
# Last-definition-wins mirrors the global-tier fold above and how
# pi-codemode-mcp merges project tiers on disk. Earlier tiers are dead
# once a later one defines the same server, so an early-stale entry
# doesn't make Pi's effective config drift (codex-review finding F2).
# Pass `[latest]` to `_project_override_message` so the error names only
# the file the user actually has to edit.
var latest: Dictionary = project_tiers[project_tiers.size() - 1]
var details := _entry_status_details(client, latest["entry"], server_url, launch)
# The project tier that drives the client's effective config: the
# latest for last-wins clients (pi-codemode-mcp merges project tiers
# that way; earlier tiers are dead once a later one defines the same
# server — codex-review finding F2), the earliest for first-wins
# clients (omp reads `.omp/mcp.json` before `.omp/.mcp.json`). Pass
# `[effective_tier]` to `_project_override_message` so the error names
# only the file the user actually has to edit.
var effective_index := 0 if _first_wins(client) else project_tiers.size() - 1
var effective_tier: Dictionary = project_tiers[effective_index]
var disabled_error := _disabled_entry_error(client, loaded.get("tiers", []), server_name, effective_tier["entry"], str(effective_tier["path"]))
if not disabled_error.is_empty():
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": disabled_error}
var details := _entry_status_details(client, effective_tier["entry"], server_url, launch)
if details.get("status") != McpClient.Status.CONFIGURED:
## Keep `owned` from the effective entry: the post-update migration
## decides from it whether this mismatch is ours to repin.
var mismatch := details.duplicate()
mismatch["status"] = McpClient.Status.CONFIGURED_MISMATCH
mismatch["error_msg"] = _project_override_message([latest], "update or remove", client.display_name, server_name)
mismatch["error_msg"] = _project_override_message([effective_tier], "update or remove", client.display_name, server_name)
return mismatch
return {"status": McpClient.Status.CONFIGURED, "error_msg": ""}
if effective == null:
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
var disabled_error := _disabled_entry_error(client, loaded.get("tiers", []), server_name, effective, effective_path)
if not disabled_error.is_empty():
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": disabled_error}
return _entry_status_details(client, effective, server_url, launch)
@@ -247,6 +284,12 @@ static func remove(
static func _remove_merged(
client: McpClient, server_name: String, project_roots: PackedStringArray
) -> Dictionary:
## 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).
var scope_error := _scope_ambiguity_error(client)
if not scope_error.is_empty():
return {"status": "error", "message": scope_error}
var project := _load_project_definitions(client, server_name, project_roots)
if not project.get("ok", false):
return {"status": "error", "message": str(project.get("error", "Cannot inspect project config tiers"))}
@@ -633,6 +676,63 @@ static func _uses_merge_tiers(client: McpClient) -> bool:
var templates = client.get("config_merge_path_templates")
return templates is Dictionary and not templates.is_empty()
## True when the client resolves duplicate definitions first-wins (see
## `McpClient.config_merge_first_wins`). Dynamic get keeps a mixed-snapshot
## self-update parse-safe against an older McpClient base (#398/#736).
static func _first_wins(client: McpClient) -> bool:
return bool(client.get("config_merge_first_wins"))
## Fail-closed gate for clients whose user scope can be relocated by something
## the editor cannot observe (omp named profiles). Any directory matching a
## declared `config_scope_globs` template means the effective destination is
## ambiguous. Declared relocation environment names also cause refusal.
static func _scope_ambiguity_error(client: McpClient) -> String:
var env_names: Variant = client.get("config_scope_envs")
if env_names is PackedStringArray:
for env_name in env_names:
if not McpPathTemplate.env_lookup(env_name).is_empty():
return "%s is set and may relocate %s configuration. Confirm the active client config path and edit the entry manually; default-profile files were not changed." % [env_name, client.display_name]
var globs: Variant = client.get("config_scope_globs")
if not (globs is PackedStringArray):
return ""
var found := PackedStringArray()
for template in globs:
for candidate in McpPathTemplate.expand_path_candidates(String(template)):
if DirAccess.dir_exists_absolute(candidate) and not found.has(candidate):
found.append(candidate)
if found.is_empty():
return ""
return (
"%s named profiles found at %s. A named profile reads only its own agent config and the active profile is chosen per %s launch, so this editor cannot resolve the effective destination; edit the entry manually."
% [client.display_name, ", ".join(found), client.display_name]
)
static func _disabled_entry_error(client: McpClient, tiers: Array, server_name: String, entry: Variant, path: String) -> String:
if client.config_enabled_key.is_empty() or not (entry is Dictionary):
return ""
var enabled: Variant = entry.get(client.config_enabled_key)
var disabled: bool = (enabled is bool and not enabled) or (enabled is String and enabled.to_lower() in ["false", "0"])
if not disabled:
return ""
if not client.config_allowlist_key.is_empty() and not tiers.is_empty():
var allowed: Variant = tiers[0]["data"].get(client.config_allowlist_key)
if allowed is Array and allowed.has(server_name):
return ""
return "%s is disabled by %s in %s. Re-enable it in %s before configuring; all files were left unchanged." % [server_name, client.config_enabled_key, path, client.display_name]
static func _denylist_error(client: McpClient, tiers: Array, server_name: String) -> String:
var key: Variant = client.get("config_denylist_key")
if not (key is String) or String(key).is_empty() or tiers.is_empty():
return ""
var primary: Dictionary = tiers[0]
var denylist: Variant = primary["data"].get(key, null)
if denylist is Array and denylist.has(server_name):
return "%s is disabled by %s in %s. Review that primary config manually before configuring; all files were left unchanged." % [server_name, key, primary["path"]]
return ""
## Resolve the global config tiers in the client's documented merge order.
static func _merge_paths(client: McpClient) -> PackedStringArray:
@@ -765,11 +865,12 @@ static func _write_transaction(writes: Array[Dictionary]) -> Dictionary:
## land on the file that actually drives Pi's effective config.
##
## Resolution order (codex round 3, F-3-4):
## 1. Latest project tier containing the entry — F2 last-wins means the
## latest of `.pi/mcp.json` and `.mcp.json` wins (matches `_check_status_merged`).
## 2. Latest global tier containing the entry — already iterated in
## merge order, last-iterated-wins (same loop pattern as
## `manual_target_details` lines 660-665).
## 1. The project tier that wins the client's fold — the latest of
## `.pi/mcp.json` and `.mcp.json` for last-wins clients, the earliest
## for first-wins clients (matches `_check_status_merged`).
## 2. The global tier that wins the client's fold — for last-wins clients
## the last-iterated match, for first-wins clients the first match
## (tiers arrive in the client's read order).
## 3. "" when no tier has the entry — caller decides what fallback
## (`path_template`) to use.
##
@@ -789,24 +890,28 @@ static func authoritative_tier_path(
return ""
var project_tiers: Array = project.get("tiers", [])
if not project_tiers.is_empty():
# F2 last-wins: latest project tier is authoritative. When there are
# multiple project tiers, the user-visible status is driven by the
# latest, so the Open/Reveal buttons should send them there too.
var latest: Dictionary = project_tiers[project_tiers.size() - 1]
return str(latest.get("path", ""))
# The project tier that drives the user-visible status (F2: latest for
# last-wins clients, earliest for first-wins ones), so the Open/Reveal
# buttons send the user to the file they actually have to edit.
var effective_index := 0 if _first_wins(client) else project_tiers.size() - 1
var effective_tier: Dictionary = project_tiers[effective_index]
return str(effective_tier.get("path", ""))
var loaded := _load_merge_tiers(client, allow_comments)
if not bool(loaded.get("ok", false)):
return ""
var tiers: Array = loaded.get("tiers", [])
# Iterate in priority order, overwrite `selected_path` each time we
# find a tier containing the entry. With Pi's merge path order
# [mcp.json, .mcp.json] this leaves the higher-precedence `.mcp.json`.
# Iterate in the client's read order. Last-wins clients overwrite
# `selected_path` on every match (Pi's [mcp.json, .mcp.json] order then
# leaves the higher-precedence `.mcp.json`); first-wins clients stop at
# the first match, which is the file the client actually reads.
var selected_path: String = ""
for tier in tiers:
var data: Dictionary = tier.get("data", {})
var holder: Variant = _walk_path(data, select_server_key_path(data, client))
if holder is Dictionary and holder.has(server_name):
selected_path = str(tier.get("path", ""))
if _first_wins(client):
break
return selected_path
@@ -845,6 +950,10 @@ static func manual_target_details(
var holder := _walk_path(config, select_server_key_path(config, client))
if holder is Dictionary and holder.has(server_name):
selected = tier
## First-wins clients read the earliest defining tier; later
## matches are dead and must not redirect the manual flow.
if _first_wins(client):
break
if selected != null:
var config: Dictionary = selected["data"]
return {
+4 -1
View File
@@ -199,7 +199,10 @@ static func _build_json(
target = {"path": resolved_path, "key_path": client.server_key_path}
var target_path := str(target.get("path", resolved_path))
var key_path: PackedStringArray = target.get("key_path", client.server_key_path)
var key := key_path[0] if key_path.size() > 0 else "mcpServers"
## Name the whole server-map path. A nested map (ZCode's `mcp.servers`) must
## not collapse to its first segment, or the instructions place the entry a
## level too high for the client parser to find.
var key := ".".join(key_path) if key_path.size() > 0 else "mcpServers"
if client.command_shape != McpClient.CommandShape.NONE:
var lines: Array[String] = []
var launch_error := McpJsonStrategy.command_launch_error(client, launch)
+2
View File
@@ -37,6 +37,8 @@ const _CLIENT_SCRIPT_PATHS := [
"res://addons/godot_ai/clients/hermes.gd",
"res://addons/godot_ai/clients/pi.gd",
"res://addons/godot_ai/clients/deepseek_harness.gd",
"res://addons/godot_ai/clients/zcode.gd",
"res://addons/godot_ai/clients/omp.gd",
]
static var _instances: Array[McpClient] = []
+64
View File
@@ -0,0 +1,64 @@
@tool
extends McpClient
## Oh My Pi (omp): https://github.com/can1357/oh-my-pi
## omp keeps the FIRST definition of a duplicated server: project
## `.omp/mcp.json` shadows `.omp/.mcp.json`, then the active user scope's
## `mcp.json` shadows its `.mcp.json`. The tiers below are declared in that
## read order with `config_merge_first_wins`, so Configure updates the file
## that already owns the server instead of shadowing a compatibility
## entry's user state (#1085). omp never writes the compatibility files
## itself; this adapter updates an existing compatibility entry in place.
## A fresh entry is created only on tiers[0].
##
## The user scope can be relocated per launch (`omp --profile`,
## OMP_PROFILE/PI_PROFILE). `config_scope_globs` fails Configure/Remove
## closed while any named profile exists, because the active profile is
## chosen at client launch and is not persisted anywhere the editor can
## read — status reports the ambiguity instead of green-lighting the
## default file.
func _init() -> void:
id = "omp"
display_name = "Oh My Pi"
config_type = "json"
path_template = {
"unix": "~/.omp/agent/mcp.json",
"windows": "$USERPROFILE/.omp/agent/mcp.json",
}
## Declared in omp's read order (primary first, compatibility second);
## `_first_wins` folds stop at the first tier defining the server, so a
## write updates the effective file and a fresh entry lands on the
## primary. Status still verifies against every existing tier.
config_merge_path_templates = {
"unix": PackedStringArray([
"~/.omp/agent/mcp.json",
"~/.omp/agent/.mcp.json",
]),
"windows": PackedStringArray([
"$USERPROFILE/.omp/agent/mcp.json",
"$USERPROFILE/.omp/agent/.mcp.json",
]),
}
config_merge_project_paths = PackedStringArray([".omp/mcp.json", ".omp/.mcp.json"])
config_merge_first_wins = true
config_scope_globs = PackedStringArray(["~/.omp/profiles/*"])
config_scope_envs = PackedStringArray(["PI_CODING_AGENT_DIR", "PI_CONFIG_DIR", "OMP_PROFILE", "PI_PROFILE"])
## The primary user file owns this override even for a compatibility entry.
config_denylist_key = "disabledServers"
config_enabled_key = "enabled"
config_allowlist_key = "enabledServers"
server_key_path = PackedStringArray(["mcpServers"])
command_shape = McpClient.CommandShape.FLAT
command_legacy_keys = PackedStringArray(["url", "headers", "type"])
## Seeded only on a fresh entry; reconfigure preserves the effective
## entry's values. OMP_MCP_TIMEOUT_MS may override this per-server
## timeout.
command_initial_fields = {"enabled": true, "timeout": 300000}
command_timeout_fields = PackedStringArray(["timeout"])
command_user_fields = PackedStringArray(["enabled", "timeout", "env", "cwd"])
## omp creates `~/.omp/agent` on first launch (agent.db and friends)
## before any MCP server is configured, so the directory is the honest
## install signal — the config leaf may not exist yet.
detect_paths = PackedStringArray(["~/.omp/agent"])
+1
View File
@@ -0,0 +1 @@
uid://b1daig0jwxvfb
+40
View File
@@ -0,0 +1,40 @@
@tool
extends McpClient
## ZCode (Z.AI's GLM coding agent): https://zcode.z.ai/en/docs/mcp-services
## User-scope MCP servers live in `~/.zcode/cli/config.json` under the nested
## `mcp.servers` map; the workspace scope is `<project>/.zcode/config.json`,
## which this descriptor does not target because the client's working directory
## is unknown to the editor. Entries are flat command/args/env, and
## `type: "stdio"` is what the settings form, the bundled plugin manifest, and
## working third-party integrations write - it also repins a stale
## `type: "http"` left on a hand-added remote entry. `enable` (singular) is
## ZCode's documented user-state key (absence means enabled), so it is
## preserved rather than written.
##
## Manual-only: adding any `.zcode` server makes ZCode skip `~/.agents/mcp.json`
## for that scope entirely (documented `.zcode`-first rule, no merging). An
## automatic write could therefore silently disable every server a user keeps in
## the fallback, so Configure and Remove only render the manual entry; moving
## those entries into the native file stays the user's explicit choice.
func _init() -> void:
id = "zcode"
display_name = "ZCode"
config_type = "json"
## A native write would shadow `~/.agents/mcp.json`; see the class comment.
automatic_config_edits = false
path_template = {
"unix": "~/.zcode/cli/config.json",
"windows": "$USERPROFILE/.zcode/cli/config.json",
}
server_key_path = PackedStringArray(["mcp", "servers"])
command_shape = McpClient.CommandShape.FLAT
command_transport_key = "type"
command_transport_value = "stdio"
command_legacy_keys = PackedStringArray(["url", "headers"])
command_user_fields = PackedStringArray(["env", "enable"])
## ZCode creates `~/.zcode` on first launch; the config leaf may not exist
## yet, so the directory is the installed signal.
detect_paths = PackedStringArray(["~/.zcode"])
+1
View File
@@ -0,0 +1 @@
uid://b0xpusa88vykn