297 lines
11 KiB
GDScript
297 lines
11 KiB
GDScript
@tool
|
|
extends RefCounted
|
|
|
|
# client.* — manage MCP client config files so users don't have to hand-edit them.
|
|
# Each client has its own config schema; we handle three shapes:
|
|
# "mcp_servers" → {"mcpServers": {"<name>": {command, args}}}
|
|
# Used by Claude Code, Claude Desktop, Cursor, Windsurf.
|
|
# "vscode_servers" → {"servers": {"<name>": {command, args}}}
|
|
# VS Code's native MCP config.
|
|
# "continue_experimental" → {"experimental": {"modelContextProtocolServers":
|
|
# [{"transport": {"type": "stdio", command, args}}]}}
|
|
# Continue.dev's MCP config shape (array, not dict).
|
|
#
|
|
# Clients requiring JSONC (with comments) or non-file storage — Zed, VS Code
|
|
# user-scope settings.json, Cline — aren't supported yet. Use manual config
|
|
# for those; see the README per-client section.
|
|
|
|
const SERVER_NAME := "godot-agent-tools"
|
|
const SERVER_COMMAND := "npx"
|
|
const SERVER_ARGS := ["-y", "godot-agent-tools-mcp"]
|
|
|
|
|
|
static func list_clients(_params: Dictionary) -> Dictionary:
|
|
var out: Array = []
|
|
for client_id in _client_ids():
|
|
var info := _client_info(client_id)
|
|
if info.has("_error"):
|
|
out.append({"client": client_id, "error": info._error})
|
|
continue
|
|
var res := _read_config(info.path)
|
|
var cfg: Dictionary = res.cfg
|
|
var installed := _has_server_entry(cfg, info.format)
|
|
out.append({
|
|
"client": client_id,
|
|
"config_path": info.path,
|
|
"exists": _path_exists(info.path),
|
|
"configured": installed,
|
|
"unparseable": res.unparseable,
|
|
})
|
|
return _ok({"clients": out})
|
|
|
|
|
|
static func configure(params: Dictionary) -> Dictionary:
|
|
var client_id: String = params.get("client", "")
|
|
var overwrite: bool = params.get("overwrite", false)
|
|
if client_id == "":
|
|
return _err(-32602, "missing 'client' (supported: %s)" % ", ".join(_client_ids()))
|
|
var info := _client_info(client_id)
|
|
if info.has("_error"):
|
|
return _err(-32602, info._error)
|
|
|
|
var res := _read_config(info.path)
|
|
if res.unparseable:
|
|
return _err(-32001, "existing config is not valid JSON — refusing to overwrite (fix or remove it first): %s" % info.path)
|
|
var cfg: Dictionary = res.cfg
|
|
if _has_server_entry(cfg, info.format) and not overwrite:
|
|
return _ok({"client": client_id, "path": info.path, "status": "already_configured"})
|
|
|
|
_set_server_entry(cfg, info.format)
|
|
var write_err := _write_config(info.path, cfg)
|
|
if write_err != "":
|
|
return _err(-32001, write_err)
|
|
return _ok({"client": client_id, "path": info.path, "status": "configured"})
|
|
|
|
|
|
static func remove(params: Dictionary) -> Dictionary:
|
|
var client_id: String = params.get("client", "")
|
|
if client_id == "":
|
|
return _err(-32602, "missing 'client'")
|
|
var info := _client_info(client_id)
|
|
if info.has("_error"):
|
|
return _err(-32602, info._error)
|
|
|
|
var res := _read_config(info.path)
|
|
if res.unparseable:
|
|
return _err(-32001, "existing config is not valid JSON — refusing to modify (fix or remove it first): %s" % info.path)
|
|
var cfg: Dictionary = res.cfg
|
|
if not _has_server_entry(cfg, info.format):
|
|
return _ok({"client": client_id, "path": info.path, "status": "not_present"})
|
|
|
|
_clear_server_entry(cfg, info.format)
|
|
var write_err := _write_config(info.path, cfg)
|
|
if write_err != "":
|
|
return _err(-32001, write_err)
|
|
return _ok({"client": client_id, "path": info.path, "status": "removed"})
|
|
|
|
|
|
static func _client_ids() -> Array:
|
|
return [
|
|
"claude_code_project",
|
|
"claude_code_user",
|
|
"claude_desktop",
|
|
"cursor_project",
|
|
"cursor_user",
|
|
"windsurf_user",
|
|
"continue_user",
|
|
"vscode_project",
|
|
]
|
|
|
|
|
|
static func _client_info(client_id: String) -> Dictionary:
|
|
var home := OS.get_environment("USERPROFILE")
|
|
if home == "":
|
|
home = OS.get_environment("HOME")
|
|
if home == "":
|
|
return {"_error": "cannot resolve home directory (neither USERPROFILE nor HOME set)"}
|
|
|
|
match client_id:
|
|
"claude_code_project":
|
|
return {"path": ProjectSettings.globalize_path("res://.mcp.json"), "format": "mcp_servers"}
|
|
"claude_code_user":
|
|
return {"path": home.path_join(".claude.json"), "format": "mcp_servers"}
|
|
"cursor_project":
|
|
return {"path": ProjectSettings.globalize_path("res://.cursor/mcp.json"), "format": "mcp_servers"}
|
|
"cursor_user":
|
|
return {"path": home.path_join(".cursor").path_join("mcp.json"), "format": "mcp_servers"}
|
|
"windsurf_user":
|
|
return {"path": home.path_join(".codeium").path_join("windsurf").path_join("mcp_config.json"), "format": "mcp_servers"}
|
|
"continue_user":
|
|
return {"path": home.path_join(".continue").path_join("config.json"), "format": "continue_experimental"}
|
|
"vscode_project":
|
|
return {"path": ProjectSettings.globalize_path("res://.vscode/mcp.json"), "format": "vscode_servers"}
|
|
"claude_desktop":
|
|
var os_name := OS.get_name()
|
|
var path: String
|
|
if os_name == "macOS":
|
|
path = home.path_join("Library").path_join("Application Support").path_join("Claude").path_join("claude_desktop_config.json")
|
|
elif os_name == "Windows":
|
|
var appdata := OS.get_environment("APPDATA")
|
|
if appdata == "":
|
|
return {"_error": "APPDATA env var not set on Windows"}
|
|
path = appdata.path_join("Claude").path_join("claude_desktop_config.json")
|
|
else:
|
|
path = home.path_join(".config").path_join("Claude").path_join("claude_desktop_config.json")
|
|
return {"path": path, "format": "mcp_servers"}
|
|
_:
|
|
return {"_error": "unknown client: %s (supported: %s)" % [client_id, ", ".join(_client_ids())]}
|
|
|
|
|
|
static func _path_exists(path: String) -> bool:
|
|
return FileAccess.file_exists(path)
|
|
|
|
|
|
# Returns {"cfg": Dictionary, "unparseable": bool}. unparseable=true means the
|
|
# file exists with non-empty content that is NOT a valid JSON object — callers
|
|
# that go on to WRITE must abort, since merging into {} would wipe the user's
|
|
# data (e.g. a large ~/.claude.json with a transient syntax error, or a JSONC
|
|
# file with comments that JSON.parse_string can't handle).
|
|
static func _read_config(path: String) -> Dictionary:
|
|
var content := ""
|
|
if _path_exists(path):
|
|
var f := FileAccess.open(path, FileAccess.READ)
|
|
if f:
|
|
content = f.get_as_text()
|
|
f.close()
|
|
if content.strip_edges() == "":
|
|
return {"cfg": {}, "unparseable": false}
|
|
var parsed = JSON.parse_string(content)
|
|
if typeof(parsed) != TYPE_DICTIONARY:
|
|
return {"cfg": {}, "unparseable": true}
|
|
return {"cfg": parsed, "unparseable": false}
|
|
|
|
|
|
static func _write_config(path: String, cfg: Dictionary) -> String:
|
|
var dir_path := path.get_base_dir()
|
|
if not DirAccess.dir_exists_absolute(dir_path):
|
|
var derr := DirAccess.make_dir_recursive_absolute(dir_path)
|
|
if derr != OK:
|
|
return "mkdir failed for %s (%d)" % [dir_path, derr]
|
|
# Back up any existing non-empty file before overwriting, so a mistaken merge
|
|
# or an interrupted write is recoverable.
|
|
if FileAccess.file_exists(path):
|
|
var prev := FileAccess.open(path, FileAccess.READ)
|
|
if prev:
|
|
var prev_content := prev.get_as_text()
|
|
prev.close()
|
|
if prev_content.strip_edges() != "":
|
|
var bak := FileAccess.open(path + ".bak", FileAccess.WRITE)
|
|
if bak:
|
|
bak.store_string(prev_content)
|
|
bak.close()
|
|
var f := FileAccess.open(path, FileAccess.WRITE)
|
|
if f == null:
|
|
return "could not open for write: %s (error %d)" % [path, FileAccess.get_open_error()]
|
|
f.store_string(JSON.stringify(cfg, " "))
|
|
f.close()
|
|
return ""
|
|
|
|
|
|
# --- Format-specific readers/writers -----------------------------------------
|
|
|
|
static func _has_server_entry(cfg: Dictionary, format: String) -> bool:
|
|
match format:
|
|
"mcp_servers":
|
|
var servers = cfg.get("mcpServers")
|
|
return typeof(servers) == TYPE_DICTIONARY and (servers as Dictionary).has(SERVER_NAME)
|
|
"vscode_servers":
|
|
var servers = cfg.get("servers")
|
|
return typeof(servers) == TYPE_DICTIONARY and (servers as Dictionary).has(SERVER_NAME)
|
|
"continue_experimental":
|
|
var exp = cfg.get("experimental")
|
|
if typeof(exp) != TYPE_DICTIONARY:
|
|
return false
|
|
var arr = exp.get("modelContextProtocolServers")
|
|
if typeof(arr) != TYPE_ARRAY:
|
|
return false
|
|
for entry in arr:
|
|
if typeof(entry) == TYPE_DICTIONARY and _entry_matches_continue(entry):
|
|
return true
|
|
return false
|
|
_:
|
|
return false
|
|
|
|
|
|
static func _set_server_entry(cfg: Dictionary, format: String) -> void:
|
|
match format:
|
|
"mcp_servers":
|
|
if typeof(cfg.get("mcpServers")) != TYPE_DICTIONARY:
|
|
cfg["mcpServers"] = {}
|
|
(cfg["mcpServers"] as Dictionary)[SERVER_NAME] = {
|
|
"command": SERVER_COMMAND,
|
|
"args": SERVER_ARGS.duplicate(),
|
|
}
|
|
"vscode_servers":
|
|
if typeof(cfg.get("servers")) != TYPE_DICTIONARY:
|
|
cfg["servers"] = {}
|
|
(cfg["servers"] as Dictionary)[SERVER_NAME] = {
|
|
"command": SERVER_COMMAND,
|
|
"args": SERVER_ARGS.duplicate(),
|
|
}
|
|
"continue_experimental":
|
|
if typeof(cfg.get("experimental")) != TYPE_DICTIONARY:
|
|
cfg["experimental"] = {}
|
|
var exp: Dictionary = cfg["experimental"]
|
|
if typeof(exp.get("modelContextProtocolServers")) != TYPE_ARRAY:
|
|
exp["modelContextProtocolServers"] = []
|
|
var arr: Array = exp["modelContextProtocolServers"]
|
|
# Remove any existing match so we don't duplicate.
|
|
var kept: Array = []
|
|
for entry in arr:
|
|
if typeof(entry) != TYPE_DICTIONARY or not _entry_matches_continue(entry):
|
|
kept.append(entry)
|
|
kept.append({
|
|
"transport": {
|
|
"type": "stdio",
|
|
"command": SERVER_COMMAND,
|
|
"args": SERVER_ARGS.duplicate(),
|
|
},
|
|
})
|
|
exp["modelContextProtocolServers"] = kept
|
|
|
|
|
|
static func _clear_server_entry(cfg: Dictionary, format: String) -> void:
|
|
match format:
|
|
"mcp_servers":
|
|
var servers = cfg.get("mcpServers")
|
|
if typeof(servers) == TYPE_DICTIONARY:
|
|
(servers as Dictionary).erase(SERVER_NAME)
|
|
"vscode_servers":
|
|
var servers = cfg.get("servers")
|
|
if typeof(servers) == TYPE_DICTIONARY:
|
|
(servers as Dictionary).erase(SERVER_NAME)
|
|
"continue_experimental":
|
|
var exp = cfg.get("experimental")
|
|
if typeof(exp) != TYPE_DICTIONARY:
|
|
return
|
|
var arr = exp.get("modelContextProtocolServers")
|
|
if typeof(arr) != TYPE_ARRAY:
|
|
return
|
|
var kept: Array = []
|
|
for entry in arr:
|
|
if typeof(entry) != TYPE_DICTIONARY or not _entry_matches_continue(entry):
|
|
kept.append(entry)
|
|
exp["modelContextProtocolServers"] = kept
|
|
|
|
|
|
# Continue's config uses a flat array with no `name` field — identify our entry
|
|
# by the transport command+args combo.
|
|
static func _entry_matches_continue(entry: Dictionary) -> bool:
|
|
var transport = entry.get("transport")
|
|
if typeof(transport) != TYPE_DICTIONARY:
|
|
return false
|
|
if transport.get("command") != SERVER_COMMAND:
|
|
return false
|
|
var a = transport.get("args")
|
|
if typeof(a) != TYPE_ARRAY or (a as Array).size() < 2:
|
|
return false
|
|
return a[1] == SERVER_ARGS[1]
|
|
|
|
|
|
static func _ok(data) -> Dictionary:
|
|
return {"data": data}
|
|
|
|
|
|
static func _err(code: int, msg: String) -> Dictionary:
|
|
return {"error": {"code": code, "message": msg}}
|