@satelliteoflove/godot-mcp 3.11.0 → 3.13.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 (47) hide show
  1. package/addon/commands/game_time_commands.gd +14 -6
  2. package/addon/commands/input_commands.gd +70 -20
  3. package/addon/commands/screenshot_commands.gd +7 -8
  4. package/addon/core/mcp_debugger_plugin.gd +22 -4
  5. package/addon/game_bridge/mcp_game_bridge.gd +117 -22
  6. package/addon/plugin.cfg +1 -1
  7. package/dist/__tests__/connection/timeouts.test.d.ts +2 -0
  8. package/dist/__tests__/connection/timeouts.test.d.ts.map +1 -0
  9. package/dist/__tests__/connection/timeouts.test.js +58 -0
  10. package/dist/__tests__/connection/timeouts.test.js.map +1 -0
  11. package/dist/__tests__/connection/websocket.test.js +58 -0
  12. package/dist/__tests__/connection/websocket.test.js.map +1 -1
  13. package/dist/__tests__/helpers/mock-godot.d.ts +3 -0
  14. package/dist/__tests__/helpers/mock-godot.d.ts.map +1 -1
  15. package/dist/__tests__/helpers/mock-godot.js +2 -2
  16. package/dist/__tests__/helpers/mock-godot.js.map +1 -1
  17. package/dist/__tests__/tools/editor.test.js +1 -1
  18. package/dist/__tests__/tools/editor.test.js.map +1 -1
  19. package/dist/__tests__/tools/game-time.test.js +45 -5
  20. package/dist/__tests__/tools/game-time.test.js.map +1 -1
  21. package/dist/__tests__/tools/input.test.js +115 -0
  22. package/dist/__tests__/tools/input.test.js.map +1 -1
  23. package/dist/connection/timeouts.d.ts +47 -0
  24. package/dist/connection/timeouts.d.ts.map +1 -0
  25. package/dist/connection/timeouts.js +72 -0
  26. package/dist/connection/timeouts.js.map +1 -0
  27. package/dist/connection/websocket.d.ts +3 -1
  28. package/dist/connection/websocket.d.ts.map +1 -1
  29. package/dist/connection/websocket.js +8 -4
  30. package/dist/connection/websocket.js.map +1 -1
  31. package/dist/core/types.d.ts +2 -1
  32. package/dist/core/types.d.ts.map +1 -1
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +7 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/tools/editor.d.ts +0 -2
  37. package/dist/tools/editor.d.ts.map +1 -1
  38. package/dist/tools/editor.js +7 -9
  39. package/dist/tools/editor.js.map +1 -1
  40. package/dist/tools/game-time.d.ts.map +1 -1
  41. package/dist/tools/game-time.js +33 -8
  42. package/dist/tools/game-time.js.map +1 -1
  43. package/dist/tools/input.d.ts +2 -0
  44. package/dist/tools/input.d.ts.map +1 -1
  45. package/dist/tools/input.js +78 -3
  46. package/dist/tools/input.js.map +1 -1
  47. package/package.json +1 -1
@@ -4,10 +4,11 @@ class_name MCPGameTimeCommands
4
4
 
5
5
  # Game-time control relay: freeze / step / step_until / thaw / status execute in
6
6
  # the game bridge (see mcp_game_bridge.gd); this side only forwards over the
7
- # debugger channel and waits. Timeout cascade: a step/step_until request is
8
- # capped at 20s of game time and the bridge's wall budget returns by 25s, so the
9
- # 28s relay timeout below fires only if the bridge is gone and stays under the
10
- # server's 30s command timeout so errors surface typed instead of generic.
7
+ # debugger channel and waits. Timeout cascade (#276): the server derives the
8
+ # whole stagger from the call's in-game budget and pushes relay_timeout_ms down
9
+ # in params; we wait exactly that long, so the bridge (which returns by its
10
+ # pushed wall budget) answers first and errors surface typed. BASE_TIMEOUT and
11
+ # STEP_TIMEOUT are fallbacks only — for an older server that pushes no budget.
11
12
  const BASE_TIMEOUT := 10.0
12
13
  const STEP_TIMEOUT := 28.0
13
14
 
@@ -29,11 +30,11 @@ func game_time_freeze(params: Dictionary) -> Dictionary:
29
30
 
30
31
 
31
32
  func game_time_step(params: Dictionary) -> Dictionary:
32
- return await _relay("game_time_step", [params], STEP_TIMEOUT)
33
+ return await _relay("game_time_step", [params], _relay_timeout(params, STEP_TIMEOUT))
33
34
 
34
35
 
35
36
  func game_time_step_until(params: Dictionary) -> Dictionary:
36
- return await _relay("game_time_step_until", [params], STEP_TIMEOUT)
37
+ return await _relay("game_time_step_until", [params], _relay_timeout(params, STEP_TIMEOUT))
37
38
 
38
39
 
39
40
  func game_time_thaw(params: Dictionary) -> Dictionary:
@@ -44,6 +45,13 @@ func game_time_status(params: Dictionary) -> Dictionary:
44
45
  return await _relay("game_time_status", [params], BASE_TIMEOUT)
45
46
 
46
47
 
48
+ func _relay_timeout(params: Dictionary, fallback: float) -> float:
49
+ # Use the server-pushed relay budget when present (#276); the local constant
50
+ # is only a fallback for an older server that does not derive the cascade.
51
+ var ms: float = float(params.get("relay_timeout_ms", fallback * 1000.0))
52
+ return ms / 1000.0
53
+
54
+
47
55
  func _relay(msg_type: String, args: Array, timeout: float) -> Dictionary:
48
56
  var response = await _send_and_wait(msg_type, args, timeout)
49
57
  if response == null:
@@ -14,6 +14,9 @@ var _input_map_pending: bool = false
14
14
 
15
15
  var _sequence_result: Dictionary = {}
16
16
  var _sequence_pending: bool = false
17
+ # Frames captured mid-sequence (#239), collected from sequence_capture_received
18
+ # signals and attached to the result once the sequence completes.
19
+ var _sequence_captures: Array = []
17
20
 
18
21
 
19
22
  var _type_text_result: Dictionary = {}
@@ -31,19 +34,30 @@ func get_commands() -> Dictionary:
31
34
  }
32
35
 
33
36
 
37
+ # Total wall budget for a long-running input command. The server derives the
38
+ # whole cascade and pushes relay_timeout_ms down in params (#276); the local
39
+ # fallback is used only for an older server that pushes no budget.
40
+ func _pushed_budget(params: Dictionary, fallback: float) -> float:
41
+ if params.has("relay_timeout_ms"):
42
+ return float(params["relay_timeout_ms"]) / 1000.0
43
+ return fallback
44
+
45
+
34
46
  # Block until the running game's bridge reports it can consume input, bounded by
35
- # READY_TIMEOUT. Returns true once ready, false if the game stops or never comes
36
- # up in time. In the common case (game already running) this returns immediately
37
- # without waiting a frame. Gating input on this is the fix for #241: the debug
38
- # session connects before the main scene loads, so input dispatched on
39
- # has_active_session() alone lands in a game with nothing to receive it.
40
- func _await_bridge_ready(debugger_plugin) -> bool:
41
- var start_time := Time.get_ticks_msec()
47
+ # READY_TIMEOUT and the shared call deadline (op_start + total_budget) so the
48
+ # ready-wait can never eat the budget the command itself needs (#276). Returns
49
+ # true once ready, false if the game stops or never comes up in time. In the
50
+ # common case (game already running) this returns immediately without waiting a
51
+ # frame. Gating input on this is the fix for #241: the debug session connects
52
+ # before the main scene loads, so input dispatched on has_active_session() alone
53
+ # lands in a game with nothing to receive it.
54
+ func _await_bridge_ready(debugger_plugin, op_start: int, total_budget: float) -> bool:
42
55
  while not debugger_plugin.is_bridge_ready():
43
56
  if not EditorInterface.is_playing_scene():
44
57
  return false # game stopped or crashed while we waited
45
58
  await Engine.get_main_loop().process_frame
46
- if (Time.get_ticks_msec() - start_time) / 1000.0 > READY_TIMEOUT:
59
+ var elapsed := (Time.get_ticks_msec() - op_start) / 1000.0
60
+ if elapsed > READY_TIMEOUT or elapsed > total_budget:
47
61
  return false
48
62
  return true
49
63
 
@@ -132,6 +146,8 @@ func _on_input_map_received(actions: Array, error: String) -> void:
132
146
  func execute_input_sequence(params: Dictionary) -> Dictionary:
133
147
  var inputs: Array = params.get("inputs", [])
134
148
  var report: Array = params.get("report", [])
149
+ var screenshots: Array = params.get("screenshot_at_ms", [])
150
+ var screenshot_max_width: int = int(params.get("screenshot_max_width", 640))
135
151
  if inputs.is_empty():
136
152
  return _error("INVALID_PARAMS", "inputs array is required and must not be empty")
137
153
 
@@ -141,35 +157,54 @@ func execute_input_sequence(params: Dictionary) -> Dictionary:
141
157
  var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
142
158
  if debugger_plugin == null:
143
159
  return _error("NO_SESSION", "No active debug session")
144
- if not await _await_bridge_ready(debugger_plugin):
145
- return _error("BRIDGE_NOT_READY", _BRIDGE_NOT_READY_MSG)
146
-
160
+ # One deadline for the whole call, stamped BEFORE the ready-wait so the
161
+ # bridge-ready gap is folded into the budget instead of stacking on top of
162
+ # it (#276). Prefer the server-pushed budget; the fallback (older server) is
163
+ # the longest input/capture offset plus headroom, floored at INPUT_TIMEOUT,
164
+ # plus the ready-wait that now counts against the same deadline.
165
+ var op_start := Time.get_ticks_msec()
147
166
  var max_end_time: float = 0.0
148
167
  for input in inputs:
149
168
  var start_ms: float = input.get("start_ms", 0.0)
150
169
  var duration_ms: float = input.get("duration_ms", 0.0)
151
170
  max_end_time = max(max_end_time, start_ms + duration_ms)
171
+ for shot_ms in screenshots:
172
+ max_end_time = max(max_end_time, float(shot_ms))
173
+ var fallback: float = max(INPUT_TIMEOUT, (max_end_time / 1000.0) + 5.0) + READY_TIMEOUT
174
+ var timeout := _pushed_budget(params, fallback)
152
175
 
153
- var timeout := max(INPUT_TIMEOUT, (max_end_time / 1000.0) + 5.0)
176
+ if not await _await_bridge_ready(debugger_plugin, op_start, timeout):
177
+ return _error("BRIDGE_NOT_READY", _BRIDGE_NOT_READY_MSG)
154
178
 
155
179
  _sequence_pending = true
156
180
  _sequence_result = {}
181
+ _sequence_captures = []
157
182
 
183
+ # Captures stream in as separate signals before the final result; collect
184
+ # them for the duration of the wait (not one-shot), then detach.
185
+ debugger_plugin.sequence_capture_received.connect(_on_sequence_capture)
158
186
  debugger_plugin.input_sequence_completed.connect(_on_sequence_completed, CONNECT_ONE_SHOT)
159
- debugger_plugin.request_input_sequence(inputs, report)
187
+ debugger_plugin.request_input_sequence(inputs, report, screenshots, screenshot_max_width)
160
188
 
161
- var start_time := Time.get_ticks_msec()
162
189
  while _sequence_pending:
163
190
  await Engine.get_main_loop().process_frame
164
- if (Time.get_ticks_msec() - start_time) / 1000.0 > timeout:
191
+ if (Time.get_ticks_msec() - op_start) / 1000.0 > timeout:
165
192
  _sequence_pending = false
166
193
  if debugger_plugin.input_sequence_completed.is_connected(_on_sequence_completed):
167
194
  debugger_plugin.input_sequence_completed.disconnect(_on_sequence_completed)
195
+ if debugger_plugin.sequence_capture_received.is_connected(_on_sequence_capture):
196
+ debugger_plugin.sequence_capture_received.disconnect(_on_sequence_capture)
168
197
  return _error("TIMEOUT", "Timed out waiting for input sequence to complete")
169
198
 
199
+ if debugger_plugin.sequence_capture_received.is_connected(_on_sequence_capture):
200
+ debugger_plugin.sequence_capture_received.disconnect(_on_sequence_capture)
201
+
170
202
  if _sequence_result.has("error"):
171
203
  return _error("SEQUENCE_ERROR", _sequence_result.get("error", "Unknown error"))
172
204
 
205
+ if not _sequence_captures.is_empty():
206
+ _sequence_result["captures"] = _sequence_captures
207
+
173
208
  return _success(_sequence_result)
174
209
 
175
210
 
@@ -178,6 +213,18 @@ func _on_sequence_completed(result: Dictionary) -> void:
178
213
  _sequence_result = result
179
214
 
180
215
 
216
+ func _on_sequence_capture(requested_ms: int, actual_ms: int, ok: bool, image_base64: String, width: int, height: int, error: String) -> void:
217
+ _sequence_captures.append({
218
+ "requested_ms": requested_ms,
219
+ "actual_ms": actual_ms,
220
+ "ok": ok,
221
+ "image_base64": image_base64,
222
+ "width": width,
223
+ "height": height,
224
+ "error": error,
225
+ })
226
+
227
+
181
228
  func type_text(params: Dictionary) -> Dictionary:
182
229
  var text: String = params.get("text", "")
183
230
  var delay_ms: int = int(params.get("delay_ms", 50))
@@ -192,10 +239,14 @@ func type_text(params: Dictionary) -> Dictionary:
192
239
  var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
193
240
  if debugger_plugin == null:
194
241
  return _error("NO_SESSION", "No active debug session")
195
- if not await _await_bridge_ready(debugger_plugin):
196
- return _error("BRIDGE_NOT_READY", _BRIDGE_NOT_READY_MSG)
242
+ # Shared deadline (ready-wait + typing), stamped before the ready-wait so the
243
+ # gap is folded into the budget (#276); server-pushed budget or local fallback.
244
+ var op_start := Time.get_ticks_msec()
245
+ var fallback: float = max(INPUT_TIMEOUT, (text.length() * delay_ms / 1000.0) + 5.0) + READY_TIMEOUT
246
+ var timeout := _pushed_budget(params, fallback)
197
247
 
198
- var timeout := max(INPUT_TIMEOUT, (text.length() * delay_ms / 1000.0) + 5.0)
248
+ if not await _await_bridge_ready(debugger_plugin, op_start, timeout):
249
+ return _error("BRIDGE_NOT_READY", _BRIDGE_NOT_READY_MSG)
199
250
 
200
251
  _type_text_pending = true
201
252
  _type_text_result = {}
@@ -203,10 +254,9 @@ func type_text(params: Dictionary) -> Dictionary:
203
254
  debugger_plugin.type_text_completed.connect(_on_type_text_completed, CONNECT_ONE_SHOT)
204
255
  debugger_plugin.request_type_text(text, delay_ms, submit)
205
256
 
206
- var start_time := Time.get_ticks_msec()
207
257
  while _type_text_pending:
208
258
  await Engine.get_main_loop().process_frame
209
- if (Time.get_ticks_msec() - start_time) / 1000.0 > timeout:
259
+ if (Time.get_ticks_msec() - op_start) / 1000.0 > timeout:
210
260
  _type_text_pending = false
211
261
  if debugger_plugin.type_text_completed.is_connected(_on_type_text_completed):
212
262
  debugger_plugin.type_text_completed.disconnect(_on_type_text_completed)
@@ -3,7 +3,6 @@ extends MCPBaseCommand
3
3
  class_name MCPScreenshotCommands
4
4
 
5
5
  const DEFAULT_MAX_WIDTH := 900
6
- const DEFAULT_JPEG_QUALITY := 75
7
6
  const SCREENSHOT_TIMEOUT := 5.0
8
7
 
9
8
  var _screenshot_result: Dictionary = {}
@@ -22,7 +21,6 @@ func capture_game_screenshot(params: Dictionary) -> Dictionary:
22
21
  return _error("NOT_RUNNING", "No game is currently running. Use run_project first.")
23
22
 
24
23
  var max_width: int = params.get("max_width", DEFAULT_MAX_WIDTH)
25
- var quality: float = params.get("quality", DEFAULT_JPEG_QUALITY) / 100.0
26
24
 
27
25
  var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
28
26
  if debugger_plugin == null:
@@ -35,7 +33,7 @@ func capture_game_screenshot(params: Dictionary) -> Dictionary:
35
33
  _screenshot_result = {}
36
34
 
37
35
  debugger_plugin.screenshot_received.connect(_on_screenshot_received, CONNECT_ONE_SHOT)
38
- debugger_plugin.request_screenshot(max_width, quality)
36
+ debugger_plugin.request_screenshot(max_width)
39
37
 
40
38
  var start_time := Time.get_ticks_msec()
41
39
  while _screenshot_pending:
@@ -64,7 +62,6 @@ func _on_screenshot_received(success: bool, image_base64: String, width: int, he
64
62
  func capture_editor_screenshot(params: Dictionary) -> Dictionary:
65
63
  var viewport_type: String = params.get("viewport", "")
66
64
  var max_width: int = params.get("max_width", DEFAULT_MAX_WIDTH)
67
- var quality: float = params.get("quality", DEFAULT_JPEG_QUALITY) / 100.0
68
65
 
69
66
  var viewport: SubViewport = null
70
67
 
@@ -80,10 +77,12 @@ func capture_editor_screenshot(params: Dictionary) -> Dictionary:
80
77
  return _error("NO_VIEWPORT", "Could not find editor viewport")
81
78
 
82
79
  var image := viewport.get_texture().get_image()
83
- return _process_and_encode_image(image, max_width, quality)
80
+ return _process_and_encode_image(image, max_width)
84
81
 
85
82
 
86
- func _process_and_encode_image(image: Image, max_width: int, quality: float = 0.75) -> Dictionary:
83
+ # Lossless PNG, not JPEG: vision-token cost is set by resolution, not codec, so
84
+ # JPEG only added compression artifacts. max_width bounds the resolution cost.
85
+ func _process_and_encode_image(image: Image, max_width: int) -> Dictionary:
87
86
  if image == null:
88
87
  return _error("CAPTURE_FAILED", "Failed to capture image from viewport")
89
88
 
@@ -92,8 +91,8 @@ func _process_and_encode_image(image: Image, max_width: int, quality: float = 0.
92
91
  var new_height := int(image.get_height() * scale_factor)
93
92
  image.resize(max_width, new_height, Image.INTERPOLATE_LANCZOS)
94
93
 
95
- var jpg_buffer := image.save_jpg_to_buffer(quality)
96
- var base64 := Marshalls.raw_to_base64(jpg_buffer)
94
+ var png_buffer := image.save_png_to_buffer()
95
+ var base64 := Marshalls.raw_to_base64(png_buffer)
97
96
 
98
97
  return _success({
99
98
  "image_base64": base64,
@@ -8,6 +8,7 @@ signal performance_metrics_received(metrics: Dictionary)
8
8
  signal find_nodes_received(matches: Array, count: int, error: String)
9
9
  signal input_map_received(actions: Array, error: String)
10
10
  signal input_sequence_completed(result: Dictionary)
11
+ signal sequence_capture_received(requested_ms: int, actual_ms: int, ok: bool, image_base64: String, width: int, height: int, error: String)
11
12
  signal type_text_completed(result: Dictionary)
12
13
  signal game_response(message_type: String, data: Variant)
13
14
  signal bridge_ready()
@@ -52,6 +53,9 @@ func _capture(message: String, data: Array, session_id: int) -> bool:
52
53
  "godot_mcp:input_sequence_result":
53
54
  _handle_input_sequence_result(data)
54
55
  return true
56
+ "godot_mcp:sequence_capture":
57
+ _handle_sequence_capture(data)
58
+ return true
55
59
  "godot_mcp:type_text_result":
56
60
  _handle_type_text_result(data)
57
61
  return true
@@ -124,14 +128,14 @@ func is_bridge_ready() -> bool:
124
128
  return _bridge_ready and has_active_session()
125
129
 
126
130
 
127
- func request_screenshot(max_width: int = 1024, quality: float = 0.75) -> void:
131
+ func request_screenshot(max_width: int = 1024) -> void:
128
132
  if _active_session_id < 0:
129
133
  screenshot_received.emit(false, "", 0, 0, "No active game session")
130
134
  return
131
135
  _pending_screenshot = true
132
136
  var session := get_session(_active_session_id)
133
137
  if session:
134
- session.send_message("godot_mcp:take_screenshot", [max_width, quality])
138
+ session.send_message("godot_mcp:take_screenshot", [max_width])
135
139
  else:
136
140
  _pending_screenshot = false
137
141
  screenshot_received.emit(false, "", 0, 0, "Could not get debugger session")
@@ -229,14 +233,14 @@ func _handle_input_map_result(data: Array) -> void:
229
233
  input_map_received.emit(actions, error)
230
234
 
231
235
 
232
- func request_input_sequence(inputs: Array, report: Array = []) -> void:
236
+ func request_input_sequence(inputs: Array, report: Array = [], screenshots: Array = [], screenshot_max_width: int = 640) -> void:
233
237
  if _active_session_id < 0:
234
238
  input_sequence_completed.emit({"error": "No active game session"})
235
239
  return
236
240
  _pending_input_sequence = true
237
241
  var session := get_session(_active_session_id)
238
242
  if session:
239
- session.send_message("godot_mcp:execute_input_sequence", [inputs, report])
243
+ session.send_message("godot_mcp:execute_input_sequence", [inputs, report, screenshots, screenshot_max_width])
240
244
  else:
241
245
  _pending_input_sequence = false
242
246
  input_sequence_completed.emit({"error": "Could not get debugger session"})
@@ -248,6 +252,20 @@ func _handle_input_sequence_result(data: Array) -> void:
248
252
  input_sequence_completed.emit(result)
249
253
 
250
254
 
255
+ # A mid-sequence frame capture (#239) arriving on its own message. Re-emitted for
256
+ # the editor command to collect; the final input_sequence_result follows once the
257
+ # bridge has sent every requested frame.
258
+ func _handle_sequence_capture(data: Array) -> void:
259
+ var requested_ms: int = int(data[0]) if data.size() > 0 else 0
260
+ var actual_ms: int = int(data[1]) if data.size() > 1 else 0
261
+ var ok: bool = bool(data[2]) if data.size() > 2 else false
262
+ var base64: String = String(data[3]) if data.size() > 3 else ""
263
+ var width: int = int(data[4]) if data.size() > 4 else 0
264
+ var height: int = int(data[5]) if data.size() > 5 else 0
265
+ var error: String = String(data[6]) if data.size() > 6 else ""
266
+ sequence_capture_received.emit(requested_ms, actual_ms, ok, base64, width, height, error)
267
+
268
+
251
269
  func request_type_text(text: String, delay_ms: int, submit: bool) -> void:
252
270
  if _active_session_id < 0:
253
271
  type_text_completed.emit({"error": "No active game session"})
@@ -2,7 +2,6 @@ extends Node
2
2
  class_name MCPGameBridge
3
3
 
4
4
  const DEFAULT_MAX_WIDTH := 1024
5
- const DEFAULT_JPEG_QUALITY := 0.75
6
5
  const Onscreen := preload("onscreen.gd")
7
6
 
8
7
  # Cap on frames waited for the main scene to appear before announcing ready
@@ -119,8 +118,12 @@ func _sequence_process(delta: float) -> void:
119
118
  if _sequence_draining:
120
119
  if tree and not tree.paused:
121
120
  _sequence_gameplay_ms += delta * 1000.0
122
- _sequence_settle_remaining -= 1
123
- if _sequence_settle_remaining <= 0:
121
+ if _sequence_settle_remaining > 0:
122
+ _sequence_settle_remaining -= 1
123
+ # Finalize only once the settle frames have elapsed (so an effect probe's
124
+ # `after` reflects the final input) AND every deferred frame capture has
125
+ # been sent back.
126
+ if _sequence_settle_remaining <= 0 and _sequence_captures_pending == 0:
124
127
  _emit_sequence_result()
125
128
  return
126
129
 
@@ -145,13 +148,28 @@ func _sequence_process(delta: float) -> void:
145
148
  _held_actions.erase(seq_event.action)
146
149
  _actions_completed += 1
147
150
 
148
- if _sequence_events.is_empty():
149
- if _sequence_report.is_empty():
151
+ # Trigger any frame captures whose offset has arrived (#239). Capture is
152
+ # deferred to frame_post_draw, so it completes a frame or two later; the
153
+ # pending count keeps the result from being sent until every frame is in.
154
+ while _sequence_capture_offsets.size() > 0 and int(_sequence_capture_offsets[0]) <= elapsed:
155
+ var off: int = int(_sequence_capture_offsets.pop_front())
156
+ _sequence_captures_pending += 1
157
+ _capture_sequence_frame.call_deferred(off)
158
+
159
+ # Done when both the input timeline and the capture schedule are exhausted;
160
+ # captures scheduled past the last input keep the window open until their
161
+ # offsets arrive.
162
+ if _sequence_events.is_empty() and _sequence_capture_offsets.is_empty():
163
+ if not _sequence_report.is_empty():
164
+ # Defer so the effect probe's `after` reflects the final input.
165
+ _sequence_draining = true
166
+ _sequence_settle_remaining = SEQUENCE_SETTLE_FRAMES
167
+ elif _sequence_captures_pending == 0:
150
168
  _emit_sequence_result()
151
169
  else:
152
- # Defer the result so the effect probe's `after` reflects the final input.
170
+ # No probe, but captures are still resolving wait for them.
153
171
  _sequence_draining = true
154
- _sequence_settle_remaining = SEQUENCE_SETTLE_FRAMES
172
+ _sequence_settle_remaining = 0
155
173
 
156
174
 
157
175
  # Assemble and send the input-sequence result, then reset probe state. Carries an
@@ -203,6 +221,42 @@ func _compute_report_deltas(before: Dictionary, after: Dictionary) -> Dictionary
203
221
  return {"report": deltas, "any_changed": any_changed}
204
222
 
205
223
 
224
+ # Capture one frame mid-sequence (#239) and stream it back on its own message.
225
+ # Deferred from _sequence_process to frame_post_draw so it reads the rendered
226
+ # frame nearest the requested offset; the actual elapsed offset is reported
227
+ # alongside so the agent knows exactly when each frame landed. Each capture rides
228
+ # its own message, and the count gates the result.
229
+ #
230
+ # Encoded as lossless PNG, deliberately not JPEG: vision-token cost is a function
231
+ # of resolution (≈ width*height/750), not of file size or codec, so JPEG would
232
+ # only add compression artifacts for zero token saving. The token lever is
233
+ # _sequence_capture_max_width (resolution); PNG just costs more transport bytes.
234
+ func _capture_sequence_frame(requested_offset_ms: int) -> void:
235
+ await RenderingServer.frame_post_draw
236
+ var actual_ms := Time.get_ticks_msec() - _sequence_start_time
237
+ var viewport := get_viewport()
238
+ if viewport == null:
239
+ _send_sequence_capture(requested_offset_ms, actual_ms, false, "", 0, 0, "NO_VIEWPORT: could not get game viewport")
240
+ return
241
+ var image := viewport.get_texture().get_image()
242
+ if image == null:
243
+ _send_sequence_capture(requested_offset_ms, actual_ms, false, "", 0, 0, "CAPTURE_FAILED: could not read viewport image")
244
+ return
245
+ if _sequence_capture_max_width > 0 and image.get_width() > _sequence_capture_max_width:
246
+ var scale_factor := float(_sequence_capture_max_width) / float(image.get_width())
247
+ image.resize(_sequence_capture_max_width, int(image.get_height() * scale_factor), Image.INTERPOLATE_LANCZOS)
248
+ var png_buffer := image.save_png_to_buffer()
249
+ var base64 := Marshalls.raw_to_base64(png_buffer)
250
+ _send_sequence_capture(requested_offset_ms, actual_ms, true, base64, image.get_width(), image.get_height(), "")
251
+
252
+
253
+ func _send_sequence_capture(requested_ms: int, actual_ms: int, ok: bool, base64: String, width: int, height: int, error: String) -> void:
254
+ # Decrement first: the result is gated on this reaching zero, and a capture
255
+ # that errors must still release its slot or the sequence would never finish.
256
+ _sequence_captures_pending = maxi(0, _sequence_captures_pending - 1)
257
+ EngineDebugger.send_message("godot_mcp:sequence_capture", [requested_ms, actual_ms, ok, base64, width, height, error])
258
+
259
+
206
260
  var _sequence_events: Array = []
207
261
  var _sequence_start_time: int = 0
208
262
  var _sequence_running: bool = false
@@ -222,6 +276,20 @@ const SEQUENCE_SETTLE_FRAMES := 2
222
276
  var _sequence_report: Array = []
223
277
  var _sequence_report_inputs: Array = []
224
278
  var _sequence_report_before: Dictionary = {}
279
+ # Mid-sequence frame capture (#239): offsets (ms from start, sorted) still to be
280
+ # captured during the real-time run, the capture params, and the count of
281
+ # deferred captures not yet sent — the result is held until this reaches zero.
282
+ var _sequence_capture_offsets: Array = []
283
+ var _sequence_captures_pending: int = 0
284
+ var _sequence_capture_max_width: int = 640
285
+ const SEQUENCE_MAX_CAPTURES := 8
286
+ # Non-binding sanity backstop only (#276). The server derives the per-call
287
+ # timeout from the sequence span and rejects offsets beyond what the ceiling
288
+ # permits before they ever reach here, so this just guards a malformed direct
289
+ # message. Kept far above any server-permitted budget so it never silently
290
+ # clamps a legitimate offset (which would reintroduce the cross-layer drift
291
+ # that #276 removed).
292
+ const SEQUENCE_MAX_CAPTURE_OFFSET_MS := 300000
225
293
  # Actions whose press has been injected but whose paired release has not yet
226
294
  # fired. Used to guarantee a release even if the queue is cleared mid-flight
227
295
  # (new sequence) or the node leaves the tree — otherwise the dropped release
@@ -311,12 +379,14 @@ func _on_debugger_message(message: String, data: Array) -> bool:
311
379
 
312
380
  func _take_screenshot_deferred(data: Array) -> void:
313
381
  var max_width: int = data[0] if data.size() > 0 else DEFAULT_MAX_WIDTH
314
- var quality: float = data[1] if data.size() > 1 else DEFAULT_JPEG_QUALITY
315
382
  await RenderingServer.frame_post_draw
316
- _capture_and_send_screenshot(max_width, quality)
383
+ _capture_and_send_screenshot(max_width)
317
384
 
318
385
 
319
- func _capture_and_send_screenshot(max_width: int, quality: float = DEFAULT_JPEG_QUALITY) -> void:
386
+ # Lossless PNG, not JPEG: image vision-token cost scales with resolution, not
387
+ # codec, so JPEG only traded fidelity (compression artifacts) for nothing. Width
388
+ # is downscaled to max_width to bound that resolution-driven cost.
389
+ func _capture_and_send_screenshot(max_width: int) -> void:
320
390
  var viewport := get_viewport()
321
391
  if viewport == null:
322
392
  _send_screenshot_error("NO_VIEWPORT", "Could not get game viewport")
@@ -329,8 +399,8 @@ func _capture_and_send_screenshot(max_width: int, quality: float = DEFAULT_JPEG_
329
399
  var scale_factor := float(max_width) / float(image.get_width())
330
400
  var new_height := int(image.get_height() * scale_factor)
331
401
  image.resize(max_width, new_height, Image.INTERPOLATE_LANCZOS)
332
- var jpg_buffer := image.save_jpg_to_buffer(quality)
333
- var base64 := Marshalls.raw_to_base64(jpg_buffer)
402
+ var png_buffer := image.save_png_to_buffer()
403
+ var base64 := Marshalls.raw_to_base64(png_buffer)
334
404
  EngineDebugger.send_message("godot_mcp:screenshot_result", [
335
405
  true,
336
406
  base64,
@@ -1084,6 +1154,8 @@ func _event_to_string(event: InputEvent) -> String:
1084
1154
  func _handle_execute_input_sequence(data: Array) -> void:
1085
1155
  var inputs: Array = data[0] if data.size() > 0 else []
1086
1156
  var report: Array = data[1] if data.size() > 1 and data[1] is Array else []
1157
+ var screenshot_offsets: Array = data[2] if data.size() > 2 and data[2] is Array else []
1158
+ var cap_max_width: int = int(data[3]) if data.size() > 3 else 640
1087
1159
 
1088
1160
  if inputs.is_empty():
1089
1161
  EngineDebugger.send_message("godot_mcp:input_sequence_result", [{
@@ -1091,6 +1163,15 @@ func _handle_execute_input_sequence(data: Array) -> void:
1091
1163
  }])
1092
1164
  return
1093
1165
 
1166
+ # Normalize the optional frame-capture schedule (#239): clamp each offset,
1167
+ # cap the count, and sort so _sequence_process can pop them in order.
1168
+ var capture_offsets: Array = []
1169
+ for o in screenshot_offsets:
1170
+ if capture_offsets.size() >= SEQUENCE_MAX_CAPTURES:
1171
+ break
1172
+ capture_offsets.append(clampi(int(o), 0, SEQUENCE_MAX_CAPTURE_OFFSET_MS))
1173
+ capture_offsets.sort()
1174
+
1094
1175
  # Compile the optional effect probe up front, before touching any input state,
1095
1176
  # so a bad expression rejects the call cleanly (same contract as step_until's
1096
1177
  # report). Reuses the predicate context: autoloads by name, plus `tree`/`root`.
@@ -1117,12 +1198,15 @@ func _handle_execute_input_sequence(data: Array) -> void:
1117
1198
  _sequence_gameplay_ms = 0.0
1118
1199
  _sequence_draining = false
1119
1200
  _sequence_settle_remaining = 0
1120
- # Clear probe state up front so an early return below (unknown action) cannot
1121
- # leave a stale report to be drained against an interrupted window. It is
1122
- # re-armed from report_compiled once the timeline is validated.
1201
+ # Clear probe and capture state up front so an early return below (unknown
1202
+ # action) cannot leave a stale report or capture schedule to be acted on
1203
+ # against an interrupted window. Both are re-armed once the timeline validates.
1123
1204
  _sequence_report = []
1124
1205
  _sequence_report_inputs = []
1125
1206
  _sequence_report_before = {}
1207
+ _sequence_capture_offsets = []
1208
+ _sequence_captures_pending = 0
1209
+ _sequence_capture_max_width = cap_max_width
1126
1210
 
1127
1211
  for input in inputs:
1128
1212
  var action_name: String = input.get("action_name", "")
@@ -1158,6 +1242,9 @@ func _handle_execute_input_sequence(data: Array) -> void:
1158
1242
  _sequence_report_inputs = report_inputs
1159
1243
  _sequence_report_before = _evaluate_report(report_compiled, report_inputs) if not report_compiled.is_empty() else {}
1160
1244
 
1245
+ # Arm the capture schedule (validated and sorted above).
1246
+ _sequence_capture_offsets = capture_offsets
1247
+
1161
1248
  _sequence_start_time = Time.get_ticks_msec()
1162
1249
  _sequence_running = true
1163
1250
  _update_processing()
@@ -1247,10 +1334,15 @@ func _type_text_async(text: String, delay_ms: int, submit: bool) -> void:
1247
1334
  # ---------------------------------------------------------------------------
1248
1335
 
1249
1336
  const LAUNCH_FROZEN_ENV := "GODOT_MCP_LAUNCH_FROZEN"
1250
- # Timeout cascade: step request <= 20s game time, wall budget 25s, editor
1251
- # relay 28s, server command timeout 30s. Each layer answers before the one
1252
- # above it gives up.
1253
- const STEP_MAX_MS := 20000
1337
+ # Timeout cascade (#276): the server derives the whole stagger from the call's
1338
+ # in-game budget and pushes wall_budget_ms down here. The bridge returns by that
1339
+ # wall budget, the editor relay waits a margin longer, the server socket a
1340
+ # margin longer still — each answers before the one above gives up.
1341
+ # STEP_MAX_MS non-binding sanity backstop (the server already clamps the request)
1342
+ # STEP_DEFAULT_MS budget used when a call omits max_ms (older server that sends no default)
1343
+ # STEP_WALL_BUDGET_MS wall-budget fallback when the server pushes no wall_budget_ms
1344
+ const STEP_MAX_MS := 300000
1345
+ const STEP_DEFAULT_MS := 20000
1254
1346
  const STEP_MAX_FRAMES := 1200
1255
1347
  const STEP_WALL_BUDGET_MS := 25000
1256
1348
  const STEP_MAX_TRANSITIONS := 50
@@ -1273,6 +1365,7 @@ var _step_gameplay_ms := 0.0 # the unpaused portion: what gameplay actually exp
1273
1365
  var _step_frames := 0
1274
1366
  var _step_physics_ticks := 0
1275
1367
  var _step_wall_start := 0
1368
+ var _step_wall_budget_ms := STEP_WALL_BUDGET_MS # set per-call from the server-pushed wall_budget_ms (#276)
1276
1369
  var _step_events: Array = [] # in-step input timeline, scheduled on the game-time clock
1277
1370
  var _step_events_fired := 0
1278
1371
  var _step_transitions: Array = []
@@ -1430,6 +1523,7 @@ func _handle_game_time_step(data: Array) -> void:
1430
1523
  _step_finish_pending = false
1431
1524
  _step_wall_exceeded = false
1432
1525
  _step_wall_start = Time.get_ticks_msec()
1526
+ _step_wall_budget_ms = int(params.get("wall_budget_ms", STEP_WALL_BUDGET_MS))
1433
1527
  _step_predicate = null
1434
1528
  _step_response_type = "game_time_step"
1435
1529
  _step_active = true
@@ -1546,9 +1640,9 @@ func _handle_game_time_step_until(data: Array) -> void:
1546
1640
  _send_game_time_response("game_time_step_until", {"error": "step_until requires a non-empty `until` expression"})
1547
1641
  return
1548
1642
 
1549
- var max_ms: int = int(params.get("max_ms", STEP_MAX_MS))
1643
+ var max_ms: int = int(params.get("max_ms", STEP_DEFAULT_MS))
1550
1644
  if max_ms <= 0:
1551
- max_ms = STEP_MAX_MS
1645
+ max_ms = STEP_DEFAULT_MS
1552
1646
  max_ms = mini(max_ms, STEP_MAX_MS)
1553
1647
 
1554
1648
  # Compile and validate the predicate against the live tree before committing
@@ -1611,6 +1705,7 @@ func _handle_game_time_step_until(data: Array) -> void:
1611
1705
  _step_finish_pending = false
1612
1706
  _step_wall_exceeded = false
1613
1707
  _step_wall_start = Time.get_ticks_msec()
1708
+ _step_wall_budget_ms = int(params.get("wall_budget_ms", STEP_WALL_BUDGET_MS))
1614
1709
  _step_predicate = expr
1615
1710
  _step_predicate_inputs = ctx_inputs
1616
1711
  _step_predicate_met = false
@@ -1682,7 +1777,7 @@ func _step_process(delta: float) -> void:
1682
1777
  _step_predicate_met = true
1683
1778
  done = true
1684
1779
 
1685
- if Time.get_ticks_msec() - _step_wall_start > STEP_WALL_BUDGET_MS:
1780
+ if Time.get_ticks_msec() - _step_wall_start > _step_wall_budget_ms:
1686
1781
  # Slow-mo, Engine.time_scale = 0, or a pause-held window can starve
1687
1782
  # the game-time clock; the wall budget guarantees the call returns
1688
1783
  # (partial, honestly reported) before the editor relay gives up.
package/addon/plugin.cfg CHANGED
@@ -3,6 +3,6 @@
3
3
  name="Godot MCP"
4
4
  description="Model Context Protocol server for AI assistant integration"
5
5
  author="godot-mcp"
6
- version="3.11.0"
6
+ version="3.13.0"
7
7
  script="plugin.gd"
8
8
  godot_version_min="4.5"
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=timeouts.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"timeouts.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/connection/timeouts.test.ts"],"names":[],"mappings":""}