@beremaran/godot-agent-loop 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +925 -0
  3. package/addons/godot_agent_loop/LICENSE +23 -0
  4. package/addons/godot_agent_loop/README.md +31 -0
  5. package/addons/godot_agent_loop/plugin.cfg +8 -0
  6. package/addons/godot_agent_loop/plugin.gd +417 -0
  7. package/agent-plugin/.claude-plugin/plugin.json +19 -0
  8. package/agent-plugin/.codex-plugin/plugin.json +37 -0
  9. package/agent-plugin/.mcp.json +14 -0
  10. package/agent-plugin/adapter-manifest.json +45 -0
  11. package/agent-plugin/pi/extension.ts +136 -0
  12. package/agent-plugin/skills/build-godot-game/SKILL.md +41 -0
  13. package/agent-plugin/skills/debug-godot-game/SKILL.md +37 -0
  14. package/agent-plugin/skills/ship-godot-game/SKILL.md +46 -0
  15. package/agent-plugin/skills/ship-godot-game/agents/openai.yaml +4 -0
  16. package/agent-plugin/skills/verify-godot-change/SKILL.md +35 -0
  17. package/build/authoring-session-manager.js +237 -0
  18. package/build/domain-tool-registries.js +207 -0
  19. package/build/editor-bridge-protocol.js +1 -0
  20. package/build/editor-connection.js +112 -0
  21. package/build/editor-mutation-guard.js +30 -0
  22. package/build/editor-plugin-installer.js +220 -0
  23. package/build/engine-api/extension_api-4.7.stable.official.5b4e0cb0f.json +356830 -0
  24. package/build/game-command-service.js +49 -0
  25. package/build/game-connection.js +337 -0
  26. package/build/godot-executable.js +156 -0
  27. package/build/godot-process-manager.js +112 -0
  28. package/build/godot-subprocess.js +23 -0
  29. package/build/headless-operation-runner.js +68 -0
  30. package/build/headless-operation-service.js +65 -0
  31. package/build/index.js +478 -0
  32. package/build/interaction-server-installer.js +234 -0
  33. package/build/opencode-setup.js +199 -0
  34. package/build/project-support.js +219 -0
  35. package/build/runtime-protocol.js +202 -0
  36. package/build/scripts/godot_operations.gd +1534 -0
  37. package/build/scripts/mcp_editor_plugin.gd +417 -0
  38. package/build/scripts/mcp_interaction_server.gd +1255 -0
  39. package/build/scripts/mcp_runtime/audio_animation_domain.gd +568 -0
  40. package/build/scripts/mcp_runtime/command_params.gd +339 -0
  41. package/build/scripts/mcp_runtime/core_domain.gd +591 -0
  42. package/build/scripts/mcp_runtime/input_domain.gd +695 -0
  43. package/build/scripts/mcp_runtime/networking_domain.gd +356 -0
  44. package/build/scripts/mcp_runtime/physics_domain.gd +653 -0
  45. package/build/scripts/mcp_runtime/privileged_command_policy.gd +81 -0
  46. package/build/scripts/mcp_runtime/rendering_domain.gd +807 -0
  47. package/build/scripts/mcp_runtime/runtime_domain.gd +108 -0
  48. package/build/scripts/mcp_runtime/scene_2d_domain.gd +527 -0
  49. package/build/scripts/mcp_runtime/scene_3d_domain.gd +573 -0
  50. package/build/scripts/mcp_runtime/system_domain.gd +277 -0
  51. package/build/scripts/mcp_runtime/ui_domain.gd +619 -0
  52. package/build/scripts/mcp_runtime/variant_codec.gd +335 -0
  53. package/build/scripts/validate_script.gd +28 -0
  54. package/build/server-instructions.js +11 -0
  55. package/build/session-timing.js +20 -0
  56. package/build/tool-argument-validation.js +120 -0
  57. package/build/tool-definitions.js +2727 -0
  58. package/build/tool-handlers/game-tool-handlers.js +1345 -0
  59. package/build/tool-handlers/lifecycle-tool-handlers.js +346 -0
  60. package/build/tool-handlers/project-handler-services.js +1458 -0
  61. package/build/tool-handlers/project-tool-handlers.js +1506 -0
  62. package/build/tool-handlers/visual-regression-service.js +139 -0
  63. package/build/tool-manifest.js +1193 -0
  64. package/build/tool-mutation-policy.js +122 -0
  65. package/build/tool-registry.js +34 -0
  66. package/build/tool-surface.js +70 -0
  67. package/build/utils.js +273 -0
  68. package/package.json +110 -0
  69. package/product.json +52 -0
  70. package/scripts/prepare-package.js +42 -0
@@ -0,0 +1,1255 @@
1
+ extends Node
2
+
3
+ # MCP Interaction Server - newline-delimited JSON-RPC 2.0 server for game interaction.
4
+ # No class_name to avoid autoload conflict.
5
+ #
6
+ # This script is the composition root: it owns the socket lifecycle, sessions,
7
+ # the request lifecycle, and the command registry. Subsystem handlers live in
8
+ # domain scripts under res://mcp_runtime/, which register their own commands and
9
+ # reach the transport only through RuntimeDomain. Handlers that remain below are
10
+ # cross-cutting runtime commands owned by this composition root.
11
+
12
+ const CommandParams = preload("res://mcp_runtime/command_params.gd")
13
+ const VariantCodec = preload("res://mcp_runtime/variant_codec.gd")
14
+ const PrivilegedCommandPolicy = preload("res://mcp_runtime/privileged_command_policy.gd")
15
+ const DOMAIN_SCRIPTS: Array[String] = [
16
+ "res://mcp_runtime/input_domain.gd",
17
+ "res://mcp_runtime/ui_domain.gd",
18
+ "res://mcp_runtime/scene_2d_domain.gd",
19
+ "res://mcp_runtime/physics_domain.gd",
20
+ "res://mcp_runtime/scene_3d_domain.gd",
21
+ "res://mcp_runtime/rendering_domain.gd",
22
+ "res://mcp_runtime/audio_animation_domain.gd",
23
+ "res://mcp_runtime/networking_domain.gd",
24
+ "res://mcp_runtime/system_domain.gd",
25
+ "res://mcp_runtime/core_domain.gd",
26
+ ]
27
+
28
+ class RuntimeSession:
29
+ extends RefCounted
30
+
31
+ var id: int
32
+ var peer: StreamPeerTCP
33
+ var buffer: PackedByteArray = PackedByteArray()
34
+ var connected: bool = true
35
+ var request_running: bool = false
36
+ var request_id: Variant = null
37
+ var request_command: String = ""
38
+ var request_state: String = "received"
39
+ var cancellation_requested: bool = false
40
+ var authenticated: bool = false
41
+ var request_correlation_id: String = ""
42
+ var request_started_msec: int = 0
43
+
44
+ func _init(session_id: int, session_peer: StreamPeerTCP) -> void:
45
+ id = session_id
46
+ peer = session_peer
47
+
48
+
49
+ class CommandDescriptor:
50
+ extends RefCounted
51
+
52
+ # Typed dispatch entry for one runtime command. Every handler receives the
53
+ # request params dictionary and is awaited, so synchronous handlers and
54
+ # coroutine handlers share a single execution path in the transport layer.
55
+ var command: String
56
+ var handler: Callable
57
+ var cancellable: bool
58
+ var privileged: bool
59
+
60
+ func _init(command_name: String, command_handler: Callable, is_cancellable: bool, is_privileged: bool) -> void:
61
+ command = command_name
62
+ handler = command_handler
63
+ cancellable = is_cancellable
64
+ privileged = is_privileged
65
+
66
+
67
+ var _server: TCPServer
68
+ var _sessions: Dictionary = {}
69
+ var _next_session_id: int = 1
70
+ var _next_correlation_id: int = 1
71
+ # Exactly one runtime command executes at a time. Its session owns the request ID
72
+ # and peer until it responds, or disconnects and its eventual response is discarded.
73
+ var _active_session: RuntimeSession = null
74
+ const DEFAULT_PORT: int = 9090
75
+ const PORT_ENVIRONMENT_VARIABLE: String = "GODOT_MCP_RUNTIME_PORT"
76
+ const SECRET_ENVIRONMENT_VARIABLE: String = "GODOT_MCP_RUNTIME_SECRET"
77
+ const DISABLED_ENVIRONMENT_VARIABLE: String = "GODOT_MCP_RUNTIME_DISABLED"
78
+ # The listen port. Exported for editor configuration; when the game process is
79
+ # started by the MCP server, GODOT_MCP_RUNTIME_PORT overrides it so parallel
80
+ # sessions (and the E2E harness) each get an isolated loopback port.
81
+ @export var port: int = DEFAULT_PORT
82
+ const PROTOCOL_VERSION: String = "1.0"
83
+ const CAPABILITIES: Array[String] = ["runtime-commands", "godot-json-values"]
84
+ const AUTHORING_COMMANDS_CAPABILITY: String = "authoring-commands"
85
+ const RENDERING_CONTEXT_CAPABILITY: String = "rendering-context"
86
+ const METHOD_PREFIX: String = "godot.runtime."
87
+ const CANCELLABLE_COMMANDS: Array[String] = ["wait", "await_signal", "resource", "http_request"]
88
+ const ERROR_LIMIT_EXCEEDED: int = -32006
89
+ const ERROR_PRIVILEGED_COMMAND_DISABLED: int = PrivilegedCommandPolicy.ERROR_CODE
90
+ const ERROR_AUTHENTICATION_REQUIRED: int = -32008
91
+
92
+ # The MCP launcher supplies a fresh secret through the child environment. An
93
+ # empty value retains compatibility for a user-managed runtime, but every
94
+ # MCP-owned launch authenticates before any command is accepted.
95
+ @export var runtime_secret: String = ""
96
+ @export var allow_privileged_commands: bool = false
97
+
98
+ # These limits are exports so a project can tune its local developer runtime
99
+ # without changing the protocol implementation. Values include JSON framing.
100
+ @export var max_request_line_bytes: int = 1 * 1024 * 1024
101
+ @export var max_receive_buffer_bytes: int = 2 * 1024 * 1024
102
+ @export var max_receive_chunk_bytes: int = 64 * 1024
103
+ @export var max_json_nesting_depth: int = 32
104
+ @export var max_json_collection_items: int = 1024
105
+ @export var max_response_bytes: int = 8 * 1024 * 1024
106
+ @export var max_screenshot_pixels: int = 16 * 1024 * 1024
107
+ @export var max_screenshot_png_bytes: int = 6 * 1024 * 1024
108
+ # Command registry: maps a runtime command name to its CommandDescriptor.
109
+ # The transport layer dispatches only through this registry and never names
110
+ # individual subsystem commands.
111
+ var _commands: Dictionary = {}
112
+ # Domain nodes, kept as children so their handlers resolve the scene tree and
113
+ # viewport exactly as the server does. Each owns its subsystem's state.
114
+ var _domains: Array[Node] = []
115
+ var _codec: VariantCodec
116
+ var _privileged_policy: PrivilegedCommandPolicy
117
+ var _profile_active: bool = false
118
+ var _profile_samples: Array[Dictionary] = []
119
+ # Set only by godot_operations.gd when it owns the process main loop. Regular
120
+ # injected game runs publish the command names but do not advertise or execute
121
+ # project-file authoring commands.
122
+ var _authoring_dispatcher: Callable = Callable()
123
+
124
+ func _ready() -> void:
125
+ # Ensure MCP server keeps processing even when game is paused
126
+ process_mode = Node.PROCESS_MODE_ALWAYS
127
+ _codec = VariantCodec.new(max_json_nesting_depth, max_json_collection_items)
128
+ _privileged_policy = PrivilegedCommandPolicy.new()
129
+ runtime_secret = _resolve_runtime_secret()
130
+ _register_domains()
131
+ _register_commands()
132
+ # A one-shot authoring fallback can run while the real game owns this
133
+ # project's runtime port. It still needs the autoloads to parse, but must not
134
+ # start a second transport or emit a misleading bind failure.
135
+ if OS.get_environment(DISABLED_ENVIRONMENT_VARIABLE) == "true":
136
+ return
137
+ _server = TCPServer.new()
138
+ port = _resolve_port()
139
+ var err: int = _server.listen(port, "127.0.0.1")
140
+ if err != OK:
141
+ push_error("McpInteractionServer: Failed to listen on port %d, error: %d" % [port, err])
142
+ return
143
+ print("McpInteractionServer: Listening on 127.0.0.1:%d" % port)
144
+
145
+
146
+ # The environment override wins over the export so the process that launched
147
+ # the game controls where it must connect.
148
+ func _resolve_port() -> int:
149
+ var configured: String = OS.get_environment(PORT_ENVIRONMENT_VARIABLE)
150
+ if configured.is_valid_int():
151
+ var parsed: int = int(configured)
152
+ if parsed > 0 and parsed < 65536:
153
+ return parsed
154
+ push_warning("McpInteractionServer: Ignoring out-of-range %s=%s" % [PORT_ENVIRONMENT_VARIABLE, configured])
155
+ elif not configured.is_empty():
156
+ push_warning("McpInteractionServer: Ignoring non-numeric %s=%s" % [PORT_ENVIRONMENT_VARIABLE, configured])
157
+ return port
158
+
159
+
160
+ func _resolve_runtime_secret() -> String:
161
+ var configured: String = OS.get_environment(SECRET_ENVIRONMENT_VARIABLE)
162
+ return configured if not configured.is_empty() else runtime_secret
163
+
164
+
165
+ func _process(_delta: float) -> void:
166
+ if _server == null:
167
+ return
168
+
169
+ # Accept all pending connections. A newly connected client must never replace a
170
+ # peer retained by an awaited command from an earlier session.
171
+ while _server.is_connection_available():
172
+ var new_client: StreamPeerTCP = _server.take_connection()
173
+ if new_client != null:
174
+ var session: RuntimeSession = RuntimeSession.new(_next_session_id, new_client)
175
+ _sessions[session.id] = session
176
+ _next_session_id += 1
177
+ print("McpInteractionServer: Client connected (session %d)" % session.id)
178
+
179
+ for session_id: Variant in _sessions.keys():
180
+ var session_value: Variant = _sessions.get(session_id)
181
+ if not session_value is RuntimeSession:
182
+ continue
183
+ var session: RuntimeSession = session_value
184
+ _poll_session(session)
185
+ if not session.connected and session != _active_session:
186
+ @warning_ignore("return_value_discarded")
187
+ _sessions.erase(session.id)
188
+
189
+
190
+ func _poll_session(session: RuntimeSession) -> void:
191
+ if not session.connected or session.peer == null:
192
+ return
193
+
194
+ @warning_ignore("return_value_discarded")
195
+ session.peer.poll()
196
+ var status: int = session.peer.get_status()
197
+ if status == StreamPeerTCP.STATUS_ERROR or status == StreamPeerTCP.STATUS_NONE:
198
+ session.connected = false
199
+ session.buffer = PackedByteArray()
200
+ print("McpInteractionServer: Client disconnected (session %d)" % session.id)
201
+ return
202
+
203
+ if status != StreamPeerTCP.STATUS_CONNECTED:
204
+ return
205
+
206
+ var available: int = session.peer.get_available_bytes()
207
+ if available > 0:
208
+ var remaining_capacity: int = max_receive_buffer_bytes - session.buffer.size()
209
+ if remaining_capacity <= 0:
210
+ _reject_and_close_session(session, "Receive buffer exceeds the configured limit", {"limit_bytes": max_receive_buffer_bytes})
211
+ return
212
+ var bytes_to_read: int = min(available, min(max_receive_chunk_bytes, remaining_capacity))
213
+ var data: Array = session.peer.get_data(bytes_to_read)
214
+ if data[0] == OK:
215
+ var bytes: PackedByteArray = data[1]
216
+ session.buffer.append_array(bytes)
217
+
218
+ # Process complete lines (newline-delimited JSON)
219
+ while true:
220
+ var newline_pos: int = session.buffer.find(10)
221
+ if newline_pos < 0:
222
+ break
223
+ var line_bytes: PackedByteArray = session.buffer.slice(0, newline_pos)
224
+ session.buffer = session.buffer.slice(newline_pos + 1)
225
+ if line_bytes.size() > max_request_line_bytes:
226
+ _reject_and_close_session(session, "Request line exceeds the configured limit", {"limit_bytes": max_request_line_bytes})
227
+ return
228
+ if line_bytes.size() == 0:
229
+ continue
230
+ var line: String = line_bytes.get_string_from_utf8()
231
+ if line.to_utf8_buffer() != line_bytes:
232
+ _reject_and_close_session(session, "Request line is not valid UTF-8")
233
+ return
234
+ line = line.strip_edges()
235
+ if not line.is_empty():
236
+ _handle_command(session, line)
237
+ if not session.connected:
238
+ return
239
+
240
+ if session.buffer.size() > max_request_line_bytes:
241
+ _reject_and_close_session(session, "Request line exceeds the configured limit", {"limit_bytes": max_request_line_bytes})
242
+
243
+
244
+ func _reject_and_close_session(session: RuntimeSession, message: String, details: Dictionary = {}) -> void:
245
+ _send_error(session, null, ERROR_LIMIT_EXCEEDED, message, details)
246
+ session.buffer = PackedByteArray()
247
+ session.connected = false
248
+ if session.peer != null:
249
+ # Drain unread input first: closing a socket with pending received data
250
+ # sends a TCP RST, which would discard the queued error response before
251
+ # the client can read it. Drain is bounded so a flooding peer cannot
252
+ # keep this loop alive.
253
+ var drained: int = 0
254
+ @warning_ignore("return_value_discarded")
255
+ session.peer.poll()
256
+ var pending: int = session.peer.get_available_bytes()
257
+ while pending > 0 and drained < max_receive_buffer_bytes:
258
+ var chunk: int = min(pending, max_receive_chunk_bytes)
259
+ @warning_ignore("return_value_discarded")
260
+ session.peer.get_data(chunk)
261
+ drained += chunk
262
+ @warning_ignore("return_value_discarded")
263
+ session.peer.poll()
264
+ pending = session.peer.get_available_bytes()
265
+ session.peer.disconnect_from_host()
266
+ if session != _active_session:
267
+ @warning_ignore("return_value_discarded")
268
+ _sessions.erase(session.id)
269
+
270
+
271
+ func _validate_json_limits(value: Variant, depth: int = 0) -> String:
272
+ if depth > max_json_nesting_depth:
273
+ return "JSON nesting exceeds the configured limit of %d" % max_json_nesting_depth
274
+ if value is Array:
275
+ var array_value: Array = value
276
+ if array_value.size() > max_json_collection_items:
277
+ return "JSON array exceeds the configured limit of %d items" % max_json_collection_items
278
+ for item: Variant in array_value:
279
+ var array_error: String = _validate_json_limits(item, depth + 1)
280
+ if not array_error.is_empty():
281
+ return array_error
282
+ elif value is Dictionary:
283
+ var dictionary_value: Dictionary = value
284
+ if dictionary_value.size() > max_json_collection_items:
285
+ return "JSON object exceeds the configured limit of %d properties" % max_json_collection_items
286
+ for key: Variant in dictionary_value:
287
+ var dictionary_error: String = _validate_json_limits(dictionary_value[key], depth + 1)
288
+ if not dictionary_error.is_empty():
289
+ return dictionary_error
290
+ return ""
291
+
292
+
293
+ func _handle_command(session: RuntimeSession, json_str: String) -> void:
294
+ var json: JSON = JSON.new()
295
+ var parse_err: int = json.parse(json_str)
296
+ if parse_err != OK:
297
+ _send_error(session, null, -32700, "Invalid JSON: %s" % json.get_error_message())
298
+ return
299
+
300
+ var data: Variant = json.data
301
+ if not data is Dictionary:
302
+ _send_error(session, null, -32600, "Expected JSON-RPC request object")
303
+ return
304
+ var limits_error: String = _validate_json_limits(data)
305
+ if not limits_error.is_empty():
306
+ _reject_and_close_session(session, limits_error, {"max_depth": max_json_nesting_depth, "max_collection_items": max_json_collection_items})
307
+ return
308
+
309
+ var request: Dictionary = data
310
+ var req_id: Variant = request.get("id", null)
311
+ if request.get("jsonrpc", "") != "2.0" or req_id == null or not request.has("method"):
312
+ _send_error(session, req_id, -32600, "Expected a JSON-RPC 2.0 request with id and method")
313
+ return
314
+ var method: String = str(request.get("method", ""))
315
+ var raw_params: Variant = request.get("params", {})
316
+ if not raw_params is Dictionary:
317
+ _send_error(session, req_id, -32602, "params must be an object")
318
+ return
319
+ var params: Dictionary = raw_params
320
+ if method == "godot.runtime.handshake":
321
+ _handle_handshake(session, req_id, params)
322
+ return
323
+ if not session.authenticated:
324
+ # Never echo the provided secret or any later request parameters.
325
+ _send_error(session, req_id, ERROR_AUTHENTICATION_REQUIRED,
326
+ "Runtime session authentication is required", {"reason": "authentication_required"})
327
+ return
328
+ if not method.begins_with(METHOD_PREFIX):
329
+ _send_error(session, req_id, -32601, "Unknown method: %s" % method)
330
+ return
331
+ if method == "%scancel" % METHOD_PREFIX:
332
+ _handle_cancel(session, req_id, params)
333
+ return
334
+
335
+ var command: String = method.trim_prefix(METHOD_PREFIX)
336
+ var descriptor: CommandDescriptor = _commands.get(command)
337
+ if descriptor == null:
338
+ _send_error(session, req_id, -32601, "Unknown method: %s" % method)
339
+ return
340
+ if descriptor.privileged and not _privileged_policy.is_enabled(command, allow_privileged_commands):
341
+ # Do not include params, source, property values, URLs, headers, or engine
342
+ # error text in this response. The command name and opt-in mechanism are
343
+ # safe policy metadata; request contents remain private to the caller.
344
+ _audit_event("authorization_denied", session, command)
345
+ _send_error(session, req_id, ERROR_PRIVILEGED_COMMAND_DISABLED,
346
+ "Privileged runtime command is disabled by policy",
347
+ _privileged_policy.denial_details(command))
348
+ return
349
+
350
+ if _active_session != null:
351
+ _send_error(session, req_id, -32001, "Server busy processing another command. Try again.")
352
+ return
353
+ session.request_running = true
354
+ session.request_id = req_id
355
+ session.request_command = command
356
+ session.request_state = "running"
357
+ session.cancellation_requested = false
358
+ var supplied_correlation: Variant = params.get("_mcp_correlation_id", "")
359
+ var _correlation_erased: bool = params.erase("_mcp_correlation_id")
360
+ var correlation_string: String = str(supplied_correlation) if supplied_correlation is String else ""
361
+ if correlation_string.length() <= 64 and correlation_string.is_valid_identifier():
362
+ session.request_correlation_id = correlation_string
363
+ else:
364
+ session.request_correlation_id = "runtime_%d_%d" % [session.id, _next_correlation_id]
365
+ _next_correlation_id += 1
366
+ session.request_started_msec = Time.get_ticks_msec()
367
+ _active_session = session
368
+ _audit_request("request_started", session)
369
+
370
+ # Synchronous handlers complete before this await resumes; coroutine
371
+ # handlers suspend here until their own awaits finish.
372
+ await descriptor.handler.call(params)
373
+
374
+
375
+ func _register_command(command: String, handler: Callable) -> void:
376
+ _commands[command] = CommandDescriptor.new(
377
+ command,
378
+ handler,
379
+ CANCELLABLE_COMMANDS.has(command),
380
+ _privileged_policy.is_privileged(command),
381
+ )
382
+
383
+
384
+ # Instantiates each domain, attaches it to the tree, and lets it register its
385
+ # own commands through _register_command. Domains are the unit of the ongoing
386
+ # split; a command belongs either to a domain or to this composition root, and
387
+ # the shared registry rejects a duplicate registration either way.
388
+ func _register_domains() -> void:
389
+ for script_path: String in DOMAIN_SCRIPTS:
390
+ var domain_script: GDScript = load(script_path)
391
+ var domain: Node = domain_script.new()
392
+ domain.name = script_path.get_file().get_basename()
393
+ _domains.append(domain)
394
+ add_child(domain)
395
+ # Domains are loaded by path rather than preloaded, because RuntimeDomain
396
+ # preloads this script to type its transport helpers. These two calls are
397
+ # the one dynamic seam that direction leaves in the composition root.
398
+ @warning_ignore("unsafe_method_access")
399
+ domain.setup(self, _register_command)
400
+ @warning_ignore("unsafe_method_access")
401
+ domain.register_commands()
402
+
403
+
404
+ func _register_commands() -> void:
405
+ _register_command("authoring_add_node", _cmd_authoring_add_node)
406
+ _register_command("authoring_attach_script", _cmd_authoring_attach_script)
407
+ _register_command("authoring_create_resource", _cmd_authoring_create_resource)
408
+ _register_command("authoring_create_scene", _cmd_authoring_create_scene)
409
+ _register_command("authoring_export_mesh_library", _cmd_authoring_export_mesh_library)
410
+ _register_command("authoring_get_uid", _cmd_authoring_get_uid)
411
+ _register_command("authoring_load_sprite", _cmd_authoring_load_sprite)
412
+ _register_command("authoring_manage_resource", _cmd_authoring_manage_resource)
413
+ _register_command("authoring_manage_scene_signals", _cmd_authoring_manage_scene_signals)
414
+ _register_command("authoring_manage_scene_structure", _cmd_authoring_manage_scene_structure)
415
+ _register_command("authoring_manage_theme_resource", _cmd_authoring_manage_theme_resource)
416
+ _register_command("authoring_modify_node", _cmd_authoring_modify_node)
417
+ _register_command("authoring_read_scene", _cmd_authoring_read_scene)
418
+ _register_command("authoring_remove_node", _cmd_authoring_remove_node)
419
+ _register_command("authoring_resave_resources", _cmd_authoring_resave_resources)
420
+ _register_command("authoring_save_scene", _cmd_authoring_save_scene)
421
+ _register_command("screenshot", _cmd_screenshot)
422
+ _register_command("eval", _cmd_eval)
423
+ _register_command("wait", _cmd_wait)
424
+ _register_command("get_ui_elements", _cmd_get_ui_elements)
425
+ _register_command("pause", _cmd_pause)
426
+ _register_command("get_performance", _cmd_get_performance)
427
+ _register_command("play_animation", _cmd_play_animation)
428
+ _register_command("tween_property", _cmd_tween_property)
429
+ _register_command("create_timer", _cmd_create_timer)
430
+ _register_command("serialize_state", _cmd_serialize_state)
431
+ _register_command("script", _cmd_script)
432
+
433
+
434
+ func register_authoring_dispatcher(dispatcher: Callable) -> void:
435
+ _authoring_dispatcher = dispatcher
436
+
437
+
438
+ func _dispatch_authoring(operation: String, params: Dictionary) -> void:
439
+ if not _authoring_dispatcher.is_valid():
440
+ _send_response({
441
+ "error": "Authoring commands require the harness-owned operations session",
442
+ "error_data": {"reason": "authoring_session_required", "operation": operation},
443
+ })
444
+ return
445
+ var raw_result: Variant = _authoring_dispatcher.call(operation, params)
446
+ if not raw_result is Dictionary:
447
+ _send_response({
448
+ "error": "Authoring dispatcher returned an invalid response",
449
+ "error_data": {"reason": "invalid_authoring_response", "operation": operation},
450
+ })
451
+ return
452
+ var result: Dictionary = raw_result
453
+ _send_response(result)
454
+
455
+
456
+ func _cmd_authoring_add_node(params: Dictionary) -> void:
457
+ _dispatch_authoring("add_node", params)
458
+
459
+
460
+ func _cmd_authoring_attach_script(params: Dictionary) -> void:
461
+ _dispatch_authoring("attach_script", params)
462
+
463
+
464
+ func _cmd_authoring_create_resource(params: Dictionary) -> void:
465
+ _dispatch_authoring("create_resource", params)
466
+
467
+
468
+ func _cmd_authoring_create_scene(params: Dictionary) -> void:
469
+ _dispatch_authoring("create_scene", params)
470
+
471
+
472
+ func _cmd_authoring_export_mesh_library(params: Dictionary) -> void:
473
+ _dispatch_authoring("export_mesh_library", params)
474
+
475
+
476
+ func _cmd_authoring_get_uid(params: Dictionary) -> void:
477
+ _dispatch_authoring("get_uid", params)
478
+
479
+
480
+ func _cmd_authoring_load_sprite(params: Dictionary) -> void:
481
+ _dispatch_authoring("load_sprite", params)
482
+
483
+
484
+ func _cmd_authoring_manage_resource(params: Dictionary) -> void:
485
+ _dispatch_authoring("manage_resource", params)
486
+
487
+
488
+ func _cmd_authoring_manage_scene_signals(params: Dictionary) -> void:
489
+ _dispatch_authoring("manage_scene_signals", params)
490
+
491
+
492
+ func _cmd_authoring_manage_scene_structure(params: Dictionary) -> void:
493
+ _dispatch_authoring("manage_scene_structure", params)
494
+
495
+
496
+ func _cmd_authoring_manage_theme_resource(params: Dictionary) -> void:
497
+ _dispatch_authoring("manage_theme_resource", params)
498
+
499
+
500
+ func _cmd_authoring_modify_node(params: Dictionary) -> void:
501
+ _dispatch_authoring("modify_node", params)
502
+
503
+
504
+ func _cmd_authoring_read_scene(params: Dictionary) -> void:
505
+ _dispatch_authoring("read_scene", params)
506
+
507
+
508
+ func _cmd_authoring_remove_node(params: Dictionary) -> void:
509
+ _dispatch_authoring("remove_node", params)
510
+
511
+
512
+ func _cmd_authoring_resave_resources(params: Dictionary) -> void:
513
+ _dispatch_authoring("resave_resources", params)
514
+
515
+
516
+ func _cmd_authoring_save_scene(params: Dictionary) -> void:
517
+ _dispatch_authoring("save_scene", params)
518
+
519
+
520
+ func _handle_handshake(session: RuntimeSession, req_id: Variant, params: Dictionary) -> void:
521
+ if params.get("protocolVersion", "") != PROTOCOL_VERSION:
522
+ _send_error(session, req_id, -32002, "Unsupported protocol version", {"supported": PROTOCOL_VERSION})
523
+ return
524
+ if not session.authenticated and not runtime_secret.is_empty() and params.get("secret", "") != runtime_secret:
525
+ _audit_event("authentication_failed", session)
526
+ _send_error(session, req_id, ERROR_AUTHENTICATION_REQUIRED,
527
+ "Runtime session authentication failed", {"reason": "authentication_failed"})
528
+ return
529
+ session.authenticated = true
530
+ _audit_event("authentication_succeeded", session)
531
+ var capabilities: Array[String] = _privileged_policy.capabilities(CAPABILITIES, allow_privileged_commands)
532
+ if _has_rendering_context():
533
+ capabilities.append(RENDERING_CONTEXT_CAPABILITY)
534
+ if _authoring_dispatcher.is_valid():
535
+ capabilities.append(AUTHORING_COMMANDS_CAPABILITY)
536
+ if not runtime_secret.is_empty():
537
+ capabilities.append("session-authentication")
538
+ _send_response_raw(session, {"jsonrpc": "2.0", "id": req_id, "result": {
539
+ "protocolVersion": PROTOCOL_VERSION,
540
+ "capabilities": capabilities,
541
+ }})
542
+
543
+
544
+ func _has_rendering_context() -> bool:
545
+ return DisplayServer.get_name() != "headless"
546
+
547
+
548
+ func _audit_event(event: String, session: RuntimeSession, command: String = "", details: Dictionary = {}) -> void:
549
+ var record: Dictionary = {
550
+ "component": "godot-agent-loop-runtime",
551
+ "event": event,
552
+ "session_id": session.id,
553
+ "unix_time": int(Time.get_unix_time_from_system()),
554
+ }
555
+ if not command.is_empty():
556
+ record["command"] = command
557
+ for key: Variant in details:
558
+ record[key] = details[key]
559
+ print(JSON.stringify(record))
560
+
561
+
562
+ func _audit_request(event: String, session: RuntimeSession, error_code: int = 0) -> void:
563
+ var details: Dictionary = {
564
+ "correlation_id": session.request_correlation_id,
565
+ "duration_ms": max(0, Time.get_ticks_msec() - session.request_started_msec),
566
+ "state": session.request_state,
567
+ }
568
+ if error_code != 0:
569
+ details["error_code"] = error_code
570
+ _audit_event(event, session, session.request_command, details)
571
+
572
+
573
+ # Cancellation is cooperative: only commands that regularly yield to the scene
574
+ # tree can be cancelled safely. The original request receives -32003 when it
575
+ # observes the cancellation; this acknowledgement is for the cancel request.
576
+ func _handle_cancel(session: RuntimeSession, req_id: Variant, params: Dictionary) -> void:
577
+ var target_id: Variant = params.get("request_id", null)
578
+ if target_id == null:
579
+ _send_error(session, req_id, -32602, "request_id is required")
580
+ return
581
+ if _active_session == null or _active_session != session or not _active_session.request_running or _active_session.request_id != target_id:
582
+ _send_error(session, req_id, -32004, "Request is not running", {"request_id": target_id})
583
+ return
584
+ var descriptor: CommandDescriptor = _commands.get(_active_session.request_command)
585
+ if descriptor == null or not descriptor.cancellable:
586
+ _send_error(session, req_id, -32005, "Request is not cancellable", {"request_id": target_id, "command": _active_session.request_command})
587
+ return
588
+ _active_session.cancellation_requested = true
589
+ _audit_request("cancellation_requested", _active_session)
590
+ _send_response_raw(session, {"jsonrpc": "2.0", "id": req_id, "result": {"cancelled": true, "request_id": target_id}})
591
+
592
+
593
+ # Send the active request's response only through the session that received it.
594
+ # Disconnected sessions retain their request state until this point, then their
595
+ # response is intentionally discarded rather than sent to a later connection.
596
+ func _send_response(data: Dictionary) -> void:
597
+ var session: RuntimeSession = _active_session
598
+ if session == null or not session.request_running:
599
+ return
600
+ var codec_error: Dictionary = _codec.take_error()
601
+ if not codec_error.is_empty():
602
+ data = {
603
+ "error": codec_error.get("message", "Variant codec failed"),
604
+ "error_data": codec_error,
605
+ }
606
+ _active_session = null
607
+ session.request_running = false
608
+ var id: Variant = session.request_id
609
+ session.request_id = null
610
+ if session.cancellation_requested:
611
+ session.request_state = "cancelled"
612
+ _audit_request("request_cancelled", session, -32003)
613
+ _send_error(session, id, -32003, "Request cancelled", {"command": session.request_command})
614
+ elif data.has("error"):
615
+ session.request_state = "responded"
616
+ _audit_request("request_failed", session, -32000)
617
+ _send_error(session, id, -32000, str(data["error"]), data.get("error_data", null))
618
+ else:
619
+ session.request_state = "responded"
620
+ _audit_request("request_completed", session)
621
+ _send_response_raw(session, {"jsonrpc": "2.0", "id": id, "result": data})
622
+ session.request_command = ""
623
+ session.request_correlation_id = ""
624
+ session.request_started_msec = 0
625
+ session.cancellation_requested = false
626
+ if not session.connected:
627
+ @warning_ignore("return_value_discarded")
628
+ _sessions.erase(session.id)
629
+
630
+
631
+ func _is_active_request_cancelled() -> bool:
632
+ return _active_session != null and _active_session.cancellation_requested
633
+
634
+
635
+ func _send_timeout_response(message: String, details: Dictionary = {}) -> void:
636
+ var session: RuntimeSession = _active_session
637
+ if session == null or not session.request_running:
638
+ return
639
+ _active_session = null
640
+ session.request_running = false
641
+ var id: Variant = session.request_id
642
+ session.request_id = null
643
+ session.request_state = "timed_out"
644
+ _audit_request("request_timed_out", session, -32004)
645
+ session.request_command = ""
646
+ session.request_correlation_id = ""
647
+ session.request_started_msec = 0
648
+ session.cancellation_requested = false
649
+ _send_error(session, id, -32004, message, details)
650
+ if not session.connected:
651
+ @warning_ignore("return_value_discarded")
652
+ _sessions.erase(session.id)
653
+
654
+
655
+ func _send_limit_response(message: String, details: Dictionary = {}) -> void:
656
+ var session: RuntimeSession = _active_session
657
+ if session == null or not session.request_running:
658
+ return
659
+ _active_session = null
660
+ session.request_running = false
661
+ var id: Variant = session.request_id
662
+ session.request_id = null
663
+ session.request_state = "responded"
664
+ _audit_request("request_failed", session, ERROR_LIMIT_EXCEEDED)
665
+ session.request_command = ""
666
+ session.request_correlation_id = ""
667
+ session.request_started_msec = 0
668
+ session.cancellation_requested = false
669
+ _send_error(session, id, ERROR_LIMIT_EXCEEDED, message, details)
670
+ if not session.connected:
671
+ @warning_ignore("return_value_discarded")
672
+ _sessions.erase(session.id)
673
+
674
+
675
+ # Send response without clearing busy flag (used when rejecting during busy state)
676
+ func _send_response_raw(session: RuntimeSession, data: Dictionary) -> void:
677
+ if not session.connected or session.peer == null:
678
+ return
679
+ var json_str: String = JSON.stringify(data) + "\n"
680
+ var bytes: PackedByteArray = json_str.to_utf8_buffer()
681
+ if bytes.size() > max_response_bytes:
682
+ var fallback: Dictionary = {
683
+ "jsonrpc": "2.0",
684
+ "id": data.get("id", null),
685
+ "error": {
686
+ "code": ERROR_LIMIT_EXCEEDED,
687
+ "message": "Response exceeds the configured limit",
688
+ "data": {"limit_bytes": max_response_bytes},
689
+ },
690
+ }
691
+ bytes = (JSON.stringify(fallback) + "\n").to_utf8_buffer()
692
+ if bytes.size() > max_response_bytes:
693
+ session.connected = false
694
+ session.peer.disconnect_from_host()
695
+ return
696
+ @warning_ignore("return_value_discarded")
697
+ session.peer.put_data(bytes)
698
+
699
+
700
+ func _send_error(session: RuntimeSession, id: Variant, code: int, message: String, details: Variant = null) -> void:
701
+ var error: Dictionary = {"code": code, "message": message}
702
+ if details != null:
703
+ error["data"] = details
704
+ _send_response_raw(session, {"jsonrpc": "2.0", "id": id, "error": error})
705
+
706
+
707
+ # --- Validated parameter helpers ---
708
+ # Handlers build a CommandParams reader, pull typed values, then call
709
+ # _params_invalid() once; on failure it sends the standardized -32000 error
710
+ # with structured details and the handler returns without doing any work.
711
+ func _params_invalid(reader: CommandParams) -> bool:
712
+ if reader.failed():
713
+ _send_params_error(reader)
714
+ return true
715
+ return false
716
+
717
+
718
+ # The same failure without the question: for a handler that failed the reader on
719
+ # a rule the accessors cannot express and is about to stop.
720
+ func _send_params_error(reader: CommandParams) -> void:
721
+ if not reader.failed():
722
+ return
723
+ _send_response({"error": reader.error_message, "error_data": reader.error_details})
724
+
725
+
726
+ # Resolves a node parameter relative to the tree root. With a default_path the
727
+ # parameter is optional; either way a missing node records a structured failure.
728
+ func _require_node(reader: CommandParams, param_name: String = "node_path", default_path: String = "") -> Node:
729
+ var path: String
730
+ if default_path.is_empty():
731
+ path = reader.required_node_path(param_name)
732
+ else:
733
+ path = reader.optional_string(param_name, default_path)
734
+ if reader.failed():
735
+ return null
736
+ var node: Node = get_tree().root.get_node_or_null(NodePath(path))
737
+ if node == null:
738
+ reader.fail("Node not found: %s" % path, {"param": name, "reason": "node_not_found", "value": path})
739
+ return node
740
+
741
+
742
+ # Structured details for command failures caused by a Godot Error value.
743
+ func _godot_error_data(err: int) -> Dictionary:
744
+ return {"reason": "godot_error", "godot_error": err, "godot_error_string": error_string(err)}
745
+
746
+
747
+ # --- Screenshot ---
748
+ func _cmd_screenshot(_params: Dictionary) -> void:
749
+ if not _has_rendering_context():
750
+ _send_response({
751
+ "error": "Screenshot requires a headed Godot session with a reachable rendering context",
752
+ "error_data": {
753
+ "reason": "rendering_context_unavailable",
754
+ "display_driver": DisplayServer.get_name(),
755
+ "remediation": "Run Godot with a desktop display or a virtual display such as Xvfb",
756
+ },
757
+ })
758
+ return
759
+ # Wait one frame so the viewport is fully rendered
760
+ await get_tree().process_frame
761
+ var viewport: Viewport = get_viewport()
762
+ var viewport_size: Vector2i = viewport.get_visible_rect().size
763
+ if viewport_size.x <= 0 or viewport_size.y <= 0 or viewport_size.x * viewport_size.y > max_screenshot_pixels:
764
+ _send_limit_response("Screenshot dimensions exceed the configured limit", {"max_pixels": max_screenshot_pixels})
765
+ return
766
+ var image: Image = viewport.get_texture().get_image()
767
+ if image == null:
768
+ _send_response({"error": "Failed to capture screenshot"})
769
+ return
770
+ var png_buffer: PackedByteArray = image.save_png_to_buffer()
771
+ if png_buffer.size() > max_screenshot_png_bytes:
772
+ _send_limit_response("Screenshot payload exceeds the configured limit", {"limit_bytes": max_screenshot_png_bytes})
773
+ return
774
+ var base64_str: String = Marshalls.raw_to_base64(png_buffer)
775
+ _send_response({
776
+ "success": true,
777
+ "data": base64_str,
778
+ "width": image.get_width(),
779
+ "height": image.get_height()
780
+ })
781
+
782
+
783
+ # --- Get UI Elements ---
784
+ func _cmd_get_ui_elements(_params: Dictionary) -> void:
785
+ var elements: Array = []
786
+ _collect_ui_elements(get_tree().root, elements)
787
+ _send_response({"success": true, "elements": elements})
788
+
789
+
790
+ func _collect_ui_elements(node: Node, elements: Array) -> void:
791
+ if node is Control:
792
+ var ctrl: Control = node as Control
793
+ if ctrl.visible and ctrl.get_global_rect().size.x > 0:
794
+ var info: Dictionary = {
795
+ "name": ctrl.name,
796
+ "type": ctrl.get_class(),
797
+ "path": str(ctrl.get_path()),
798
+ "position": {"x": ctrl.global_position.x, "y": ctrl.global_position.y},
799
+ "size": {"width": ctrl.size.x, "height": ctrl.size.y},
800
+ }
801
+ # Get text content for common text-bearing nodes
802
+ if ctrl is Label:
803
+ info["text"] = (ctrl as Label).text
804
+ elif ctrl is Button:
805
+ info["text"] = (ctrl as Button).text
806
+ elif ctrl is LineEdit:
807
+ info["text"] = (ctrl as LineEdit).text
808
+ elif ctrl is RichTextLabel:
809
+ info["text"] = (ctrl as RichTextLabel).get_parsed_text()
810
+
811
+ elements.append(info)
812
+
813
+ for child in node.get_children():
814
+ _collect_ui_elements(child, elements)
815
+
816
+
817
+ # --- Get Scene Tree ---
818
+ func _cmd_eval(params: Dictionary) -> void:
819
+ var code: String = params.get("code", "")
820
+ if code.is_empty():
821
+ _send_response({"error": "No code provided"})
822
+ return
823
+
824
+ # Wrap user code in a function so we can capture the return value
825
+ var script_source: String = """extends Node
826
+
827
+ func execute():
828
+ var __result = null
829
+ __result = await _run()
830
+ return __result
831
+
832
+ func _run():
833
+ %s
834
+ """ % [_indent_code(code)]
835
+
836
+ var script: GDScript = GDScript.new()
837
+ script.source_code = script_source
838
+ var err: int = script.reload()
839
+ if err != OK:
840
+ _send_response({"error": "Failed to compile GDScript (error %d). Check syntax." % err})
841
+ return
842
+
843
+ var temp_node: Node = Node.new()
844
+ temp_node.set_script(script)
845
+ # Allow eval to work even when game is paused
846
+ temp_node.process_mode = Node.PROCESS_MODE_ALWAYS
847
+ add_child(temp_node)
848
+
849
+ var result: Variant = null
850
+ if temp_node.has_method("execute"):
851
+ # The eval source is compiled at request time, so this call is dynamic by
852
+ # definition. It is gated by the privileged-command policy.
853
+ @warning_ignore("unsafe_method_access")
854
+ result = await temp_node.execute()
855
+
856
+ temp_node.queue_free()
857
+ _send_response({"success": true, "result": _variant_to_json(result)})
858
+
859
+
860
+ func _indent_code(code: String) -> String:
861
+ var lines: PackedStringArray = code.split("\n")
862
+ var indented: String = ""
863
+ for line in lines:
864
+ indented += "\t" + line + "\n"
865
+ return indented
866
+
867
+
868
+ # --- Get Property ---
869
+ func _cmd_pause(params: Dictionary) -> void:
870
+ var paused: bool = params.get("paused", true)
871
+ get_tree().paused = paused
872
+ _send_response({"success": true, "paused": paused})
873
+
874
+
875
+ # --- Get Performance ---
876
+ func _cmd_get_performance(params: Dictionary) -> void:
877
+ var action: String = str(params.get("action", "sample"))
878
+ if not ["sample", "start", "stop", "report", "leaks"].has(action):
879
+ _send_response({"error": "action must be sample, start, stop, report, or leaks"})
880
+ return
881
+ if action == "start":
882
+ _profile_active = true
883
+ _profile_samples.clear()
884
+ _send_response({"success": true, "profiling": true, "sample_count": 0})
885
+ return
886
+ if action == "stop":
887
+ _profile_active = false
888
+ var stopped: Dictionary = _profile_report()
889
+ stopped["profiling"] = false
890
+ _send_response(stopped)
891
+ return
892
+ if action == "report":
893
+ var report: Dictionary = _profile_report()
894
+ report["profiling"] = _profile_active
895
+ _send_response(report)
896
+ return
897
+ var sample_count: int = CommandParams.to_int(params.get("sample_count", params.get("sampleCount", 1)), 1)
898
+ if sample_count < 1 or sample_count > 120:
899
+ _send_response({"error": "sample_count must be between 1 and 120", "error_data": {"param": "sample_count", "reason": "out_of_range"}})
900
+ return
901
+ var samples: Array[Dictionary] = []
902
+ for index: int in range(sample_count):
903
+ var current: Dictionary = _performance_snapshot()
904
+ samples.append(current)
905
+ if _profile_active:
906
+ _profile_samples.append(current)
907
+ if index + 1 < sample_count:
908
+ await get_tree().process_frame
909
+ var result: Dictionary = samples[0].duplicate(true)
910
+ result["success"] = true
911
+ result["samples"] = samples
912
+ result["profiling"] = _profile_active
913
+ result["requested_sample_count"] = sample_count
914
+ if action == "leaks":
915
+ result["leak_diagnostics"] = {
916
+ "object_count": result.get("object_count", 0),
917
+ "object_node_count": result.get("object_node_count", 0),
918
+ "object_orphan_node_count": result.get("object_orphan_node_count", 0),
919
+ "static_memory_bytes": result.get("memory_static", 0),
920
+ "tracked_profile_samples": _profile_samples.size(),
921
+ }
922
+ _send_response(result)
923
+
924
+
925
+ func _performance_snapshot() -> Dictionary:
926
+ return {
927
+ "fps": Performance.get_monitor(Performance.TIME_FPS),
928
+ "frame_time": Performance.get_monitor(Performance.TIME_PROCESS),
929
+ "physics_frame_time": Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS),
930
+ "memory_static": Performance.get_monitor(Performance.MEMORY_STATIC),
931
+ "memory_static_max": Performance.get_monitor(Performance.MEMORY_STATIC_MAX),
932
+ "object_count": Performance.get_monitor(Performance.OBJECT_COUNT),
933
+ "object_node_count": Performance.get_monitor(Performance.OBJECT_NODE_COUNT),
934
+ "object_orphan_node_count": Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT),
935
+ "render_total_objects": Performance.get_monitor(Performance.RENDER_TOTAL_OBJECTS_IN_FRAME),
936
+ "render_total_draw_calls": Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME),
937
+ }
938
+
939
+
940
+ func _profile_report() -> Dictionary:
941
+ var report: Dictionary = {"success": true, "sample_count": _profile_samples.size(), "samples": _profile_samples.duplicate(true)}
942
+ if _profile_samples.is_empty():
943
+ report["summary"] = {}
944
+ return report
945
+ var latest: Dictionary = _profile_samples[_profile_samples.size() - 1]
946
+ var fps_total: float = 0.0
947
+ var frame_total: float = 0.0
948
+ for sample: Dictionary in _profile_samples:
949
+ fps_total += _metric_float(sample.get("fps", 0.0))
950
+ frame_total += _metric_float(sample.get("frame_time", 0.0))
951
+ report["summary"] = {
952
+ "fps_average": fps_total / _profile_samples.size(),
953
+ "frame_time_average": frame_total / _profile_samples.size(),
954
+ "latest_object_count": latest.get("object_count", 0),
955
+ "latest_orphan_node_count": latest.get("object_orphan_node_count", 0),
956
+ }
957
+ return report
958
+
959
+
960
+ func _metric_float(value: Variant) -> float:
961
+ if value is int or value is float:
962
+ return value
963
+ return 0.0
964
+
965
+
966
+ # --- Wait N Frames ---
967
+ func _cmd_wait(params: Dictionary) -> void:
968
+ var reader := CommandParams.new(params)
969
+ var frames: int = reader.optional_int("frames", 1, 1)
970
+ var frame_type: String = reader.optional_enum("frame_type", "render", ["render", "physics"])
971
+ if _params_invalid(reader):
972
+ return
973
+ var use_physics: bool = frame_type == "physics" or CommandParams.to_bool(params.get("physics"), false)
974
+ for i: int in frames:
975
+ if use_physics:
976
+ await get_tree().physics_frame
977
+ else:
978
+ await get_tree().process_frame
979
+ if _active_session != null and _active_session.cancellation_requested:
980
+ _send_response({})
981
+ return
982
+ _send_response({"success": true, "waited_frames": frames, "frame_type": "physics" if use_physics else "render"})
983
+
984
+
985
+ # --- Shared JSON/Variant codec boundary ---
986
+ func _variant_to_json(value: Variant) -> Variant:
987
+ _codec.configure(max_json_nesting_depth, max_json_collection_items)
988
+ return _codec.encode(value)
989
+
990
+
991
+ func _json_to_variant(value: Variant, type_hint: String = "") -> Variant:
992
+ _codec.configure(max_json_nesting_depth, max_json_collection_items)
993
+ return _codec.decode(value, type_hint)
994
+
995
+
996
+ func _json_to_variant_for_property(node: Node, property: String, value: Variant) -> Variant:
997
+ _codec.configure(max_json_nesting_depth, max_json_collection_items)
998
+ return _codec.decode_for_property(node, property, value)
999
+
1000
+
1001
+ # --- Connect Signal ---
1002
+ func _cmd_play_animation(params: Dictionary) -> void:
1003
+ var node_path: String = params.get("node_path", "")
1004
+ if node_path.is_empty():
1005
+ _send_response({"error": "node_path is required"})
1006
+ return
1007
+
1008
+ var node: Node = get_tree().root.get_node_or_null(node_path)
1009
+ if node == null:
1010
+ _send_response({"error": "Node not found: %s" % node_path})
1011
+ return
1012
+
1013
+ if not node is AnimationPlayer:
1014
+ _send_response({"error": "Node is not an AnimationPlayer: %s (is %s)" % [node_path, node.get_class()]})
1015
+ return
1016
+
1017
+ var anim_player: AnimationPlayer = node as AnimationPlayer
1018
+ var action: String = params.get("action", "play")
1019
+
1020
+ match action:
1021
+ "play":
1022
+ var animation: String = params.get("animation", "")
1023
+ if animation.is_empty():
1024
+ _send_response({"error": "animation name is required for play action"})
1025
+ return
1026
+ if not anim_player.has_animation(animation):
1027
+ _send_response({"error": "Animation '%s' not found. Available: %s" % [animation, str(anim_player.get_animation_list())]})
1028
+ return
1029
+ anim_player.play(animation)
1030
+ _send_response({"success": true, "action": "play", "animation": animation})
1031
+ "stop":
1032
+ anim_player.stop()
1033
+ _send_response({"success": true, "action": "stop"})
1034
+ "pause":
1035
+ anim_player.pause()
1036
+ _send_response({"success": true, "action": "pause"})
1037
+ "get_list":
1038
+ var anims: Array = []
1039
+ for anim_name in anim_player.get_animation_list():
1040
+ anims.append(str(anim_name))
1041
+ _send_response({"success": true, "animations": anims, "current": anim_player.current_animation, "playing": anim_player.is_playing()})
1042
+ _:
1043
+ _send_response({"error": "Unknown animation action: %s. Use play, stop, pause, or get_list" % action})
1044
+
1045
+
1046
+ # --- Tween Property ---
1047
+ func _cmd_tween_property(params: Dictionary) -> void:
1048
+ var node_path: String = params.get("node_path", "")
1049
+ var property: String = params.get("property", "")
1050
+ if node_path.is_empty() or property.is_empty():
1051
+ _send_response({"error": "node_path and property are required"})
1052
+ return
1053
+
1054
+ var node: Node = get_tree().root.get_node_or_null(node_path)
1055
+ if node == null:
1056
+ _send_response({"error": "Node not found: %s" % node_path})
1057
+ return
1058
+
1059
+ var final_value: Variant = _json_to_variant_for_property(node, property, params.get("final_value", null))
1060
+ var duration: float = CommandParams.to_float(params.get("duration"), 1.0)
1061
+ var trans_type: int = CommandParams.to_int(params.get("trans_type"), 0) # Tween.TRANS_LINEAR
1062
+ var ease_type: int = CommandParams.to_int(params.get("ease_type"), 2) # Tween.EASE_IN_OUT
1063
+
1064
+ var tween: Tween = create_tween()
1065
+ var tweener: PropertyTweener = tween.tween_property(node, property, final_value, duration)
1066
+ if tweener == null:
1067
+ tween.kill()
1068
+ _send_response({"error": "tween_property failed: value type does not match property '%s' on %s" % [property, node.get_class()]})
1069
+ return
1070
+ @warning_ignore("return_value_discarded")
1071
+ tweener.set_trans(trans_type).set_ease(ease_type)
1072
+ _send_response({"success": true, "node": node_path, "property": property, "duration": duration})
1073
+
1074
+
1075
+ # --- Get Nodes In Group ---
1076
+
1077
+
1078
+ func _cmd_create_timer(params: Dictionary) -> void:
1079
+ var parent_path: String = params.get("parent_path", "/root")
1080
+ var wait_time: float = CommandParams.to_float(params.get("wait_time"), 1.0)
1081
+ var one_shot: bool = params.get("one_shot", false)
1082
+ var autostart: bool = params.get("autostart", false)
1083
+
1084
+ var parent: Node = get_tree().root.get_node_or_null(parent_path)
1085
+ if parent == null:
1086
+ _send_response({"error": "Parent node not found: %s" % parent_path})
1087
+ return
1088
+
1089
+ var timer: Timer = Timer.new()
1090
+ timer.wait_time = wait_time
1091
+ timer.one_shot = one_shot
1092
+ timer.autostart = autostart
1093
+ var timer_name: String = CommandParams.json_string(params, "name")
1094
+ if not timer_name.is_empty():
1095
+ timer.name = timer_name
1096
+ parent.add_child(timer)
1097
+ if autostart:
1098
+ timer.start()
1099
+ _send_response({"success": true, "path": str(timer.get_path()), "name": timer.name, "wait_time": timer.wait_time, "one_shot": timer.one_shot, "autostart": autostart})
1100
+
1101
+
1102
+ # --- Set Particles ---
1103
+ func _cmd_serialize_state(params: Dictionary) -> void:
1104
+ var node_path: String = params.get("node_path", "/root")
1105
+ var action: String = params.get("action", "save")
1106
+ var max_depth: int = CommandParams.to_int(params.get("max_depth"), 5)
1107
+
1108
+ var node: Node = get_tree().root.get_node_or_null(node_path)
1109
+ if node == null:
1110
+ _send_response({"error": "Node not found: %s" % node_path})
1111
+ return
1112
+
1113
+ match action:
1114
+ "save":
1115
+ var state: Dictionary = _serialize_node(node, max_depth, 0)
1116
+ _send_response({"success": true, "action": "save", "state": state})
1117
+ "load":
1118
+ var data: Dictionary = params.get("data", {})
1119
+ if data.is_empty():
1120
+ _send_response({"error": "data is required for load action"})
1121
+ return
1122
+ var count: int = _deserialize_node(node, data)
1123
+ _send_response({"success": true, "action": "load", "restored_count": count})
1124
+ _:
1125
+ _send_response({"error": "Unknown serialize action: %s. Use save or load" % action})
1126
+
1127
+
1128
+ func _serialize_node(node: Node, max_depth: int, depth: int) -> Dictionary:
1129
+ var result: Dictionary = {
1130
+ "class": node.get_class(),
1131
+ "name": node.name,
1132
+ "path": str(node.get_path()),
1133
+ }
1134
+ # Capture editor-visible properties
1135
+ var props: Dictionary = {}
1136
+ for prop in node.get_property_list():
1137
+ var prop_dict: Dictionary = prop
1138
+ if prop_dict.get("usage", 0) & PROPERTY_USAGE_STORAGE:
1139
+ var prop_name: String = prop_dict.get("name", "")
1140
+ if prop_name.is_empty() or prop_name.begins_with("_"):
1141
+ continue
1142
+ props[prop_name] = _variant_to_json(node.get(prop_name))
1143
+ result["properties"] = props
1144
+
1145
+ if depth < max_depth:
1146
+ var children: Array = []
1147
+ for child in node.get_children():
1148
+ # Skip the MCP interaction server itself
1149
+ if child == self:
1150
+ continue
1151
+ children.append(_serialize_node(child, max_depth, depth + 1))
1152
+ result["children"] = children
1153
+
1154
+ return result
1155
+
1156
+
1157
+ func _deserialize_node(node: Node, data: Dictionary) -> int:
1158
+ var count: int = 0
1159
+ # Restore properties
1160
+ var props: Dictionary = CommandParams.json_dictionary(data, "properties")
1161
+ for prop_name: Variant in props:
1162
+ var property: String = str(prop_name)
1163
+ var value: Variant = _json_to_variant_for_property(node, property, props[prop_name])
1164
+ node.set(property, value)
1165
+ count += 1
1166
+
1167
+ # Restore children
1168
+ var children_data: Array = CommandParams.json_array(data, "children")
1169
+ for child_data: Variant in children_data:
1170
+ if not child_data is Dictionary:
1171
+ continue
1172
+ var child_state: Dictionary = child_data
1173
+ var child_name: String = CommandParams.json_string(child_state, "name")
1174
+ var child: Node = null
1175
+ for c: Node in node.get_children():
1176
+ if c.name == child_name:
1177
+ child = c
1178
+ break
1179
+ if child != null:
1180
+ count += _deserialize_node(child, child_state)
1181
+ return count
1182
+
1183
+
1184
+ # --- Bone Pose ---
1185
+
1186
+
1187
+
1188
+ func _cmd_script(params: Dictionary) -> void:
1189
+ var node_path: String = params.get("node_path", "")
1190
+ var node: Node = get_tree().root.get_node_or_null(node_path)
1191
+ if node == null:
1192
+ _send_response({"error": "Node not found: %s" % node_path})
1193
+ return
1194
+ var action: String = params.get("action", "get_source")
1195
+ match action:
1196
+ "get_source":
1197
+ var attached: Variant = node.get_script()
1198
+ if attached == null:
1199
+ _send_response({"success": true, "has_script": false})
1200
+ return
1201
+ var script_resource: Script = attached
1202
+ var source_code: String = ""
1203
+ if script_resource is GDScript:
1204
+ var gdscript: GDScript = script_resource
1205
+ source_code = gdscript.source_code
1206
+ _send_response({"success": true, "has_script": true, "source": source_code, "path": script_resource.resource_path})
1207
+ "attach":
1208
+ var source: String = params.get("source", "")
1209
+ if source.is_empty():
1210
+ _send_response({"error": "source is required for attach"})
1211
+ return
1212
+ var s: GDScript = GDScript.new()
1213
+ s.source_code = source
1214
+ var err: int = s.reload()
1215
+ if err != OK:
1216
+ _send_response({"error": "Script compile error: %d" % err})
1217
+ return
1218
+ node.set_script(s)
1219
+ _send_response({"success": true, "action": "attach", "node_path": node_path})
1220
+ "detach":
1221
+ node.set_script(null)
1222
+ _send_response({"success": true, "action": "detach", "node_path": node_path})
1223
+ _:
1224
+ _send_response({"error": "Unknown script action: %s" % action})
1225
+
1226
+
1227
+
1228
+ # ==========================================================================
1229
+ # Batch 2: 3D Rendering + Lighting + Sky + Physics
1230
+ # ==========================================================================
1231
+
1232
+
1233
+
1234
+
1235
+
1236
+
1237
+
1238
+
1239
+
1240
+
1241
+
1242
+
1243
+ func _exit_tree() -> void:
1244
+ if _active_session != null and _active_session.request_running:
1245
+ _active_session.request_state = "abandoned"
1246
+ _audit_request("request_abandoned", _active_session)
1247
+ for session: RuntimeSession in _sessions.values():
1248
+ if session.peer != null:
1249
+ session.peer.disconnect_from_host()
1250
+ _sessions.clear()
1251
+ _active_session = null
1252
+ if _server != null:
1253
+ _server.stop()
1254
+ _server = null
1255
+ print("McpInteractionServer: Stopped")