2026-10-09 12:49:07 +02:00

243 lines
11 KiB
GDScript

@tool
extends RefCounted
## Repository for the project-root .mcp.json file.
##
## Owns all plugin access to .mcp.json: it reads (path resolution, the
## godot-mcp-toolkit server entry's env vars, read-only detection) AND builds
## the plugin-initiated write. The write is OS-aware — it emits a per-OS
## mcpServers command/args (bare npx on macOS/Linux, cmd /c npx on Windows; a
## GODOT_MCP_DEV_SERVER_PATH override emits the local-dist node form). The file
## stays user-owned for edits; the plugin writes it only on explicit user action
## (the dock's / Tools-menu's "Write .mcp.json"), preserving an existing file's
## GODOT_MCP_* env keys.
##
## UI-free by design: the write reports its outcome through an injected
## on_result Callable so the overwrite-confirmation dialog stays in the
## shared write flow and the feedback (toast) stays with each caller; this
## repository touches only the file and never reaches EditorInterface /
## a dialog / a toast.
# Bundled env/shape skeleton: the write reads its env block for the base env
# (GODOT_MCP_CONFIG_VERSION); the per-OS builder overlays command/args.
const _TEMPLATE_PATH := "res://addons/godot_mcp_toolkit/.mcp.json.template"
# The mcpServers key the toolkit owns in .mcp.json.
const _SERVER_KEY := "godot-mcp-toolkit"
# Released npm package the client launches — the default (release) command base.
const _RELEASE_PACKAGE := "@npgamedev/godot-mcp-server"
# Optional env override: set to a local dist/index.js and the builder emits the
# dev form ([code]node <that path>[/code]) instead of the released npx form — one
# path serving Windows dogfooding and pointing a Mac at a local build. The path is
# read from the environment at build time; no machine path is ever hardcoded here.
const _DEV_SERVER_PATH_ENV := "GODOT_MCP_DEV_SERVER_PATH"
# Reused JSON parser — avoids allocating a JSON on every poll (the dock re-checks
# .mcp.json validity ~1s). Editor-only + main-thread, so one shared instance is
# safe; each parse() overwrites the prior data.
static var _json := JSON.new()
static func get_mcp_json_path() -> String:
return ProjectSettings.globalize_path("res://") + ".mcp.json"
static func has_mcp_json() -> bool:
return FileAccess.file_exists(get_mcp_json_path())
static func get_all_env_vars() -> Dictionary:
var raw := _read_server_env()
# Env vars are strings by definition. JSON may store them as integers
# (e.g. "GODOT_MCP_READ_ONLY": 1 instead of "1"). Coerce all values
# to String so consumers can safely compare with == "1" etc.
var coerced: Dictionary = {}
for key in raw:
coerced[key] = str(raw[key])
return coerced
## Parse .mcp.json WITHOUT spamming the console. JSON.parse_string() ERR_PRINTs on
## a malformed file ("Parse JSON failed. Error at line ..."), and the dock re-checks
## validity every ~1s — so use JSON.new().parse(), which reports failure via its
## return code silently. Returns the parsed object, or null if the file is missing /
## not valid JSON / not a JSON object.
static func _parse_mcp_json():
var path := get_mcp_json_path()
if not FileAccess.file_exists(path):
return null
var text := FileAccess.get_file_as_string(path)
if _json.parse(text) != OK or not _json.data is Dictionary:
return null
return _json.data
static func _read_server_env() -> Dictionary:
var parsed = _parse_mcp_json()
if parsed == null:
return {}
return _extract_server_env(parsed)
## Navigate a parsed .mcp.json / template object to the toolkit server entry's env
## dict. Returns {} when there is no matching server entry or no env dict. Shared
## by the live-file read and the template-base read so the shape-walk lives once.
static func _extract_server_env(parsed: Dictionary) -> Dictionary:
var servers: Dictionary = parsed.get("mcpServers", {})
var server_key := _find_server_key(servers)
if server_key.is_empty():
return {}
if not servers[server_key] is Dictionary:
return {}
var server_entry: Dictionary = servers[server_key]
var env = server_entry.get("env", {})
return env if env is Dictionary else {}
static func _find_server_key(servers: Dictionary) -> String:
if servers.has(_SERVER_KEY):
return _SERVER_KEY
for key in servers:
if "godot-mcp" in str(key).to_lower():
return str(key)
return ""
## True iff .mcp.json sets GODOT_MCP_READ_ONLY=1 on the server entry.
## A shared query (dock panel + button, Info dialog) — one parse, one home.
## Callers that need it twice in a row cache the result (the dock does so
## per status refresh) rather than re-parsing.
static func is_read_only() -> bool:
var env := get_all_env_vars()
return env.get("GODOT_MCP_READ_ONLY", "") == "1"
## True iff .mcp.json exists but is NOT valid JSON (or not a JSON object) — the
## MCP client can't parse it, so it won't launch the server (and get_all_env_vars
## silently returns {}). A live FACT about the file's current content — safe to
## check on the dock's 1s timer like file presence, unlike read-only (server-state).
static func is_malformed() -> bool:
return has_mcp_json() and _parse_mcp_json() == null
## True iff a .mcp.json already exists at the project root, so a write would
## overwrite it. The shared write flow uses this to decide whether to show the
## overwrite confirmation dialog before calling write_from_template().
static func needs_overwrite_confirm() -> bool:
return FileAccess.file_exists(get_mcp_json_path())
## Build the mcpServers server entry ({command, args}) for [param os_name] — the
## pure core of the OS-aware write. macOS and Linux emit a bare npx (release) or
## node (dev); Windows uses cmd /c npx (npx is a .cmd shim there). The release
## form runs the published package via npx; the dev form (when [param
## dev_server_path] is set) runs node against that local dist entry, serving both
## Windows dogfooding and pointing at a local build. No env is emitted here — the
## caller supplies it (the template's GODOT_MCP_CONFIG_VERSION plus an existing
## file's preserved GODOT_MCP_* keys).
static func build_server_entry(os_name: String, dev_server_path: String) -> Dictionary:
var is_dev := not dev_server_path.is_empty()
var command := ""
var args: Array = []
match os_name:
"Windows":
# node is a real exe on PATH; npx is a .cmd shim that needs cmd /c.
command = "node" if is_dev else "cmd"
args = [dev_server_path] if is_dev else ["/c", "npx", "-y", _RELEASE_PACKAGE]
_:
# macOS, Linux, and any unknown OS: no shim — a bare command. A GUI-launched
# client resolves bare npx to a version-manager node via its captured shell env.
command = "node" if is_dev else "npx"
args = [dev_server_path] if is_dev else ["-y", _RELEASE_PACKAGE]
return {"command": command, "args": args}
## Write .mcp.json for the current OS, reporting the outcome through on_result so
## the UI (toast) stays caller-side. on_result is called once with:
## (ok: bool, message: String, severity: int, tooltip: String)
## — severity is the editor-toast scale (0 info / 1 warning / 2 error), which
## callers forward straight to their toast. Missing template -> error report;
## existing file with force_overwrite == false -> a "needs confirm" report
## (defensive — the shared write flow pre-checks via needs_overwrite_confirm());
## otherwise build the OS-aware content (see build_server_entry) and report
## success (info, with the destination as the tooltip) or the open failure
## (error). UI-free: no dialog, no EditorInterface, no toast.
static func write_from_template(force_overwrite: bool, on_result: Callable) -> void:
if not FileAccess.file_exists(_TEMPLATE_PATH):
on_result.call(false, "Template not found: " + _TEMPLATE_PATH, 2, "")
return
var dest := get_mcp_json_path()
if not force_overwrite and needs_overwrite_confirm():
# Defensive: the dock should have shown its confirm dialog first. Report
# without writing so no overwrite happens unconfirmed.
on_result.call(false, ".mcp.json already exists — overwrite not confirmed", 1, dest)
return
_do_write(dest, _build_content(), on_result)
## Performs the actual write of `content` to `dest` and reports via on_result.
## Private to the repository — the public entry point is write_from_template().
static func _do_write(dest: String, content: String, on_result: Callable) -> void:
var file := FileAccess.open(dest, FileAccess.WRITE)
if file == null:
on_result.call(
false, "Failed to write .mcp.json (err %d)" % FileAccess.get_open_error(), 2, ""
)
return
file.store_string(content)
file.close()
on_result.call(true, "MCP: .mcp.json written", 0, "Wrote to " + dest)
## Layer the emitted server env, most-general first: the template's base env
## (GODOT_MCP_CONFIG_VERSION), then an EXISTING file's env so a user's own
## GODOT_MCP_* keys (port pins, read-only, token/project paths) survive a rewrite.
## Pure — neither input is mutated; [param existing_env] is {} for a first-time
## create. Returns the merged env for the server entry.
static func merge_server_env(base_env: Dictionary, existing_env: Dictionary) -> Dictionary:
var env: Dictionary = base_env.duplicate()
env.merge(existing_env, true)
return env
## Build the OS-aware .mcp.json content — the full document string. Delegates the
## per-OS server entry to [method _build_entry] and wraps it (see
## [method _stringify_entry]).
static func _build_content() -> String:
return _stringify_entry(_build_entry())
## Serialize a server entry as the complete .mcp.json document string (tab-indented,
## trailing newline).
static func _stringify_entry(entry: Dictionary) -> String:
var document := {"mcpServers": {_SERVER_KEY: entry}}
return JSON.stringify(document, "\t") + "\n"
## Build the OS-aware server entry ({command, args, env}): read the dev-server
## override, build the per-OS command/args, and layer the env so an existing
## file's user keys are preserved (see merge_server_env).
static func _build_entry() -> Dictionary:
var dev_server_path := OS.get_environment(_DEV_SERVER_PATH_ENV)
var entry := build_server_entry(OS.get_name(), dev_server_path)
# Materialize each env read with duplicate() BEFORE the next read — the reads
# share one JSON parser, so a later parse would invalidate an earlier returned
# slice. The template base guarantees GODOT_MCP_CONFIG_VERSION; an existing
# file's env is preserved so a rewrite never strips a user's GODOT_MCP_* keys.
var base_env := _read_template_env().duplicate()
var existing_env: Dictionary = {}
if has_mcp_json():
existing_env = _read_server_env().duplicate()
entry["env"] = merge_server_env(base_env, existing_env)
return entry
## The bundled template's server-entry env dict (the write's base env), or {} when
## the template is missing / not valid JSON.
static func _read_template_env() -> Dictionary:
var text := FileAccess.get_file_as_string(_TEMPLATE_PATH)
if _json.parse(text) != OK or not _json.data is Dictionary:
return {}
return _extract_server_env(_json.data)