243 lines
11 KiB
GDScript
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)
|