@tool extends RefCounted const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") ## Handles project settings and filesystem search commands. const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd") var _connection: McpConnection var _debugger_plugin func _init(connection: McpConnection = null, debugger_plugin = null) -> void: _connection = connection _debugger_plugin = debugger_plugin func get_project_setting(params: Dictionary) -> Dictionary: var key: String = params.get("key", "") if key.is_empty(): return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: key") if not ProjectSettings.has_setting(key): return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Setting not found: %s" % key) var value = ProjectSettings.get_setting(key) return { "data": { "key": key, "value": NodeHandler._serialize_value(value), "type": type_string(typeof(value)), } } func set_project_setting(params: Dictionary) -> Dictionary: var key: String = params.get("key", "") if key.is_empty(): return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: key") if not params.has("value"): return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: value") var value = params.get("value") var had_setting := ProjectSettings.has_setting(key) var old_value = ProjectSettings.get_setting(key) if had_setting else null # JSON has no distinct int type: Godot parses `1920` as float. If the # existing setting is TYPE_INT, coerce whole-number floats back to int so # we don't silently flip typed-int settings (viewport_width, etc.) to # floats on disk. See issue #31. if had_setting and typeof(old_value) == TYPE_INT and typeof(value) == TYPE_FLOAT and float(int(value)) == value: value = int(value) ProjectSettings.set_setting(key, value) var err := ProjectSettings.save() if err != OK: if had_setting: ProjectSettings.set_setting(key, old_value) else: ProjectSettings.clear(key) return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to save project settings (error %d)" % err) return { "data": { "key": key, "value": NodeHandler._serialize_value(value), "old_value": NodeHandler._serialize_value(old_value), "type": type_string(typeof(value)), "undoable": false, "reason": "ProjectSettings changes are saved to disk", } } func run_project(params: Dictionary) -> Dictionary: var mode: String = params.get("mode", "main") var autosave: bool = params.get("autosave", true) # Idempotent: a project that's already running satisfies the caller's intent. # Returning INVALID_PARAMS here punished agents that legitimately called run # to ensure the project is playing (87+ installs/day hit the matching # stop-not-running case in telemetry). Surface state via was_already_running # so a caller wanting a *different* scene can detect and stop+restart. if EditorInterface.is_playing_scene(): return { "data": { "mode": mode, "scene": params.get("scene", ""), "autosave": autosave, "was_already_running": true, "undoable": false, "reason": "Project was already running; no action taken", } } var validation_error: Variant = null if mode == "custom": var custom_scene: String = params.get("scene", "") if custom_scene.is_empty(): validation_error = ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: scene (required when mode='custom')") elif mode != "main" and mode != "current": validation_error = ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid mode '%s' — use 'main', 'current', or 'custom'" % mode) if validation_error != null: return validation_error # play_*_scene internally triggers try_autosave() → _save_scene_with_preview() # which renders a preview thumbnail and calls frame processing. If our # WebSocket connection's _process() re-enters during that render, the # engine crashes (SIGABRT in _save_scene_with_preview). Pause processing # around the play call — same pattern as SceneHandler.save_scene. if _connection: _connection.pause_processing = true # try_autosave() reads run/auto_save/save_before_running every call, so # toggling it off around the play call suppresses the save without # touching the user's persisted preference. Issue #81. var autosave_key := "run/auto_save/save_before_running" var editor_settings: EditorSettings = null if not autosave: editor_settings = EditorInterface.get_editor_settings() var prior_autosave: bool = true var restore_setting := false if editor_settings != null and editor_settings.has_setting(autosave_key): prior_autosave = bool(editor_settings.get_setting(autosave_key)) editor_settings.set_setting(autosave_key, false) restore_setting = true if _debugger_plugin != null: _debugger_plugin.begin_game_run() match mode: "main": EditorInterface.play_main_scene() "current": EditorInterface.play_current_scene() "custom": var scene_path: String = params.get("scene", "") EditorInterface.play_custom_scene(scene_path) if restore_setting: editor_settings.set_setting(autosave_key, prior_autosave) if _connection: _connection.pause_processing = false return { "data": { "mode": mode, "scene": params.get("scene", ""), "autosave": autosave, "was_already_running": false, "undoable": false, "reason": "Play/stop is a runtime action", } } func stop_project(params: Dictionary) -> Dictionary: # Idempotent: a project that's already stopped satisfies the caller's intent. # Returning INVALID_PARAMS here was the largest single source of fleet-wide # project_manage failures (87 installs/24h). was_running=false lets callers # distinguish a no-op stop from one that actually halted a running session. if not EditorInterface.is_playing_scene(): return { "data": { "stopped": true, "was_running": false, "undoable": false, "reason": "Project was not running; no action taken", } } if _debugger_plugin != null: _debugger_plugin.end_game_run() EditorInterface.stop_playing_scene() # stop_playing_scene() is async — is_playing_scene() only flips to false on # the next frame, and readiness_changed follows in _process. Defer the # response so we can reply with authoritative readiness instead of letting # the server poll for the event. Issue #29. var request_id: String = params.get("_request_id", "") if _connection != null and not request_id.is_empty(): _finish_stop_project_deferred(request_id) return McpDispatcher.DEFERRED_RESPONSE # Fallback for contexts without a connection (e.g. batch_execute via # dispatch_direct, or unit tests that instantiate the handler with null). return { "data": { "stopped": true, "was_running": true, "undoable": false, "reason": "Play/stop is a runtime action", } } func _finish_stop_project_deferred(request_id: String) -> void: # Wait two frames so Godot can tick the stop-play state change. After this # is_playing_scene() reflects truth and get_readiness() is authoritative. # If the plugin tears down (_exit_tree frees _connection) during the await, # is_instance_valid() goes false and we drop the response silently — the # server's 5s request timeout will surface the failure to the caller. var tree := _connection.get_tree() await tree.process_frame await tree.process_frame if not is_instance_valid(_connection): return _connection.send_deferred_response(request_id, { "data": { "stopped": true, "was_running": true, "undoable": false, "reason": "Play/stop is a runtime action", "readiness_after": McpConnection.get_readiness(), } }) func search_filesystem(params: Dictionary) -> Dictionary: var name_filter: String = params.get("name", "") var type_filter: String = params.get("type", "") var path_filter: String = params.get("path", "") if name_filter.is_empty() and type_filter.is_empty() and path_filter.is_empty(): return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "At least one filter (name, type, path) is required") var efs := EditorInterface.get_resource_filesystem() if efs == null: return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "EditorFileSystem not available") var results: Array[Dictionary] = [] _scan_directory(efs.get_filesystem(), name_filter, type_filter, path_filter, results) return {"data": {"files": results, "count": results.size()}} func _scan_directory(dir: EditorFileSystemDirectory, name_filter: String, type_filter: String, path_filter: String, out: Array[Dictionary]) -> void: for i in dir.get_file_count(): var file_path := dir.get_file_path(i) var file_type := dir.get_file_type(i) var matches := true if not name_filter.is_empty(): if file_path.get_file().to_lower().find(name_filter.to_lower()) == -1: matches = false if matches and not type_filter.is_empty(): if file_type != type_filter: matches = false if matches and not path_filter.is_empty(): if file_path.to_lower().find(path_filter.to_lower()) == -1: matches = false if matches: out.append({ "path": file_path, "type": file_type, }) for i in dir.get_subdir_count(): _scan_directory(dir.get_subdir(i), name_filter, type_filter, path_filter, out)