@satelliteoflove/godot-mcp 3.12.0 → 3.14.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 (53) hide show
  1. package/addon/commands/debug_commands.gd +9 -0
  2. package/addon/commands/game_time_commands.gd +14 -6
  3. package/addon/commands/input_commands.gd +48 -22
  4. package/addon/commands/project_commands.gd +9 -1
  5. package/addon/core/mcp_utils.gd +171 -0
  6. package/addon/game_bridge/mcp_game_bridge.gd +22 -12
  7. package/addon/plugin.cfg +1 -1
  8. package/dist/__tests__/connection/timeouts.test.d.ts +2 -0
  9. package/dist/__tests__/connection/timeouts.test.d.ts.map +1 -0
  10. package/dist/__tests__/connection/timeouts.test.js +58 -0
  11. package/dist/__tests__/connection/timeouts.test.js.map +1 -0
  12. package/dist/__tests__/connection/websocket.test.js +58 -0
  13. package/dist/__tests__/connection/websocket.test.js.map +1 -1
  14. package/dist/__tests__/helpers/mock-godot.d.ts +3 -0
  15. package/dist/__tests__/helpers/mock-godot.d.ts.map +1 -1
  16. package/dist/__tests__/helpers/mock-godot.js +2 -2
  17. package/dist/__tests__/helpers/mock-godot.js.map +1 -1
  18. package/dist/__tests__/tools/editor.test.js +36 -0
  19. package/dist/__tests__/tools/editor.test.js.map +1 -1
  20. package/dist/__tests__/tools/game-time.test.js +45 -5
  21. package/dist/__tests__/tools/game-time.test.js.map +1 -1
  22. package/dist/__tests__/tools/input.test.js +75 -0
  23. package/dist/__tests__/tools/input.test.js.map +1 -1
  24. package/dist/__tests__/tools/project.test.d.ts +2 -0
  25. package/dist/__tests__/tools/project.test.d.ts.map +1 -0
  26. package/dist/__tests__/tools/project.test.js +53 -0
  27. package/dist/__tests__/tools/project.test.js.map +1 -0
  28. package/dist/connection/timeouts.d.ts +47 -0
  29. package/dist/connection/timeouts.d.ts.map +1 -0
  30. package/dist/connection/timeouts.js +72 -0
  31. package/dist/connection/timeouts.js.map +1 -0
  32. package/dist/connection/websocket.d.ts +3 -1
  33. package/dist/connection/websocket.d.ts.map +1 -1
  34. package/dist/connection/websocket.js +8 -4
  35. package/dist/connection/websocket.js.map +1 -1
  36. package/dist/tools/editor.d.ts.map +1 -1
  37. package/dist/tools/editor.js +8 -2
  38. package/dist/tools/editor.js.map +1 -1
  39. package/dist/tools/game-time.d.ts.map +1 -1
  40. package/dist/tools/game-time.js +33 -8
  41. package/dist/tools/game-time.js.map +1 -1
  42. package/dist/tools/input.d.ts.map +1 -1
  43. package/dist/tools/input.js +37 -4
  44. package/dist/tools/input.js.map +1 -1
  45. package/dist/tools/project-staleness.d.ts +15 -0
  46. package/dist/tools/project-staleness.d.ts.map +1 -0
  47. package/dist/tools/project-staleness.js +16 -0
  48. package/dist/tools/project-staleness.js.map +1 -0
  49. package/dist/tools/project.d.ts +2 -0
  50. package/dist/tools/project.d.ts.map +1 -1
  51. package/dist/tools/project.js +14 -0
  52. package/dist/tools/project.js.map +1 -1
  53. package/package.json +1 -1
@@ -125,6 +125,15 @@ func get_log_messages(params: Dictionary) -> Dictionary:
125
125
  if clear:
126
126
  MCPLogger.clear_errors()
127
127
 
128
+ # The phantom "Identifier not found: <autoload>" errors that mislead agents
129
+ # come from the editor running stale after project.godot was edited on disk
130
+ # (#245). When that divergence is present, attach it here so the caller reads
131
+ # the log and the "your editor is stale, restart it" advisory in one shot,
132
+ # instead of chasing compile errors that do not exist at runtime.
133
+ var staleness := MCPUtils.detect_project_staleness()
134
+ if staleness.get("stale", false):
135
+ result["staleness"] = staleness
136
+
128
137
  return _success(result)
129
138
 
130
139
 
@@ -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:
@@ -34,19 +34,30 @@ func get_commands() -> Dictionary:
34
34
  }
35
35
 
36
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
+
37
46
  # Block until the running game's bridge reports it can consume input, bounded by
38
- # READY_TIMEOUT. Returns true once ready, false if the game stops or never comes
39
- # up in time. In the common case (game already running) this returns immediately
40
- # without waiting a frame. Gating input on this is the fix for #241: the debug
41
- # session connects before the main scene loads, so input dispatched on
42
- # has_active_session() alone lands in a game with nothing to receive it.
43
- func _await_bridge_ready(debugger_plugin) -> bool:
44
- 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:
45
55
  while not debugger_plugin.is_bridge_ready():
46
56
  if not EditorInterface.is_playing_scene():
47
57
  return false # game stopped or crashed while we waited
48
58
  await Engine.get_main_loop().process_frame
49
- 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:
50
61
  return false
51
62
  return true
52
63
 
@@ -90,7 +101,16 @@ func _get_editor_input_map() -> Dictionary:
90
101
  "name": action_name,
91
102
  "events": event_strings,
92
103
  })
93
- return _success({"actions": actions, "source": "editor"})
104
+ # This map is read from the editor's in-memory InputMap, which is loaded at
105
+ # startup and goes stale if project.godot's [input] section is edited on disk
106
+ # (#245). Flag that so the caller knows the map may be incomplete and can
107
+ # recover with `godot_editor restart`. The game-running path above reads fresh
108
+ # from the bridge, so it never carries this.
109
+ var result := {"actions": actions, "source": "editor"}
110
+ var staleness := MCPUtils.detect_project_staleness()
111
+ if staleness.get("stale", false):
112
+ result["staleness"] = staleness
113
+ return _success(result)
94
114
 
95
115
 
96
116
  func _event_to_string(event: InputEvent) -> String:
@@ -146,11 +166,12 @@ func execute_input_sequence(params: Dictionary) -> Dictionary:
146
166
  var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
147
167
  if debugger_plugin == null:
148
168
  return _error("NO_SESSION", "No active debug session")
149
- if not await _await_bridge_ready(debugger_plugin):
150
- return _error("BRIDGE_NOT_READY", _BRIDGE_NOT_READY_MSG)
151
-
152
- # The window can extend past the last input if a frame capture is requested
153
- # at a later offset, so factor capture offsets into the timeout too.
169
+ # One deadline for the whole call, stamped BEFORE the ready-wait so the
170
+ # bridge-ready gap is folded into the budget instead of stacking on top of
171
+ # it (#276). Prefer the server-pushed budget; the fallback (older server) is
172
+ # the longest input/capture offset plus headroom, floored at INPUT_TIMEOUT,
173
+ # plus the ready-wait that now counts against the same deadline.
174
+ var op_start := Time.get_ticks_msec()
154
175
  var max_end_time: float = 0.0
155
176
  for input in inputs:
156
177
  var start_ms: float = input.get("start_ms", 0.0)
@@ -158,8 +179,11 @@ func execute_input_sequence(params: Dictionary) -> Dictionary:
158
179
  max_end_time = max(max_end_time, start_ms + duration_ms)
159
180
  for shot_ms in screenshots:
160
181
  max_end_time = max(max_end_time, float(shot_ms))
182
+ var fallback: float = max(INPUT_TIMEOUT, (max_end_time / 1000.0) + 5.0) + READY_TIMEOUT
183
+ var timeout := _pushed_budget(params, fallback)
161
184
 
162
- var timeout := max(INPUT_TIMEOUT, (max_end_time / 1000.0) + 5.0)
185
+ if not await _await_bridge_ready(debugger_plugin, op_start, timeout):
186
+ return _error("BRIDGE_NOT_READY", _BRIDGE_NOT_READY_MSG)
163
187
 
164
188
  _sequence_pending = true
165
189
  _sequence_result = {}
@@ -171,10 +195,9 @@ func execute_input_sequence(params: Dictionary) -> Dictionary:
171
195
  debugger_plugin.input_sequence_completed.connect(_on_sequence_completed, CONNECT_ONE_SHOT)
172
196
  debugger_plugin.request_input_sequence(inputs, report, screenshots, screenshot_max_width)
173
197
 
174
- var start_time := Time.get_ticks_msec()
175
198
  while _sequence_pending:
176
199
  await Engine.get_main_loop().process_frame
177
- if (Time.get_ticks_msec() - start_time) / 1000.0 > timeout:
200
+ if (Time.get_ticks_msec() - op_start) / 1000.0 > timeout:
178
201
  _sequence_pending = false
179
202
  if debugger_plugin.input_sequence_completed.is_connected(_on_sequence_completed):
180
203
  debugger_plugin.input_sequence_completed.disconnect(_on_sequence_completed)
@@ -225,10 +248,14 @@ func type_text(params: Dictionary) -> Dictionary:
225
248
  var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
226
249
  if debugger_plugin == null:
227
250
  return _error("NO_SESSION", "No active debug session")
228
- if not await _await_bridge_ready(debugger_plugin):
229
- return _error("BRIDGE_NOT_READY", _BRIDGE_NOT_READY_MSG)
251
+ # Shared deadline (ready-wait + typing), stamped before the ready-wait so the
252
+ # gap is folded into the budget (#276); server-pushed budget or local fallback.
253
+ var op_start := Time.get_ticks_msec()
254
+ var fallback: float = max(INPUT_TIMEOUT, (text.length() * delay_ms / 1000.0) + 5.0) + READY_TIMEOUT
255
+ var timeout := _pushed_budget(params, fallback)
230
256
 
231
- var timeout := max(INPUT_TIMEOUT, (text.length() * delay_ms / 1000.0) + 5.0)
257
+ if not await _await_bridge_ready(debugger_plugin, op_start, timeout):
258
+ return _error("BRIDGE_NOT_READY", _BRIDGE_NOT_READY_MSG)
232
259
 
233
260
  _type_text_pending = true
234
261
  _type_text_result = {}
@@ -236,10 +263,9 @@ func type_text(params: Dictionary) -> Dictionary:
236
263
  debugger_plugin.type_text_completed.connect(_on_type_text_completed, CONNECT_ONE_SHOT)
237
264
  debugger_plugin.request_type_text(text, delay_ms, submit)
238
265
 
239
- var start_time := Time.get_ticks_msec()
240
266
  while _type_text_pending:
241
267
  await Engine.get_main_loop().process_frame
242
- if (Time.get_ticks_msec() - start_time) / 1000.0 > timeout:
268
+ if (Time.get_ticks_msec() - op_start) / 1000.0 > timeout:
243
269
  _type_text_pending = false
244
270
  if debugger_plugin.type_text_completed.is_connected(_on_type_text_completed):
245
271
  debugger_plugin.type_text_completed.disconnect(_on_type_text_completed)
@@ -6,10 +6,18 @@ class_name MCPProjectCommands
6
6
  func get_commands() -> Dictionary:
7
7
  return {
8
8
  "get_project_info": get_project_info,
9
- "get_project_settings": get_project_settings
9
+ "get_project_settings": get_project_settings,
10
+ "get_project_staleness": get_project_staleness,
10
11
  }
11
12
 
12
13
 
14
+ # Detect whether project.godot was edited on disk after the editor loaded it,
15
+ # leaving the editor's in-memory ProjectSettings / InputMap stale (#245). Always
16
+ # returns the full report (stale or not); recovery is `godot_editor restart`.
17
+ func get_project_staleness(_params: Dictionary) -> Dictionary:
18
+ return _success(MCPUtils.detect_project_staleness())
19
+
20
+
13
21
  func get_project_info(_params: Dictionary) -> Dictionary:
14
22
  return _success({
15
23
  "name": ProjectSettings.get_setting("application/config/name", "Unknown"),
@@ -127,3 +127,174 @@ static func ensure_dir_exists(path: String) -> Error:
127
127
  return ERR_CANT_OPEN
128
128
  return dir.make_dir_recursive(path.trim_prefix("res://"))
129
129
  return DirAccess.make_dir_recursive_absolute(path)
130
+
131
+
132
+ # ── project.godot staleness (#245) ────────────────────────────────────────────
133
+ # The editor caches ProjectSettings / InputMap in memory at load. An agent that
134
+ # edits project.godot as a file (batch-writing autoloads / input map) leaves the
135
+ # editor stale: its log fills with phantom "Identifier not found: <autoload>"
136
+ # errors that do not exist at runtime, and its input map is out of date — while
137
+ # spawned games (which read disk fresh at launch) work fine. We detect that by
138
+ # content-diffing the two sections that actually cause the trap, [autoload] and
139
+ # [input], disk vs the editor's in-memory state. Recovery is `godot_editor
140
+ # restart` (#250). A content diff (not an mtime check) is used deliberately: it
141
+ # never false-positives when the editor itself saves project.godot (e.g. the
142
+ # plugin's own startup autoload write), because disk is then written FROM memory.
143
+
144
+
145
+ # Pure: diff already-read disk vs in-memory sets. No engine access, so this is
146
+ # unit-testable headless. Autoloads are symmetric (added/removed/value-changed,
147
+ # raw-string compare incl. the "*" singleton prefix). Input is additive-only
148
+ # (actions present on disk but not loaded — the trap); the reverse direction and
149
+ # event values are intentionally ignored (built-in ui_* noise / fragile compares).
150
+ static func diff_project_staleness(
151
+ disk_autoloads: Dictionary, mem_autoloads: Dictionary,
152
+ disk_input_keys: Array, mem_input_keys: Array) -> Dictionary:
153
+ var autoload_added := []
154
+ var autoload_removed := []
155
+ var autoload_changed := []
156
+ for key in disk_autoloads:
157
+ if not mem_autoloads.has(key):
158
+ autoload_added.append(key)
159
+ elif str(disk_autoloads[key]) != str(mem_autoloads[key]):
160
+ autoload_changed.append(key)
161
+ for key in mem_autoloads:
162
+ if not disk_autoloads.has(key):
163
+ autoload_removed.append(key)
164
+
165
+ var mem_input_set := {}
166
+ for k in mem_input_keys:
167
+ mem_input_set[k] = true
168
+ var input_added := []
169
+ for k in disk_input_keys:
170
+ if not mem_input_set.has(k):
171
+ input_added.append(k)
172
+
173
+ autoload_added.sort()
174
+ autoload_removed.sort()
175
+ autoload_changed.sort()
176
+ input_added.sort()
177
+
178
+ var stale: bool = not (autoload_added.is_empty() and autoload_removed.is_empty()
179
+ and autoload_changed.is_empty() and input_added.is_empty())
180
+
181
+ return {
182
+ "stale": stale,
183
+ "autoload": {
184
+ "added": autoload_added,
185
+ "removed": autoload_removed,
186
+ "changed": autoload_changed,
187
+ },
188
+ "input": {"added": input_added},
189
+ "summary": _staleness_summary(autoload_added, autoload_removed, autoload_changed, input_added),
190
+ }
191
+
192
+
193
+ static func _staleness_summary(a_added: Array, a_removed: Array, a_changed: Array, i_added: Array) -> String:
194
+ var parts := []
195
+ if not a_added.is_empty():
196
+ parts.append("%d autoload(s) added on disk (%s)" % [a_added.size(), ", ".join(a_added)])
197
+ if not a_removed.is_empty():
198
+ parts.append("%d autoload(s) removed on disk (%s)" % [a_removed.size(), ", ".join(a_removed)])
199
+ if not a_changed.is_empty():
200
+ parts.append("%d autoload(s) changed on disk (%s)" % [a_changed.size(), ", ".join(a_changed)])
201
+ if not i_added.is_empty():
202
+ parts.append("%d input action(s) added on disk (%s)" % [i_added.size(), ", ".join(i_added)])
203
+ if parts.is_empty():
204
+ return "project.godot on disk matches the editor's loaded settings."
205
+ return ("project.godot was edited on disk after the editor loaded it: %s. The editor's in-memory "
206
+ + "settings are stale (its log may show phantom \"Identifier not found\" errors that do not "
207
+ + "exist at runtime). Run `godot_editor restart` to reload project.godot (save:false to discard "
208
+ + "unsaved editor changes).") % "; ".join(parts)
209
+
210
+
211
+ # Editor-context orchestrator. Reads project.godot from disk (a plain text scan,
212
+ # NOT ConfigFile — which would eagerly instantiate the [input] InputEvent
213
+ # sub-objects) and the in-memory ProjectSettings, then diffs. Never throws: any
214
+ # read failure returns {stale=false, note=...} so a transient I/O hiccup can
215
+ # never produce a false "stale" (recovery is disruptive — silence beats crying
216
+ # wolf). The {stale, autoload, input, summary} shape mirrors diff_project_staleness.
217
+ static func detect_project_staleness() -> Dictionary:
218
+ var disk := _read_disk_project_sections()
219
+ if disk.is_empty():
220
+ return {"stale": false, "note": "Could not read res://project.godot to check staleness."}
221
+
222
+ var mem := _read_mem_sections()
223
+ var mem_autoloads: Dictionary = mem["autoload"]
224
+ var disk_autoloads: Dictionary = disk.get("autoload", {})
225
+ # A project with autoloads always writes the [autoload] section (the addon
226
+ # writes its own bridge autoload at startup). If the section is absent, treat
227
+ # it as "nothing to compare" rather than "everything removed".
228
+ if not disk.get("has_autoload_section", false):
229
+ disk_autoloads = mem_autoloads.duplicate()
230
+
231
+ return diff_project_staleness(
232
+ disk_autoloads, mem_autoloads,
233
+ disk.get("input_keys", []), mem["input_keys"])
234
+
235
+
236
+ # Scan project.godot once, section-aware. Returns
237
+ # { autoload: {Name: "value"}, input_keys: [action,...], has_autoload_section }
238
+ # or {} if the file can't be read. Only [autoload] (name + unquoted value) and
239
+ # [input] (action NAMES only — the InputEvent dicts are never parsed) are read.
240
+ static func _read_disk_project_sections() -> Dictionary:
241
+ if not FileAccess.file_exists("res://project.godot"):
242
+ MCPLog.warn("project.godot not found; skipping staleness check")
243
+ return {}
244
+ var text := FileAccess.get_file_as_string("res://project.godot")
245
+ if text.is_empty():
246
+ MCPLog.warn("Could not read project.godot for staleness check")
247
+ return {}
248
+
249
+ var autoloads := {}
250
+ var input_keys := []
251
+ var has_autoload := false
252
+ var section := ""
253
+ # A top-level key line: identifier `=` value, at column 0. Dict-body lines of
254
+ # an [input] action start with `"`/`}`/`]` (or are indented), so they never match.
255
+ var key_re := RegEx.new()
256
+ key_re.compile("^([A-Za-z_][A-Za-z0-9_]*)=(.*)$")
257
+
258
+ for raw in text.split("\n"):
259
+ var line := raw.strip_edges(false, true) # trailing only (drops CR / spaces)
260
+ if line.begins_with("[") and line.ends_with("]"):
261
+ section = line.substr(1, line.length() - 2)
262
+ if section == "autoload":
263
+ has_autoload = true
264
+ continue
265
+ if section != "autoload" and section != "input":
266
+ continue
267
+ var m := key_re.search(line)
268
+ if m == null:
269
+ continue
270
+ var key := m.get_string(1)
271
+ if section == "autoload":
272
+ autoloads[key] = _unquote(m.get_string(2))
273
+ elif not key.begins_with("ui_"):
274
+ input_keys.append(key)
275
+
276
+ return {"autoload": autoloads, "input_keys": input_keys, "has_autoload_section": has_autoload}
277
+
278
+
279
+ # Single pass over ProjectSettings (it carries hundreds of entries) collecting
280
+ # both the in-memory autoloads ({Name: value}) and the non-builtin input action
281
+ # names. Returns { autoload: Dictionary, input_keys: Array }.
282
+ static func _read_mem_sections() -> Dictionary:
283
+ var autoloads := {}
284
+ var input_keys := []
285
+ for prop in ProjectSettings.get_property_list():
286
+ var pname: String = prop["name"]
287
+ if pname.begins_with("autoload/"):
288
+ autoloads[pname.substr(9)] = str(ProjectSettings.get_setting(pname, ""))
289
+ elif pname.begins_with("input/"):
290
+ var action := pname.substr(6)
291
+ if not action.begins_with("ui_"):
292
+ input_keys.append(action)
293
+ return {"autoload": autoloads, "input_keys": input_keys}
294
+
295
+
296
+ static func _unquote(s: String) -> String:
297
+ var t := s.strip_edges()
298
+ if t.length() >= 2 and t.begins_with("\"") and t.ends_with("\""):
299
+ return t.substr(1, t.length() - 2)
300
+ return t
@@ -283,11 +283,13 @@ var _sequence_capture_offsets: Array = []
283
283
  var _sequence_captures_pending: int = 0
284
284
  var _sequence_capture_max_width: int = 640
285
285
  const SEQUENCE_MAX_CAPTURES := 8
286
- # Capped well under the 30s server command timeout: the sequence runs until its
287
- # last capture offset, so a larger value would let the whole call time out
288
- # server-side. 20s is already far longer than any transient-visual capture needs.
289
- # This bound is a placeholder for the global-timeout fix tracked in godot-mcp#276.
290
- const SEQUENCE_MAX_CAPTURE_OFFSET_MS := 20000
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
291
293
  # Actions whose press has been injected but whose paired release has not yet
292
294
  # fired. Used to guarantee a release even if the queue is cleared mid-flight
293
295
  # (new sequence) or the node leaves the tree — otherwise the dropped release
@@ -1332,10 +1334,15 @@ func _type_text_async(text: String, delay_ms: int, submit: bool) -> void:
1332
1334
  # ---------------------------------------------------------------------------
1333
1335
 
1334
1336
  const LAUNCH_FROZEN_ENV := "GODOT_MCP_LAUNCH_FROZEN"
1335
- # Timeout cascade: step request <= 20s game time, wall budget 25s, editor
1336
- # relay 28s, server command timeout 30s. Each layer answers before the one
1337
- # above it gives up.
1338
- 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
1339
1346
  const STEP_MAX_FRAMES := 1200
1340
1347
  const STEP_WALL_BUDGET_MS := 25000
1341
1348
  const STEP_MAX_TRANSITIONS := 50
@@ -1358,6 +1365,7 @@ var _step_gameplay_ms := 0.0 # the unpaused portion: what gameplay actually exp
1358
1365
  var _step_frames := 0
1359
1366
  var _step_physics_ticks := 0
1360
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)
1361
1369
  var _step_events: Array = [] # in-step input timeline, scheduled on the game-time clock
1362
1370
  var _step_events_fired := 0
1363
1371
  var _step_transitions: Array = []
@@ -1515,6 +1523,7 @@ func _handle_game_time_step(data: Array) -> void:
1515
1523
  _step_finish_pending = false
1516
1524
  _step_wall_exceeded = false
1517
1525
  _step_wall_start = Time.get_ticks_msec()
1526
+ _step_wall_budget_ms = int(params.get("wall_budget_ms", STEP_WALL_BUDGET_MS))
1518
1527
  _step_predicate = null
1519
1528
  _step_response_type = "game_time_step"
1520
1529
  _step_active = true
@@ -1631,9 +1640,9 @@ func _handle_game_time_step_until(data: Array) -> void:
1631
1640
  _send_game_time_response("game_time_step_until", {"error": "step_until requires a non-empty `until` expression"})
1632
1641
  return
1633
1642
 
1634
- 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))
1635
1644
  if max_ms <= 0:
1636
- max_ms = STEP_MAX_MS
1645
+ max_ms = STEP_DEFAULT_MS
1637
1646
  max_ms = mini(max_ms, STEP_MAX_MS)
1638
1647
 
1639
1648
  # Compile and validate the predicate against the live tree before committing
@@ -1696,6 +1705,7 @@ func _handle_game_time_step_until(data: Array) -> void:
1696
1705
  _step_finish_pending = false
1697
1706
  _step_wall_exceeded = false
1698
1707
  _step_wall_start = Time.get_ticks_msec()
1708
+ _step_wall_budget_ms = int(params.get("wall_budget_ms", STEP_WALL_BUDGET_MS))
1699
1709
  _step_predicate = expr
1700
1710
  _step_predicate_inputs = ctx_inputs
1701
1711
  _step_predicate_met = false
@@ -1767,7 +1777,7 @@ func _step_process(delta: float) -> void:
1767
1777
  _step_predicate_met = true
1768
1778
  done = true
1769
1779
 
1770
- 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:
1771
1781
  # Slow-mo, Engine.time_scale = 0, or a pause-held window can starve
1772
1782
  # the game-time clock; the wall budget guarantees the call returns
1773
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.12.0"
6
+ version="3.14.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":""}
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { deriveTimeouts, maxInGameBudgetMs, QUICK_TIMEOUT_MS, ABSOLUTE_CEILING_MS, READY_WAIT_MS, BRIDGE_WALL_SLOP_MS, STEP_BUDGET_CAP_MS, INPUT_BUDGET_CAP_MS, } from '../../connection/timeouts.js';
3
+ describe('timeout cascade (#276)', () => {
4
+ describe('deriveTimeouts stagger invariant', () => {
5
+ // The whole point: bridge answers before relay answers before the server
6
+ // gives up, and the server never exceeds the absolute ceiling — for ANY
7
+ // budget, clamped or not, with or without the ready-wait.
8
+ const cases = [
9
+ { label: 'zero budget, no ready', budget: 0, readyWait: false },
10
+ { label: 'mid budget, no ready', budget: 5_000, readyWait: false },
11
+ { label: 'at step cap', budget: STEP_BUDGET_CAP_MS, readyWait: false },
12
+ { label: 'over cap, no ready', budget: 10_000_000, readyWait: false },
13
+ { label: 'zero budget, ready', budget: 0, readyWait: true },
14
+ { label: 'at input cap', budget: INPUT_BUDGET_CAP_MS, readyWait: true },
15
+ { label: 'over cap, ready', budget: 999_999, readyWait: true },
16
+ ];
17
+ for (const c of cases) {
18
+ it(`${c.label}: bridgeWall < relay < server <= ceiling`, () => {
19
+ const t = deriveTimeouts(c.budget, { readyWait: c.readyWait });
20
+ expect(t.bridgeWallMs).toBeLessThan(t.relayMs);
21
+ expect(t.relayMs).toBeLessThan(t.serverMs);
22
+ expect(t.serverMs).toBeLessThanOrEqual(ABSOLUTE_CEILING_MS);
23
+ expect(t.clampedBudgetMs).toBeGreaterThanOrEqual(0);
24
+ expect(t.clampedBudgetMs).toBeLessThanOrEqual(maxInGameBudgetMs({ readyWait: c.readyWait }));
25
+ });
26
+ }
27
+ });
28
+ it('produces a proportionally small (sub-ceiling) timeout for a small budget', () => {
29
+ const t = deriveTimeouts(3_000, { readyWait: false });
30
+ // bridgeWall = 3000 + slop; relay = +2000; server = +2000.
31
+ expect(t.bridgeWallMs).toBe(3_000 + BRIDGE_WALL_SLOP_MS);
32
+ expect(t.serverMs).toBeLessThan(ABSOLUTE_CEILING_MS);
33
+ });
34
+ it('reserves an extra READY_WAIT_MS of headroom when the ready-wait applies', () => {
35
+ expect(maxInGameBudgetMs({ readyWait: false }) - maxInGameBudgetMs({ readyWait: true })).toBe(READY_WAIT_MS);
36
+ });
37
+ it('clamps an over-budget request to the ceiling, never beyond', () => {
38
+ expect(deriveTimeouts(Number.MAX_SAFE_INTEGER, { readyWait: true }).serverMs).toBe(ABSOLUTE_CEILING_MS);
39
+ expect(deriveTimeouts(Number.MAX_SAFE_INTEGER, { readyWait: false }).serverMs).toBe(ABSOLUTE_CEILING_MS);
40
+ });
41
+ it('handles a non-finite budget defensively (treats it as zero)', () => {
42
+ const t = deriveTimeouts(NaN, { readyWait: false });
43
+ expect(t.clampedBudgetMs).toBe(0);
44
+ expect(t.serverMs).toBeGreaterThan(0);
45
+ });
46
+ describe('published caps stay under the ceiling so they can never time out server-side', () => {
47
+ it('step cap <= max in-game budget (no ready-wait)', () => {
48
+ expect(STEP_BUDGET_CAP_MS).toBeLessThanOrEqual(maxInGameBudgetMs({ readyWait: false }));
49
+ });
50
+ it('input cap <= max in-game budget (with ready-wait)', () => {
51
+ expect(INPUT_BUDGET_CAP_MS).toBeLessThanOrEqual(maxInGameBudgetMs({ readyWait: true }));
52
+ });
53
+ });
54
+ it('keeps the quick default unchanged at 30s', () => {
55
+ expect(QUICK_TIMEOUT_MS).toBe(30_000);
56
+ });
57
+ });
58
+ //# sourceMappingURL=timeouts.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"timeouts.test.js","sourceRoot":"","sources":["../../../src/__tests__/connection/timeouts.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EACL,cAAc,EACd,iBAAiB,EACjB,gBAAgB,EAChB,mBAAmB,EACnB,aAAa,EACb,mBAAmB,EACnB,kBAAkB,EAClB,mBAAmB,GACpB,MAAM,8BAA8B,CAAC;AAEtC,QAAQ,CAAC,wBAAwB,EAAE,GAAG,EAAE;IACtC,QAAQ,CAAC,kCAAkC,EAAE,GAAG,EAAE;QAChD,yEAAyE;QACzE,wEAAwE;QACxE,0DAA0D;QAC1D,MAAM,KAAK,GAAG;YACZ,EAAE,KAAK,EAAE,uBAAuB,EAAE,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,KAAK,EAAE;YAC/D,EAAE,KAAK,EAAE,sBAAsB,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE;YAClE,EAAE,KAAK,EAAE,aAAa,EAAE,MAAM,EAAE,kBAAkB,EAAE,SAAS,EAAE,KAAK,EAAE;YACtE,EAAE,KAAK,EAAE,oBAAoB,EAAE,MAAM,EAAE,UAAU,EAAE,SAAS,EAAE,KAAK,EAAE;YACrE,EAAE,KAAK,EAAE,oBAAoB,EAAE,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,IAAI,EAAE;YAC3D,EAAE,KAAK,EAAE,cAAc,EAAE,MAAM,EAAE,mBAAmB,EAAE,SAAS,EAAE,IAAI,EAAE;YACvE,EAAE,KAAK,EAAE,iBAAiB,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,IAAI,EAAE;SAC/D,CAAC;QAEF,KAAK,MAAM,CAAC,IAAI,KAAK,EAAE,CAAC;YACtB,EAAE,CAAC,GAAG,CAAC,CAAC,KAAK,0CAA0C,EAAE,GAAG,EAAE;gBAC5D,MAAM,CAAC,GAAG,cAAc,CAAC,CAAC,CAAC,MAAM,EAAE,EAAE,SAAS,EAAE,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC;gBAC/D,MAAM,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC;gBAC/C,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC;gBAC3C,MAAM,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,mBAAmB,CAAC,mBAAmB,CAAC,CAAC;gBAC5D,MAAM,CAAC,CAAC,CAAC,eAAe,CAAC,CAAC,sBAAsB,CAAC,CAAC,CAAC,CAAC;gBACpD,MAAM,CAAC,CAAC,CAAC,eAAe,CAAC,CAAC,mBAAmB,CAAC,iBAAiB,CAAC,EAAE,SAAS,EAAE,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC;YAC/F,CAAC,CAAC,CAAC;QACL,CAAC;IACH,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,0EAA0E,EAAE,GAAG,EAAE;QAClF,MAAM,CAAC,GAAG,cAAc,CAAC,KAAK,EAAE,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC,CAAC;QACtD,2DAA2D;QAC3D,MAAM,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,IAAI,CAAC,KAAK,GAAG,mBAAmB,CAAC,CAAC;QACzD,MAAM,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,YAAY,CAAC,mBAAmB,CAAC,CAAC;IACvD,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,yEAAyE,EAAE,GAAG,EAAE;QACjF,MAAM,CAAC,iBAAiB,CAAC,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC,GAAG,iBAAiB,CAAC,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC;IAC/G,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,4DAA4D,EAAE,GAAG,EAAE;QACpE,MAAM,CAAC,cAAc,CAAC,MAAM,CAAC,gBAAgB,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,IAAI,CAAC,mBAAmB,CAAC,CAAC;QACxG,MAAM,CAAC,cAAc,CAAC,MAAM,CAAC,gBAAgB,EAAE,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,IAAI,CAAC,mBAAmB,CAAC,CAAC;IAC3G,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,6DAA6D,EAAE,GAAG,EAAE;QACrE,MAAM,CAAC,GAAG,cAAc,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC,CAAC;QACpD,MAAM,CAAC,CAAC,CAAC,eAAe,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClC,MAAM,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,eAAe,CAAC,CAAC,CAAC,CAAC;IACxC,CAAC,CAAC,CAAC;IAEH,QAAQ,CAAC,8EAA8E,EAAE,GAAG,EAAE;QAC5F,EAAE,CAAC,gDAAgD,EAAE,GAAG,EAAE;YACxD,MAAM,CAAC,kBAAkB,CAAC,CAAC,mBAAmB,CAAC,iBAAiB,CAAC,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC;QAC1F,CAAC,CAAC,CAAC;QACH,EAAE,CAAC,mDAAmD,EAAE,GAAG,EAAE;YAC3D,MAAM,CAAC,mBAAmB,CAAC,CAAC,mBAAmB,CAAC,iBAAiB,CAAC,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;QAC1F,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,0CAA0C,EAAE,GAAG,EAAE;QAClD,MAAM,CAAC,gBAAgB,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IACxC,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
2
  import { once } from 'node:events';
3
3
  import { WebSocketServer } from 'ws';
4
4
  import { GodotConnection } from '../../connection/websocket.js';
5
+ import { getServerVersion } from '../../version.js';
5
6
  // Keep diagnostics hermetic (no WSL detection / no child processes) and quiet.
6
7
  vi.mock('../../utils/connection-strategy.js', () => ({
7
8
  getTargetHost: () => '127.0.0.1',
@@ -105,4 +106,61 @@ describe('GodotConnection contention handling (#237)', () => {
105
106
  expect(message).toMatch(/reconnect/i);
106
107
  });
107
108
  });
109
+ describe('GodotConnection per-request timeout (#276)', () => {
110
+ let connection = null;
111
+ let wss = null;
112
+ afterEach(async () => {
113
+ connection?.disconnect();
114
+ connection = null;
115
+ if (wss) {
116
+ await new Promise((resolve) => wss.close(() => resolve()));
117
+ wss = null;
118
+ }
119
+ });
120
+ // Reply to the handshake (so connect() resolves immediately and without a
121
+ // version-mismatch), then let the supplied handler deal with real commands.
122
+ async function bridgeAfterHandshake(onCommand) {
123
+ return startFakeBridge((socket) => {
124
+ socket.on('message', (raw) => {
125
+ let msg;
126
+ try {
127
+ msg = JSON.parse(raw.toString());
128
+ }
129
+ catch {
130
+ return;
131
+ }
132
+ if (!msg)
133
+ return;
134
+ if (msg.command === 'mcp_handshake') {
135
+ socket.send(JSON.stringify({ id: msg.id, status: 'success', result: { addon_version: getServerVersion() } }));
136
+ return;
137
+ }
138
+ onCommand(socket, msg);
139
+ });
140
+ });
141
+ }
142
+ it('rejects after opts.timeoutMs when the bridge never answers the command', async () => {
143
+ // Drop every non-handshake command on the floor.
144
+ const bridge = await bridgeAfterHandshake(() => { });
145
+ wss = bridge.wss;
146
+ connection = new GodotConnection({ host: '127.0.0.1', port: bridge.port, autoReconnect: false });
147
+ await connection.connect();
148
+ const start = Date.now();
149
+ await expect(connection.sendCommand('get_runtime_state', {}, { timeoutMs: 150 })).rejects.toThrow();
150
+ const elapsed = Date.now() - start;
151
+ expect(elapsed).toBeGreaterThanOrEqual(120);
152
+ expect(elapsed).toBeLessThan(2000);
153
+ });
154
+ it('does not fire early when given a generous opts.timeoutMs', async () => {
155
+ // Answer the command after a short delay; a 1s budget must not trip on it.
156
+ const bridge = await bridgeAfterHandshake((socket, msg) => {
157
+ setTimeout(() => socket.send(JSON.stringify({ id: msg.id, status: 'success', result: { ok: true } })), 100);
158
+ });
159
+ wss = bridge.wss;
160
+ connection = new GodotConnection({ host: '127.0.0.1', port: bridge.port, autoReconnect: false });
161
+ await connection.connect();
162
+ const result = await connection.sendCommand('get_runtime_state', {}, { timeoutMs: 1000 });
163
+ expect(result.ok).toBe(true);
164
+ });
165
+ });
108
166
  //# sourceMappingURL=websocket.test.js.map