262 lines
12 KiB
GDScript
262 lines
12 KiB
GDScript
@tool
|
|
extends RefCounted
|
|
## project.* command handlers — get_settings, set_setting, layer names.
|
|
|
|
const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd")
|
|
const Coerce = Modules.Coerce
|
|
const Untrusted = Modules.Untrusted
|
|
|
|
const SECRET_KEY_REGEX := "(?i)password|token|secret|key"
|
|
const _VALID_LAYER_CATEGORIES := ["2d_physics", "2d_render", "3d_physics", "3d_render"]
|
|
|
|
|
|
static func register(registry: MCPToolkitCommandRegistry, _server: Node) -> void:
|
|
registry.add("project.get_settings", func(parameters: Dictionary) -> Dictionary:
|
|
return _cmd_project_get_settings(parameters)
|
|
, MCPToolkitCommandOptions.new().mark_read_only().mark_scene_independent())
|
|
registry.add("project.set_setting", func(parameters: Dictionary) -> Dictionary:
|
|
return _cmd_project_set_setting(parameters)
|
|
, MCPToolkitCommandOptions.new().mark_scene_independent())
|
|
registry.add("autoload.manage", func(parameters: Dictionary) -> Dictionary:
|
|
return _cmd_autoload_manage(parameters, _server)
|
|
, MCPToolkitCommandOptions.new().mark_scene_independent())
|
|
registry.add("project.get_layer_names", func(parameters: Dictionary) -> Dictionary:
|
|
return _cmd_get_layer_names(parameters)
|
|
, MCPToolkitCommandOptions.new().mark_read_only().mark_scene_independent())
|
|
registry.add("project.set_layer_names", func(parameters: Dictionary) -> Dictionary:
|
|
return _cmd_set_layer_names(parameters)
|
|
, MCPToolkitCommandOptions.new().mark_scene_independent())
|
|
|
|
|
|
# -- Commands -----------------------------------------------------------------
|
|
|
|
|
|
static func _cmd_project_get_settings(parameters: Dictionary) -> Dictionary:
|
|
var prefix := str(parameters.get("prefix", ""))
|
|
|
|
var regex := RegEx.new()
|
|
var compile_error := regex.compile(SECRET_KEY_REGEX)
|
|
if compile_error != OK:
|
|
return MCPToolkitError.fail("INTERNAL",
|
|
"secret regex failed to compile (err %d)" % compile_error)
|
|
|
|
var settings := {}
|
|
var filtered_secrets := 0
|
|
for property in ProjectSettings.get_property_list():
|
|
var property_name := str(property.get("name", ""))
|
|
if property_name.is_empty() or not property_name.contains("/"):
|
|
continue
|
|
if not prefix.is_empty() and not property_name.begins_with(prefix):
|
|
continue
|
|
if regex.search(property_name) != null:
|
|
filtered_secrets += 1
|
|
continue
|
|
settings[property_name] = Coerce.serialize_value(
|
|
ProjectSettings.get_setting(property_name))
|
|
|
|
return MCPToolkitSuccess.ok({
|
|
"settings": Untrusted.wrap(
|
|
"project_settings", "godot", JSON.stringify(settings)),
|
|
"count": settings.size(),
|
|
"filtered_secret_count": filtered_secrets,
|
|
})
|
|
|
|
|
|
static func _cmd_project_set_setting(parameters: Dictionary) -> Dictionary:
|
|
var key := str(parameters.get("setting", ""))
|
|
if key.is_empty():
|
|
return MCPToolkitError.fail("INVALID_PARAMS", "setting must be a non-empty string")
|
|
# The `mcp_toolkit/limits/*` slice has a dedicated writer — meta.set_limits —
|
|
# which applies floor clamps. Refusing the whole prefix here keeps that the
|
|
# single sanctioned path for toolkit settings (rest go through the dock UI).
|
|
if key.begins_with("mcp_toolkit/"):
|
|
return MCPToolkitError.fail("INVALID_PATH",
|
|
"refusing to write mcp_toolkit/* from project.set_setting (those are the toolkit's own settings — use the dock UI); got key=%s" % key)
|
|
if key.begins_with("mcp/"):
|
|
return MCPToolkitError.fail("INVALID_PATH",
|
|
"refusing to write mcp/* from project.set_setting (those are the toolkit's internal settings); got key=%s" % key)
|
|
if key.begins_with("editor/"):
|
|
return MCPToolkitError.fail("INVALID_PATH",
|
|
"refusing to write editor/* ProjectSettings from project.set_setting (editor-session state, not project config); got key=%s" % key)
|
|
# Autoload guard — registration/unregistration must go through autoload_manage
|
|
# so the editor's singleton cache refreshes immediately.
|
|
if key.begins_with("autoload/"):
|
|
var _al_raw = parameters.get("value", null)
|
|
var _al_str := str(_al_raw) if _al_raw != null else ""
|
|
var _al_name := key.substr("autoload/".length())
|
|
# Registration attempt: value looks like a script path.
|
|
if _al_str.begins_with("*res://") or _al_str.begins_with("res://"):
|
|
return MCPToolkitError.fail("INVALID_PARAMS",
|
|
"Use autoload_manage (action='register', name='%s', script_path='%s') instead of project.set_setting for autoload registration. project.set_setting bypasses the editor's autoload cache, causing unresolved identifier errors until the project is reloaded." % [_al_name, _al_str.lstrip("*")])
|
|
# Unregistration attempt: null, empty, or missing value.
|
|
if _al_raw == null or _al_str.is_empty():
|
|
return MCPToolkitError.fail("INVALID_PARAMS",
|
|
"Use autoload_manage (action='unregister', name='%s') instead of project.set_setting for autoload removal. project.set_setting bypasses the editor's autoload cache." % _al_name)
|
|
if not parameters.has("value"):
|
|
return MCPToolkitError.fail("INVALID_PARAMS", "missing value")
|
|
var raw_value = parameters.get("value", null)
|
|
# Resource-typed values in raw_value are gated through FileGuard
|
|
# via Coerce.coerce_value's Resource branch.
|
|
var coerced = Coerce.coerce_value(raw_value)
|
|
if typeof(coerced) == TYPE_DICTIONARY and (coerced as Dictionary).has("_coerce_error"):
|
|
return MCPToolkitError.fail("INVALID_PARAMS", str(coerced["_coerce_error"]))
|
|
var was_set_before := ProjectSettings.has_setting(key)
|
|
var previous_value = ProjectSettings.get_setting(key) if was_set_before else null
|
|
ProjectSettings.set_setting(key, coerced)
|
|
var save_error := ProjectSettings.save()
|
|
if save_error != OK:
|
|
return MCPToolkitError.fail("SAVE_FAILED",
|
|
"ProjectSettings.save returned %d (key=%s); change is in-memory but not persisted" % [
|
|
save_error, key])
|
|
var response := {
|
|
"key": key,
|
|
"value": Coerce.serialize_value(coerced),
|
|
"was_set_before": was_set_before,
|
|
"previous_value": Coerce.serialize_value(previous_value) if was_set_before else null,
|
|
}
|
|
if key.begins_with("autoload/"):
|
|
response["warning"] = "Modifying autoload/* via project.set_setting — the editor cache won't refresh. Consider using autoload_manage instead."
|
|
return MCPToolkitSuccess.ok(response)
|
|
|
|
|
|
static func _cmd_autoload_manage(parameters: Dictionary, server: Node = null) -> Dictionary:
|
|
var action := str(parameters.get("action", ""))
|
|
if action.is_empty():
|
|
return MCPToolkitError.fail("INVALID_PARAMS", "missing action (register|unregister|list)")
|
|
|
|
match action:
|
|
"register":
|
|
var aname := str(parameters.get("name", ""))
|
|
var script_path := str(parameters.get("script_path", ""))
|
|
if aname.is_empty() or script_path.is_empty():
|
|
return MCPToolkitError.fail("INVALID_PARAMS",
|
|
"register requires name and script_path")
|
|
if not script_path.begins_with("res://"):
|
|
return MCPToolkitError.fail("INVALID_PATH",
|
|
"script_path must start with res:// (got %s)" % script_path)
|
|
if not FileAccess.file_exists(script_path):
|
|
return MCPToolkitError.fail("NOT_FOUND",
|
|
"script not found: %s; create it with script_write first" % script_path)
|
|
var enabled := bool(parameters.get("enabled", true))
|
|
var value := ("*" if enabled else "") + script_path
|
|
# Use EditorPlugin API for immediate editor cache refresh.
|
|
var plugin: EditorPlugin = server.get("editor_plugin") if server != null else null
|
|
if plugin != null and enabled:
|
|
plugin.add_autoload_singleton(aname, script_path)
|
|
else:
|
|
ProjectSettings.set_setting("autoload/" + aname, value)
|
|
var save_error := ProjectSettings.save()
|
|
if save_error != OK:
|
|
return MCPToolkitError.fail("SAVE_FAILED",
|
|
"ProjectSettings.save returned %d" % save_error)
|
|
# Emit settings_changed so GDScriptParser refreshes its compile
|
|
# cache (add_autoload_singleton does this internally; the direct
|
|
# ProjectSettings path does not).
|
|
ProjectSettings.emit_signal("settings_changed")
|
|
var filesystem := EditorInterface.get_resource_filesystem()
|
|
if filesystem != null:
|
|
filesystem.update_file(script_path)
|
|
return MCPToolkitSuccess.ok({"action": "register", "name": aname,
|
|
"script_path": script_path, "enabled": enabled,
|
|
"hint": "Autoload registered and editor cache updated. Reference via get_node('/root/%s')." % aname})
|
|
|
|
"unregister":
|
|
var aname := str(parameters.get("name", ""))
|
|
if aname.is_empty():
|
|
return MCPToolkitError.fail("INVALID_PARAMS", "unregister requires name")
|
|
var key := "autoload/" + aname
|
|
if not ProjectSettings.has_setting(key):
|
|
return MCPToolkitError.fail("NOT_FOUND",
|
|
"no autoload named '%s' in project settings" % aname)
|
|
# Use EditorPlugin API for immediate editor cache refresh.
|
|
var plugin: EditorPlugin = server.get("editor_plugin") if server != null else null
|
|
if plugin != null:
|
|
plugin.remove_autoload_singleton(aname)
|
|
else:
|
|
ProjectSettings.clear(key)
|
|
var save_error := ProjectSettings.save()
|
|
if save_error != OK:
|
|
return MCPToolkitError.fail("SAVE_FAILED",
|
|
"ProjectSettings.save returned %d" % save_error)
|
|
ProjectSettings.emit_signal("settings_changed")
|
|
return MCPToolkitSuccess.ok({"action": "unregister", "name": aname})
|
|
|
|
"list":
|
|
var autoloads: Array = []
|
|
for property in ProjectSettings.get_property_list():
|
|
var property_name := str(property.get("name", ""))
|
|
if not property_name.begins_with("autoload/"):
|
|
continue
|
|
var aname := property_name.substr("autoload/".length())
|
|
var raw_value := str(ProjectSettings.get_setting(property_name))
|
|
var enabled := raw_value.begins_with("*")
|
|
var path := raw_value.lstrip("*")
|
|
autoloads.append({"name": aname, "script_path": path, "enabled": enabled})
|
|
return MCPToolkitSuccess.ok({"action": "list", "autoloads": autoloads,
|
|
"count": autoloads.size()})
|
|
|
|
_:
|
|
return MCPToolkitError.fail("INVALID_PARAMS",
|
|
"unknown action '%s'; must be register|unregister|list" % action)
|
|
|
|
|
|
## Validate the layer category param. Returns null when valid, an
|
|
## MCPToolkitError dict otherwise.
|
|
static func _validate_layer_category(category: String) -> Variant:
|
|
if category.is_empty():
|
|
return MCPToolkitError.fail("INVALID_PARAMS", "missing category")
|
|
if not category in _VALID_LAYER_CATEGORIES:
|
|
return MCPToolkitError.fail("INVALID_PARAMS",
|
|
"invalid category '%s'; must be one of: %s" % [
|
|
category, ", ".join(_VALID_LAYER_CATEGORIES)])
|
|
return null
|
|
|
|
|
|
static func _cmd_get_layer_names(parameters: Dictionary) -> Dictionary:
|
|
var category := str(parameters.get("category", ""))
|
|
var cat_err = _validate_layer_category(category)
|
|
if cat_err != null:
|
|
return cat_err
|
|
|
|
var layers := {}
|
|
for n in range(1, 33):
|
|
var key := "layer_names/%s/layer_%d" % [category, n]
|
|
if ProjectSettings.has_setting(key):
|
|
var name: String = str(ProjectSettings.get_setting(key))
|
|
if not name.is_empty():
|
|
layers[n] = name
|
|
return MCPToolkitSuccess.ok({"category": category, "layers": layers})
|
|
|
|
|
|
static func _cmd_set_layer_names(parameters: Dictionary) -> Dictionary:
|
|
var category := str(parameters.get("category", ""))
|
|
var cat_err = _validate_layer_category(category)
|
|
if cat_err != null:
|
|
return cat_err
|
|
|
|
var raw_layers = parameters.get("layers", null)
|
|
if raw_layers == null or typeof(raw_layers) != TYPE_DICTIONARY:
|
|
return MCPToolkitError.fail("INVALID_PARAMS",
|
|
"layers must be a dictionary mapping layer numbers (1-32) to names")
|
|
|
|
var layers_dict: Dictionary = raw_layers
|
|
for raw_key in layers_dict:
|
|
var n: int = int(raw_key)
|
|
if n < 1 or n > 32:
|
|
return MCPToolkitError.fail("INVALID_PARAMS",
|
|
"layer number %s out of range; must be 1-32" % str(raw_key))
|
|
|
|
var count := 0
|
|
for raw_key in layers_dict:
|
|
var n: int = int(raw_key)
|
|
var layer_name: String = str(layers_dict[raw_key])
|
|
var key := "layer_names/%s/layer_%d" % [category, n]
|
|
ProjectSettings.set_setting(key, layer_name)
|
|
count += 1
|
|
|
|
var save_error := ProjectSettings.save()
|
|
if save_error != OK:
|
|
return MCPToolkitError.fail("SAVE_FAILED",
|
|
"ProjectSettings.save returned %d; changes are in-memory but not persisted" % save_error)
|
|
return MCPToolkitSuccess.ok({"layers_set": count})
|