@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,417 @@
1
+ @tool
2
+ extends EditorPlugin
3
+
4
+ ## Small, authenticated editor bridge used by editor-capable integrations.
5
+ ## Mutations are recorded through EditorUndoRedoManager; the bridge never edits
6
+ ## scene text directly and never evaluates arbitrary code in the editor.
7
+
8
+ var _server: TCPServer
9
+ var _peer: StreamPeerTCP
10
+ var _buffer: PackedByteArray = PackedByteArray()
11
+ var _port: int = 9091
12
+ var _secret: String = ""
13
+ var _activity_dock: VBoxContainer
14
+ var _activity_list: ItemList
15
+ var _connection_status: Label
16
+ var _compatibility_status: Label
17
+ var _driver_status: Label
18
+ var _driver_pause_button: Button
19
+ var _driver_paused: bool = false
20
+ var _session_authenticated: bool = false
21
+ var _server_version: String = ""
22
+ var _last_history_id: int = EditorUndoRedoManager.GLOBAL_HISTORY
23
+ var _activity_entries: Array[Dictionary] = []
24
+ var _last_filesystem_sync: Dictionary = {}
25
+ const MAX_ACTIVITY_ENTRIES: int = 200
26
+ const PROTOCOL_VERSION: String = "1"
27
+ const ADDON_VERSION: String = "1.0.0"
28
+
29
+ func _enter_tree() -> void:
30
+ set_process(true)
31
+ _port = _read_port()
32
+ _secret = OS.get_environment("GODOT_MCP_EDITOR_SECRET")
33
+ _driver_paused = OS.get_environment("GODOT_MCP_EDITOR_START_PAUSED").strip_edges().to_lower() in ["1", "true", "yes"]
34
+ _server = TCPServer.new()
35
+ _create_activity_dock()
36
+ var error: int = _server.listen(_port, "127.0.0.1")
37
+ if error != OK:
38
+ push_error("Godot Agent Loop editor bridge could not listen on %d: %s" % [_port, error_string(error)])
39
+ _set_connection_status("Unavailable — port %d: %s" % [_port, error_string(error)])
40
+ elif _secret.is_empty():
41
+ _set_connection_status("Waiting — relaunch the editor through Godot Agent Loop")
42
+ else:
43
+ _set_connection_status("Waiting for an authenticated agent")
44
+
45
+ func _exit_tree() -> void:
46
+ set_process(false)
47
+ if _peer != null:
48
+ _peer.disconnect_from_host()
49
+ _peer = null
50
+ if _server != null:
51
+ _server.stop()
52
+ _server = null
53
+ if _activity_dock != null:
54
+ remove_control_from_docks(_activity_dock)
55
+ _activity_dock.queue_free()
56
+ _activity_dock = null
57
+ _activity_list = null
58
+ _connection_status = null
59
+ _compatibility_status = null
60
+ _driver_status = null
61
+ _driver_pause_button = null
62
+
63
+ func _process(_delta: float) -> void:
64
+ if _server != null and _server.is_connection_available():
65
+ if _peer != null:
66
+ _server.take_connection().disconnect_from_host()
67
+ else:
68
+ _peer = _server.take_connection()
69
+ _session_authenticated = false
70
+ _server_version = ""
71
+ _set_connection_status("Connected — authenticating")
72
+ if _peer == null:
73
+ return
74
+ var poll_error: Error = _peer.poll()
75
+ if poll_error != OK:
76
+ return
77
+ if _peer.get_status() != StreamPeerTCP.STATUS_CONNECTED:
78
+ _peer = null
79
+ _buffer = PackedByteArray()
80
+ _session_authenticated = false
81
+ _server_version = ""
82
+ _set_connection_status("Waiting for an authenticated agent")
83
+ return
84
+ var available: int = _peer.get_available_bytes()
85
+ if available <= 0:
86
+ return
87
+ var incoming: Array = _peer.get_data(mini(available, 64 * 1024))
88
+ if incoming[0] != OK:
89
+ return
90
+ var incoming_variant: Variant = incoming[1]
91
+ if not incoming_variant is PackedByteArray:
92
+ return
93
+ var incoming_bytes: PackedByteArray = incoming_variant
94
+ _buffer.append_array(incoming_bytes)
95
+ while true:
96
+ var newline: int = _buffer.find(10)
97
+ if newline < 0:
98
+ break
99
+ var line: String = _buffer.slice(0, newline).get_string_from_utf8().strip_edges()
100
+ _buffer = _buffer.slice(newline + 1)
101
+ if line.is_empty():
102
+ continue
103
+ _handle_request(line)
104
+
105
+ func _handle_request(line: String) -> void:
106
+ var parsed: Variant = JSON.parse_string(line)
107
+ if not parsed is Dictionary:
108
+ _send({"id": null, "error": "invalid_request"})
109
+ return
110
+ var request: Dictionary = parsed
111
+ if _secret.is_empty() or str(request.get("secret", "")) != _secret:
112
+ _send({"id": request.get("id", null), "error": "authentication_required"})
113
+ return
114
+ var command: String = str(request.get("command", ""))
115
+ if command == "handshake":
116
+ var handshake: Dictionary = _handshake(request.get("params", {}))
117
+ handshake["id"] = request.get("id", null)
118
+ _send(handshake)
119
+ return
120
+ if not _session_authenticated:
121
+ _send({"id": request.get("id", null), "error": "handshake_required", "protocol_version": PROTOCOL_VERSION})
122
+ return
123
+ var result: Dictionary = _dispatch(command, request.get("params", {}))
124
+ result["id"] = request.get("id", null)
125
+ _send(result)
126
+
127
+ func _dispatch(command: String, raw_params: Variant) -> Dictionary:
128
+ var params: Dictionary = raw_params if raw_params is Dictionary else {}
129
+ match command:
130
+ "inspect":
131
+ return _inspect()
132
+ "activity":
133
+ return _record_activity(params)
134
+ "driver_state":
135
+ return _driver_state()
136
+ "filesystem_changed":
137
+ return _sync_filesystem(params)
138
+ "select":
139
+ return _select(params)
140
+ "save":
141
+ var save_error: Error = EditorInterface.save_scene()
142
+ return {"success": save_error == OK, "saved": save_error == OK, "error_code": save_error}
143
+ "reload":
144
+ var scene_path: String = str(params.get("scene_path", ""))
145
+ EditorInterface.reload_scene_from_path(scene_path)
146
+ return {"success": true, "scene_path": scene_path}
147
+ "open_scene":
148
+ var open_path: String = str(params.get("scene_path", ""))
149
+ if open_path.is_empty():
150
+ return {"error": "scene_path is required"}
151
+ EditorInterface.open_scene_from_path(open_path)
152
+ return {"success": true, "scene_path": open_path}
153
+ "set_property":
154
+ return _set_property(params)
155
+ "rename_node":
156
+ return _rename_node(params)
157
+ "undo":
158
+ var undo_redo: EditorUndoRedoManager = EditorInterface.get_editor_undo_redo()
159
+ if undo_redo == null:
160
+ return {"success": false, "action": "undo"}
161
+ var undo_history: UndoRedo = undo_redo.get_history_undo_redo(_last_history_id)
162
+ return {"success": undo_history.undo(), "action": "undo"}
163
+ "redo":
164
+ var redo_manager: EditorUndoRedoManager = EditorInterface.get_editor_undo_redo()
165
+ if redo_manager == null:
166
+ return {"success": false, "action": "redo"}
167
+ var redo_history: UndoRedo = redo_manager.get_history_undo_redo(_last_history_id)
168
+ return {"success": redo_history.redo(), "action": "redo"}
169
+ _:
170
+ return {"error": "unknown_command", "allowed": ["inspect", "activity", "driver_state", "filesystem_changed", "select", "save", "reload", "open_scene", "set_property", "rename_node", "undo", "redo"]}
171
+
172
+ func _handshake(raw_params: Variant) -> Dictionary:
173
+ var params: Dictionary = raw_params if raw_params is Dictionary else {}
174
+ var requested_protocol: String = str(params.get("protocol_version", ""))
175
+ _server_version = str(params.get("server_version", ""))
176
+ if requested_protocol != PROTOCOL_VERSION:
177
+ _session_authenticated = false
178
+ _set_connection_status("Incompatible protocol — server %s, addon %s" % [requested_protocol, PROTOCOL_VERSION])
179
+ return {
180
+ "error": "incompatible_protocol",
181
+ "requested_protocol": requested_protocol,
182
+ "protocol_version": PROTOCOL_VERSION,
183
+ "addon_version": ADDON_VERSION,
184
+ }
185
+ _session_authenticated = true
186
+ _set_connection_status("Authenticated — server %s" % (_server_version if not _server_version.is_empty() else "unknown"))
187
+ return {
188
+ "success": true,
189
+ "product": "Godot Agent Loop",
190
+ "protocol_version": PROTOCOL_VERSION,
191
+ "addon_version": ADDON_VERSION,
192
+ "server_version": _server_version,
193
+ "godot_version": Engine.get_version_info().get("string", "unknown"),
194
+ }
195
+
196
+ func _create_activity_dock() -> void:
197
+ _activity_dock = VBoxContainer.new()
198
+ _activity_dock.name = "Godot Agent Loop"
199
+ var title := Label.new()
200
+ title.text = "Agent Activity"
201
+ title.tooltip_text = "Live authenticated MCP command lifecycle"
202
+ _activity_dock.add_child(title)
203
+ _connection_status = Label.new()
204
+ _connection_status.name = "ConnectionStatus"
205
+ _connection_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
206
+ _activity_dock.add_child(_connection_status)
207
+ _compatibility_status = Label.new()
208
+ _compatibility_status.name = "CompatibilityStatus"
209
+ _compatibility_status.text = "Addon %s · protocol %s · Godot %s" % [ADDON_VERSION, PROTOCOL_VERSION, Engine.get_version_info().get("string", "unknown")]
210
+ _compatibility_status.tooltip_text = "The MCP server and addon must use the same editor protocol version."
211
+ _activity_dock.add_child(_compatibility_status)
212
+ var driver_row := HBoxContainer.new()
213
+ driver_row.name = "DriverControls"
214
+ _driver_status = Label.new()
215
+ _driver_status.size_flags_horizontal = Control.SIZE_EXPAND_FILL
216
+ driver_row.add_child(_driver_status)
217
+ _driver_pause_button = Button.new()
218
+ var connect_error: Error = _driver_pause_button.pressed.connect(_toggle_driver_pause) as Error
219
+ if connect_error != OK:
220
+ push_error("Godot Agent Loop could not connect its pause control: %s" % error_string(connect_error))
221
+ driver_row.add_child(_driver_pause_button)
222
+ _activity_dock.add_child(driver_row)
223
+ _update_driver_controls()
224
+ _activity_list = ItemList.new()
225
+ _activity_list.name = "Activity"
226
+ _activity_list.custom_minimum_size = Vector2(320, 180)
227
+ _activity_list.size_flags_vertical = Control.SIZE_EXPAND_FILL
228
+ _activity_dock.add_child(_activity_list)
229
+ var setup_title := Label.new()
230
+ setup_title.text = "Setup help"
231
+ _activity_dock.add_child(setup_title)
232
+ var setup_help := RichTextLabel.new()
233
+ setup_help.name = "SetupHelp"
234
+ setup_help.fit_content = true
235
+ setup_help.custom_minimum_size = Vector2(320, 96)
236
+ setup_help.text = "Claude Code / Codex: install the Godot Agent Loop plugin\nOpenCode: godot-agent-loop setup opencode --write\nPi: pi install npm:@beremaran/godot-agent-loop\nRelaunch the editor through the MCP launch_editor tool to authenticate."
237
+ _activity_dock.add_child(setup_help)
238
+ add_control_to_dock(DOCK_SLOT_RIGHT_BL, _activity_dock)
239
+
240
+ func _set_connection_status(message: String) -> void:
241
+ if _connection_status != null:
242
+ _connection_status.text = "Connection: %s" % message
243
+
244
+ func _toggle_driver_pause() -> void:
245
+ _driver_paused = not _driver_paused
246
+ _update_driver_controls()
247
+
248
+ func _update_driver_controls() -> void:
249
+ if _driver_status != null:
250
+ _driver_status.text = "Agent paused — human editing" if _driver_paused else "Agent is driving"
251
+ if _driver_pause_button != null:
252
+ _driver_pause_button.text = "Resume Agent" if _driver_paused else "Pause Agent"
253
+ _driver_pause_button.tooltip_text = "Allow subsequent MCP mutations" if _driver_paused else "Refuse subsequent MCP mutations while you edit"
254
+
255
+ func _driver_state() -> Dictionary:
256
+ return {"success": true, "paused": _driver_paused, "agent_driving": not _driver_paused}
257
+
258
+ func _record_activity(params: Dictionary) -> Dictionary:
259
+ var event: String = str(params.get("event", ""))
260
+ if not event in ["request_started", "request_finished", "request_timed_out"]:
261
+ return {"error": "invalid_activity_event"}
262
+ var command: String = str(params.get("command", "unknown"))
263
+ var target: String = str(params.get("target", "game"))
264
+ var outcome: String = str(params.get("outcome", "running"))
265
+ var raw_duration_ms: Variant = params.get("duration_ms", 0)
266
+ var duration_ms: int = 0
267
+ if raw_duration_ms is int:
268
+ duration_ms = raw_duration_ms
269
+ elif raw_duration_ms is float:
270
+ var float_duration_ms: float = raw_duration_ms
271
+ duration_ms = roundi(float_duration_ms)
272
+ var correlation_id: String = str(params.get("correlation_id", ""))
273
+ var marker: String = "…" if event == "request_started" else "✓" if outcome == "success" else "✗"
274
+ var suffix: String = "" if event == "request_started" else " · %d ms" % duration_ms
275
+ var text: String = "%s %s → %s%s" % [marker, command, target, suffix]
276
+ var entry: Dictionary = {
277
+ "event": event, "correlation_id": correlation_id, "command": command,
278
+ "target": target, "outcome": outcome, "duration_ms": duration_ms, "text": text,
279
+ }
280
+ _activity_entries.append(entry)
281
+ var item_index: int = _activity_list.add_item(text)
282
+ _activity_list.set_item_tooltip(item_index, correlation_id)
283
+ if event != "request_started":
284
+ var color := Color(0.35, 0.85, 0.45) if outcome == "success" else Color(1.0, 0.4, 0.35)
285
+ _activity_list.set_item_custom_fg_color(item_index, color)
286
+ while _activity_entries.size() > MAX_ACTIVITY_ENTRIES:
287
+ _activity_entries.pop_front()
288
+ _activity_list.remove_item(0)
289
+ return {"success": true, "activity_count": _activity_entries.size()}
290
+
291
+ func _sync_filesystem(params: Dictionary) -> Dictionary:
292
+ var filesystem: EditorFileSystem = EditorInterface.get_resource_filesystem()
293
+ filesystem.scan()
294
+ var scene_path: String = str(params.get("scene_path", ""))
295
+ var root: Node = EditorInterface.get_edited_scene_root()
296
+ var reloaded: bool = false
297
+ if root != null and not scene_path.is_empty() and root.scene_file_path == scene_path:
298
+ EditorInterface.reload_scene_from_path(scene_path)
299
+ reloaded = true
300
+ var focus_path: String = str(params.get("focus_path", ""))
301
+ var focused: bool = false
302
+ if reloaded and not focus_path.is_empty():
303
+ focused = _focus_editor_node(focus_path)
304
+ _last_filesystem_sync = {
305
+ "success": true, "rescanned": true, "scene_path": scene_path,
306
+ "resource_path": str(params.get("resource_path", "")), "reloaded": reloaded,
307
+ "command": str(params.get("command", "")), "focus_path": focus_path,
308
+ "focused": focused,
309
+ }
310
+ return _last_filesystem_sync.duplicate(true)
311
+
312
+ func _inspect() -> Dictionary:
313
+ var root: Node = EditorInterface.get_edited_scene_root()
314
+ var selected: Array[String] = []
315
+ var selection: EditorSelection = EditorInterface.get_selection()
316
+ for node: Node in selection.get_selected_nodes():
317
+ selected.append(str(node.get_path()))
318
+ var open_scenes: Array = []
319
+ var open_result: Variant = EditorInterface.get_open_scenes()
320
+ if open_result is PackedStringArray:
321
+ var packed_open_scenes: PackedStringArray = open_result
322
+ open_scenes.assign(packed_open_scenes)
323
+ var edited_root: Variant = null
324
+ if root != null:
325
+ edited_root = {"name": root.name, "type": root.get_class(), "path": str(root.get_path())}
326
+ return {"success": true, "edited_scene": "" if root == null else str(root.scene_file_path),
327
+ "edited_root": edited_root,
328
+ "selection": selected, "open_scenes": open_scenes,
329
+ "has_undo_redo": EditorInterface.get_editor_undo_redo() != null,
330
+ "activity_dock": _activity_dock != null, "activity": _activity_entries.duplicate(true),
331
+ "driver_paused": _driver_paused, "agent_driving": not _driver_paused,
332
+ "authenticated": _session_authenticated, "server_version": _server_version,
333
+ "addon_version": ADDON_VERSION, "protocol_version": PROTOCOL_VERSION,
334
+ "godot_version": Engine.get_version_info().get("string", "unknown"),
335
+ "last_filesystem_sync": _last_filesystem_sync.duplicate(true)}
336
+
337
+ func _select(params: Dictionary) -> Dictionary:
338
+ var selection: EditorSelection = EditorInterface.get_selection()
339
+ selection.clear()
340
+ var root: Node = EditorInterface.get_edited_scene_root()
341
+ if root == null:
342
+ return {"error": "no_edited_scene"}
343
+ var selected: Array[String] = []
344
+ var paths: Variant = params.get("node_paths", [])
345
+ if not paths is Array:
346
+ return {"error": "node_paths must be an array"}
347
+ for raw_path: Variant in paths:
348
+ var node: Node = root.get_node_or_null(NodePath(str(raw_path)))
349
+ if node == null:
350
+ return {"error": "node_not_found", "node_path": str(raw_path)}
351
+ selection.add_node(node)
352
+ selected.append(str(node.get_path()))
353
+ if selected.size() == 1:
354
+ EditorInterface.edit_node(node)
355
+ return {"success": true, "selection": selected}
356
+
357
+ func _focus_editor_node(path: String) -> bool:
358
+ var root: Node = EditorInterface.get_edited_scene_root()
359
+ if root == null:
360
+ return false
361
+ var normalized: String = path.trim_prefix("root/")
362
+ var node: Node = root if normalized == "." or normalized == "root" or normalized.is_empty() else root.get_node_or_null(NodePath(normalized))
363
+ if node == null:
364
+ return false
365
+ var selection: EditorSelection = EditorInterface.get_selection()
366
+ selection.clear()
367
+ selection.add_node(node)
368
+ EditorInterface.edit_node(node)
369
+ return true
370
+
371
+ func _set_property(params: Dictionary) -> Dictionary:
372
+ var target: Node = _edited_node(params)
373
+ var property: String = str(params.get("property", ""))
374
+ if target == null: return {"error": "node_not_found"}
375
+ if property.is_empty() or not property in target: return {"error": "property_not_found", "property": property}
376
+ var before: Variant = target.get(property)
377
+ var after: Variant = params.get("value")
378
+ return _commit_property_action(target, property, before, after)
379
+
380
+ func _rename_node(params: Dictionary) -> Dictionary:
381
+ var target: Node = _edited_node(params)
382
+ var new_name: String = str(params.get("name", ""))
383
+ if target == null: return {"error": "node_not_found"}
384
+ if new_name.is_empty() or not NodePath(new_name).is_absolute() and new_name.contains("/"):
385
+ return {"error": "invalid_name"}
386
+ var before: String = target.name
387
+ return _commit_property_action(target, "name", before, new_name)
388
+
389
+ func _edited_node(params: Dictionary) -> Node:
390
+ var root: Node = EditorInterface.get_edited_scene_root()
391
+ if root == null: return null
392
+ var path: String = str(params.get("node_path", "."))
393
+ return root if path == "." or path.is_empty() else root.get_node_or_null(NodePath(path))
394
+
395
+ func _commit_property_action(target: Node, property: String, before: Variant, after: Variant) -> Dictionary:
396
+ var manager: EditorUndoRedoManager = EditorInterface.get_editor_undo_redo()
397
+ if manager == null: return {"error": "undo_redo_unavailable"}
398
+ manager.create_action("MCP %s" % property)
399
+ manager.add_do_property(target, property, after)
400
+ manager.add_undo_property(target, property, before)
401
+ manager.commit_action()
402
+ _last_history_id = manager.get_object_history_id(target)
403
+ return {"success": true, "property": property, "before": before, "after": target.get(property), "undo_recorded": true}
404
+
405
+ func _send(response: Dictionary) -> void:
406
+ if _peer == null:
407
+ return
408
+ var send_error: Error = _peer.put_data((JSON.stringify(response) + "\n").to_utf8_buffer())
409
+ if send_error != OK:
410
+ push_warning("Godot Agent Loop editor bridge send failed: %s" % error_string(send_error))
411
+
412
+ func _read_port() -> int:
413
+ var configured: String = OS.get_environment("GODOT_MCP_EDITOR_PORT")
414
+ if configured.is_valid_int():
415
+ var value: int = int(configured)
416
+ if value > 0 and value < 65536: return value
417
+ return _port