@satelliteoflove/godot-mcp 3.8.0 → 3.10.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.
package/README.md CHANGED
@@ -14,6 +14,8 @@ This MCP gives Claude direct access to your Godot editor. It can see your scene
14
14
 
15
15
  Add godot-mcp to your MCP client. See the [Installation Guide](INSTALL.md) for config examples (Claude Desktop, Claude Code, VSCode/Copilot, and more).
16
16
 
17
+ While you're at it, add [minimal-godot-mcp](https://github.com/ryanmazzolini/minimal-godot-mcp) too — it's a complementary server that covers static GDScript diagnostics and the running game's console output. See [Works Well With](#works-well-with).
18
+
17
19
  ### 2. Install the Godot addon
18
20
 
19
21
  ```bash
@@ -28,7 +30,7 @@ Open your Godot project, restart your AI assistant, and start building.
28
30
 
29
31
  ## What Claude Can Do
30
32
 
31
- - **See** your editor, scenes, running game, errors, and performance
33
+ - **See** your editor, scenes, running game, editor errors, and performance
32
34
  - **Inspect** nodes, resources, animations, tilemaps, 3D spatial data
33
35
  - **Modify** scenes, nodes, scripts, animations, tilemaps directly
34
36
  - **Test** by running the game and injecting input
@@ -36,9 +38,11 @@ Open your Godot project, restart your AI assistant, and start building.
36
38
 
37
39
  ## Works Well With
38
40
 
39
- [minimal-godot-mcp](https://github.com/ryanmazzolini/minimal-godot-mcp) by [@ryanmazzolini](https://github.com/ryanmazzolini) is another MCP server for Godot. It focuses on language server diagnostics and console output via DAP, with no addon required. This project focuses on runtime control, scene manipulation, and everything that needs a direct line into the editor.
41
+ [minimal-godot-mcp](https://github.com/ryanmazzolini/minimal-godot-mcp) by [@ryanmazzolini](https://github.com/ryanmazzolini) is another MCP server for Godot, and it's worth running alongside this one. This server drives the editor through an addon scene and node manipulation, running the game, input injection, runtime state, screenshots, and editor-side errors (`@tool`, import, and addon failures). minimal-godot-mcp needs no addon and provides exactly the functionality this server does not implement: LSP diagnostics for fast static `.gd` checking, and the running game's console output and stderr over DAP.
42
+
43
+ Neither duplicates the other, and they don't conflict. Install both and you get static analysis and the live game console alongside full editor and runtime control.
40
44
 
41
- They don't overlap much, and they don't conflict. Run them side by side for the best coverage.
45
+ **One godot-mcp client at a time, though.** A Godot editor bridge serves a single godot-mcp connection. If a second godot-mcp client connects - for example, a subagent that inherited the same MCP config - it is rejected while the first is active rather than displacing it, so the original session keeps working. The second client retries and connects automatically once the first disconnects. A client that crashes without closing its socket is taken over after a short idle timeout, so a dead session never permanently blocks new connections.
42
46
 
43
47
  ## Documentation
44
48
 
@@ -116,24 +116,16 @@ func _on_debug_output_received(output: PackedStringArray) -> void:
116
116
 
117
117
  func get_log_messages(params: Dictionary) -> Dictionary:
118
118
  var clear: bool = params.get("clear", false)
119
- var limit: int = params.get("limit", 50)
119
+ var limit: int = int(params.get("limit", 50))
120
+ var severity: String = params.get("severity", "all")
121
+ var since: int = int(params.get("since", 0))
120
122
 
121
- var all_messages := MCPLogger.get_errors()
122
- var total_count := all_messages.size()
123
-
124
- var limited: Array[Dictionary] = []
125
- var start_index := maxi(0, total_count - limit)
126
- for i in range(start_index, total_count):
127
- limited.append(all_messages[i])
123
+ var result := MCPLogger.query(since, severity, limit)
128
124
 
129
125
  if clear:
130
126
  MCPLogger.clear_errors()
131
127
 
132
- return _success({
133
- "total_count": total_count,
134
- "returned_count": limited.size(),
135
- "messages": limited,
136
- })
128
+ return _success(result)
137
129
 
138
130
 
139
131
  func get_errors(params: Dictionary) -> Dictionary:
@@ -2,11 +2,11 @@
2
2
  extends MCPBaseCommand
3
3
  class_name MCPGameTimeCommands
4
4
 
5
- # Game-time control relay: freeze / step / thaw / status execute in the game
6
- # bridge (see mcp_game_bridge.gd); this side only forwards over the debugger
7
- # channel and waits. Timeout cascade: the step request is capped at 20s of
8
- # game time and the bridge's wall budget returns by 25s, so the 28s relay
9
- # timeout below fires only if the bridge is gone — and stays under the
5
+ # Game-time control relay: freeze / step / step_until / thaw / status execute in
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
10
  # server's 30s command timeout so errors surface typed instead of generic.
11
11
  const BASE_TIMEOUT := 10.0
12
12
  const STEP_TIMEOUT := 28.0
@@ -18,6 +18,7 @@ func get_commands() -> Dictionary:
18
18
  return {
19
19
  "game_time_freeze": game_time_freeze,
20
20
  "game_time_step": game_time_step,
21
+ "game_time_step_until": game_time_step_until,
21
22
  "game_time_thaw": game_time_thaw,
22
23
  "game_time_status": game_time_status,
23
24
  }
@@ -31,6 +32,10 @@ func game_time_step(params: Dictionary) -> Dictionary:
31
32
  return await _relay("game_time_step", [params], STEP_TIMEOUT)
32
33
 
33
34
 
35
+ func game_time_step_until(params: Dictionary) -> Dictionary:
36
+ return await _relay("game_time_step_until", [params], STEP_TIMEOUT)
37
+
38
+
34
39
  func game_time_thaw(params: Dictionary) -> Dictionary:
35
40
  return await _relay("game_time_thaw", [params], BASE_TIMEOUT)
36
41
 
@@ -3,10 +3,14 @@ extends MCPBaseCommand
3
3
  class_name MCPSystemCommands
4
4
 
5
5
 
6
+ const RESTART_ACK_GRACE_SEC := 0.3
7
+
8
+
6
9
  func get_commands() -> Dictionary:
7
10
  return {
8
11
  "mcp_handshake": mcp_handshake,
9
12
  "heartbeat": heartbeat,
13
+ "restart_editor": restart_editor,
10
14
  }
11
15
 
12
16
 
@@ -29,6 +33,25 @@ func heartbeat(_params: Dictionary) -> Dictionary:
29
33
  return _success({"status": "ok"})
30
34
 
31
35
 
36
+ func restart_editor(params: Dictionary) -> Dictionary:
37
+ var save: bool = params.get("save", true)
38
+
39
+ # Restarting tears down this websocket along with the editor, so defer the
40
+ # actual restart by a short grace period. That lets this acknowledgement
41
+ # flush to the client first; the MCP server then auto-reconnects once the
42
+ # editor is back. (EditorInterface.restart_editor itself defers the quit to
43
+ # end-of-frame, which alone is too early for the response to make it out.)
44
+ var tree := Engine.get_main_loop() as SceneTree
45
+ if tree:
46
+ tree.create_timer(RESTART_ACK_GRACE_SEC).timeout.connect(
47
+ func() -> void: EditorInterface.restart_editor(save)
48
+ )
49
+ else:
50
+ EditorInterface.restart_editor(save)
51
+
52
+ return _success({"restarting": true, "save": save})
53
+
54
+
32
55
  func _get_addon_version() -> String:
33
56
  var config := ConfigFile.new()
34
57
  var err := config.load("res://addons/godot_mcp/plugin.cfg")
@@ -6,6 +6,11 @@ static var _errors: Array[Dictionary] = []
6
6
  static var _max_lines := 1000
7
7
  static var _max_errors := 100
8
8
  static var _mutex := Mutex.new()
9
+ # Monotonic sequence stamped on every retained error, never reset (not even by
10
+ # clear_errors). It is the cursor for incremental reads: a caller passes the
11
+ # `cursor` from a previous get_log_messages back as `since` to get only what is
12
+ # new. Independent of array index, so trimming the oldest entries is harmless.
13
+ static var _seq := 0
9
14
 
10
15
 
11
16
  static func _static_init() -> void:
@@ -50,6 +55,8 @@ func _log_error(function: String, file: String, line: int, code: String,
50
55
  "frames": frames,
51
56
  }
52
57
  if not _is_duplicate(error_entry):
58
+ _seq += 1
59
+ error_entry["seq"] = _seq
53
60
  _errors.append(error_entry)
54
61
  if _errors.size() > _max_errors:
55
62
  _errors.remove_at(0)
@@ -74,6 +81,58 @@ static func get_errors() -> Array[Dictionary]:
74
81
  return _errors
75
82
 
76
83
 
84
+ static func get_seq() -> int:
85
+ return _seq
86
+
87
+
88
+ # Filtered, incremental view of the retained errors. Pure (no side effects) so it
89
+ # can be unit-tested headless.
90
+ # since : return only entries with seq > since (0 = from the beginning)
91
+ # severity : "all" (default), "error" (drops warnings), or "warning" (only warnings)
92
+ # limit : keep at most this many of the most recent matches (<= 0 means all)
93
+ # `cursor` is always the highest seq issued so far; pass it back as `since` next
94
+ # time to read only what is new. `total_count` is the whole buffer (unfiltered),
95
+ # `match_count` is what passed the filters, `returned_count` is after the limit.
96
+ static func query(since: int, severity: String, limit: int) -> Dictionary:
97
+ _mutex.lock()
98
+ var cursor := _seq
99
+ var total_count := _errors.size()
100
+ var matched: Array[Dictionary] = []
101
+ for entry in _errors:
102
+ if int(entry.get("seq", 0)) <= since:
103
+ continue
104
+ if not _severity_matches(severity, int(entry.get("error_type", 0))):
105
+ continue
106
+ matched.append(entry)
107
+ _mutex.unlock()
108
+
109
+ var match_count := matched.size()
110
+ var start_index := 0
111
+ if limit > 0 and match_count > limit:
112
+ start_index = match_count - limit
113
+ var messages: Array[Dictionary] = matched.slice(start_index)
114
+
115
+ return {
116
+ "total_count": total_count,
117
+ "match_count": match_count,
118
+ "returned_count": messages.size(),
119
+ "cursor": cursor,
120
+ "messages": messages,
121
+ }
122
+
123
+
124
+ # Logger.ErrorType (inherited): ERROR_TYPE_ERROR=0, ERROR_TYPE_WARNING=1,
125
+ # ERROR_TYPE_SCRIPT=2, ERROR_TYPE_SHADER=3. Only WARNING is not an actual problem.
126
+ static func _severity_matches(severity: String, error_type: int) -> bool:
127
+ match severity:
128
+ "error":
129
+ return error_type != Logger.ERROR_TYPE_WARNING
130
+ "warning":
131
+ return error_type == Logger.ERROR_TYPE_WARNING
132
+ _:
133
+ return true
134
+
135
+
77
136
  static func get_last_stack_trace() -> Array[Dictionary]:
78
137
  if _errors.is_empty():
79
138
  return []
@@ -170,6 +170,9 @@ func _on_debugger_message(message: String, data: Array) -> bool:
170
170
  "game_time_step":
171
171
  _handle_game_time_step(data)
172
172
  return true
173
+ "game_time_step_until":
174
+ _handle_game_time_step_until(data)
175
+ return true
173
176
  "game_time_thaw":
174
177
  _handle_game_time_thaw(data)
175
178
  return true
@@ -1117,6 +1120,18 @@ var _step_events_fired := 0
1117
1120
  var _step_transitions: Array = []
1118
1121
  var _step_last_tree_paused := false
1119
1122
 
1123
+ # step_until adds a predicate evaluated each frame. _step_predicate is null for a
1124
+ # fixed-budget step, set for step_until; _step_response_type routes _finish_step's
1125
+ # reply to the matching command (the relay correlates by message type). _step_report
1126
+ # is the optional readings the agent wants back at stop time (in one round-trip,
1127
+ # instead of a separate observation call) — each is [{src: String, expr: Expression}].
1128
+ var _step_predicate: Expression = null
1129
+ var _step_predicate_inputs: Array = []
1130
+ var _step_predicate_met := false
1131
+ var _step_predicate_error := ""
1132
+ var _step_report: Array = []
1133
+ var _step_response_type := "game_time_step"
1134
+
1120
1135
 
1121
1136
  func _send_game_time_response(msg_type: String, result: Dictionary) -> void:
1122
1137
  EngineDebugger.send_message("godot_mcp:game_response", [msg_type, result])
@@ -1235,15 +1250,53 @@ func _handle_game_time_step(data: Array) -> void:
1235
1250
  # from window start). Inputs must ride inside the step: an event injected
1236
1251
  # while frozen lands on a frame gameplay never processes, so its
1237
1252
  # is_action_just_pressed edge would be silently missed.
1238
- var inputs: Array = params.get("inputs", [])
1253
+ var compiled := _compile_step_events(params.get("inputs", []))
1254
+ if compiled.has("error"):
1255
+ _send_game_time_response("game_time_step", {"error": compiled["error"]})
1256
+ return
1257
+
1258
+ # Step from a running game is allowed — it freezes first, so "advance
1259
+ # 500ms then wait for me" is a single atomic call.
1260
+ _engage_freeze()
1261
+
1262
+ _step_target_ms = float(duration_ms)
1263
+ _step_target_frames = frames
1264
+ _step_elapsed_ms = 0.0
1265
+ _step_gameplay_ms = 0.0
1266
+ _step_frames = 0
1267
+ _step_physics_ticks = 0
1268
+ _step_events = compiled["events"]
1269
+ _step_events_fired = 0
1270
+ _step_transitions = []
1271
+ _step_needs_settle = false
1272
+ _step_finish_pending = false
1273
+ _step_wall_exceeded = false
1274
+ _step_wall_start = Time.get_ticks_msec()
1275
+ _step_predicate = null
1276
+ _step_response_type = "game_time_step"
1277
+ _step_active = true
1278
+
1279
+ # Open the window: restore the game layer's own pause wish for the
1280
+ # duration. If the game's menu is holding it paused, the window still
1281
+ # elapses (and reports gameplay_ms ~0) — never deadlock waiting for
1282
+ # gameplay time that cannot come.
1283
+ var tree := get_tree()
1284
+ tree.paused = _game_paused
1285
+ _step_last_tree_paused = tree.paused
1286
+ set_physics_process(true)
1287
+ _update_processing()
1288
+
1289
+
1290
+ func _compile_step_events(inputs: Array) -> Dictionary:
1291
+ # Builds the press/release timeline shared by step and step_until. start_ms
1292
+ # is game time from window start; returns {"error": ...} on an unknown action.
1239
1293
  var events: Array = []
1240
1294
  for input in inputs:
1241
1295
  var action_name: String = input.get("action_name", "")
1242
1296
  if action_name.is_empty():
1243
1297
  continue
1244
1298
  if not InputMap.has_action(action_name):
1245
- _send_game_time_response("game_time_step", {"error": "Unknown action: %s" % action_name})
1246
- return
1299
+ return {"error": "Unknown action: %s" % action_name}
1247
1300
  var start_ms: int = int(input.get("start_ms", 0))
1248
1301
  var dur: int = int(input.get("duration_ms", 0))
1249
1302
  events.append({"time": start_ms, "action": action_name, "is_press": true})
@@ -1251,30 +1304,163 @@ func _handle_game_time_step(data: Array) -> void:
1251
1304
  events.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
1252
1305
  return a.time < b.time
1253
1306
  )
1307
+ return {"events": events}
1308
+
1309
+
1310
+ func _build_predicate_context() -> Dictionary:
1311
+ # Exposes the running game to a step_until predicate: every autoload by its
1312
+ # own name (so `G.wave > 1` just works), plus `tree` (SceneTree) and `root`
1313
+ # (root Window) for tree queries like
1314
+ # `tree.get_nodes_in_group("enemies").size() >= 1`. Chained calls must run on
1315
+ # these input objects, not the Expression base instance, so they are inputs.
1316
+ var names: Array = []
1317
+ var inputs: Array = []
1318
+ var tree := get_tree()
1319
+ for prop in ProjectSettings.get_property_list():
1320
+ var key: String = prop.get("name", "")
1321
+ if not key.begins_with("autoload/"):
1322
+ continue
1323
+ var autoload_name := key.substr("autoload/".length())
1324
+ var node := tree.root.get_node_or_null(NodePath(autoload_name))
1325
+ if node == null or node == self:
1326
+ continue # skip the bridge's own autoload and any unresolved entry
1327
+ names.append(autoload_name)
1328
+ inputs.append(node)
1329
+ if not names.has("tree"):
1330
+ names.append("tree")
1331
+ inputs.append(tree)
1332
+ if not names.has("root"):
1333
+ names.append("root")
1334
+ inputs.append(tree.root)
1335
+ return {"names": PackedStringArray(names), "inputs": inputs}
1336
+
1337
+
1338
+ func _sanitize_value(v: Variant) -> Variant:
1339
+ # Report values ride back over the debugger channel. Pass primitives through;
1340
+ # never try to serialize Objects/containers — a short string stand-in is
1341
+ # enough for the agent to see what an expression evaluated to.
1342
+ match typeof(v):
1343
+ TYPE_NIL, TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING, TYPE_STRING_NAME:
1344
+ return v
1345
+ _:
1346
+ return str(v).substr(0, 200)
1347
+
1348
+
1349
+ func _compile_report(report: Array, names: PackedStringArray, inputs: Array) -> Dictionary:
1350
+ # Compile + validate each report expression in the predicate context. Returns
1351
+ # {"error": ...} if any fails up front, else {"report": [{src, expr}, ...]}.
1352
+ var compiled: Array = []
1353
+ for item in report:
1354
+ var s := str(item).strip_edges()
1355
+ if s.is_empty():
1356
+ continue
1357
+ var e := Expression.new()
1358
+ if e.parse(s, names) != OK:
1359
+ return {"error": "report expression parse error (%s): %s" % [s, e.get_error_text()]}
1360
+ e.execute(inputs, self)
1361
+ if e.has_execute_failed():
1362
+ return {"error": "report expression failed to evaluate (%s): %s" % [s, e.get_error_text()]}
1363
+ compiled.append({"src": s, "expr": e})
1364
+ return {"report": compiled}
1365
+
1366
+
1367
+ func _evaluate_report(report_exprs: Array, inputs: Array) -> Dictionary:
1368
+ # Evaluate the compiled report expressions at stop time into {src: value}.
1369
+ var out: Dictionary = {}
1370
+ for item in report_exprs:
1371
+ var e: Expression = item["expr"]
1372
+ var v: Variant = e.execute(inputs, self)
1373
+ if e.has_execute_failed():
1374
+ out[item["src"]] = "<error: %s>" % e.get_error_text()
1375
+ else:
1376
+ out[item["src"]] = _sanitize_value(v)
1377
+ return out
1378
+
1379
+
1380
+ func _handle_game_time_step_until(data: Array) -> void:
1381
+ var params: Dictionary = data[0] if data.size() > 0 and data[0] is Dictionary else {}
1382
+ if _step_active:
1383
+ _send_game_time_response("game_time_step_until", {"error": "Step already in progress"})
1384
+ return
1385
+
1386
+ var src: String = str(params.get("until", "")).strip_edges()
1387
+ if src.is_empty():
1388
+ _send_game_time_response("game_time_step_until", {"error": "step_until requires a non-empty `until` expression"})
1389
+ return
1390
+
1391
+ var max_ms: int = int(params.get("max_ms", STEP_MAX_MS))
1392
+ if max_ms <= 0:
1393
+ max_ms = STEP_MAX_MS
1394
+ max_ms = mini(max_ms, STEP_MAX_MS)
1395
+
1396
+ # Compile and validate the predicate against the live tree before committing
1397
+ # to a step. Expression.parse() is lenient (a malformed string can parse
1398
+ # clean), so a dry-run execute is what actually catches unknown identifiers
1399
+ # and bad member access.
1400
+ var ctx := _build_predicate_context()
1401
+ var ctx_names: PackedStringArray = ctx["names"]
1402
+ var ctx_inputs: Array = ctx["inputs"]
1403
+ var expr := Expression.new()
1404
+ if expr.parse(src, ctx_names) != OK:
1405
+ _send_game_time_response("game_time_step_until", {"error": "predicate parse error: %s" % expr.get_error_text()})
1406
+ return
1407
+ var first_value: Variant = expr.execute(ctx_inputs, self)
1408
+ if expr.has_execute_failed():
1409
+ _send_game_time_response("game_time_step_until", {"error": "predicate failed to evaluate: %s" % expr.get_error_text()})
1410
+ return
1411
+
1412
+ # Optional readings to return at stop time, validated up front in the same context.
1413
+ var report_result := _compile_report(params.get("report", []), ctx_names, ctx_inputs)
1414
+ if report_result.has("error"):
1415
+ _send_game_time_response("game_time_step_until", {"error": report_result["error"]})
1416
+ return
1417
+ var report_compiled: Array = report_result["report"]
1418
+
1419
+ var compiled := _compile_step_events(params.get("inputs", []))
1420
+ if compiled.has("error"):
1421
+ _send_game_time_response("game_time_step_until", {"error": compiled["error"]})
1422
+ return
1254
1423
 
1255
- # Step from a running game is allowed — it freezes first, so "advance
1256
- # 500ms then wait for me" is a single atomic call.
1257
1424
  _engage_freeze()
1258
1425
 
1259
- _step_target_ms = float(duration_ms)
1260
- _step_target_frames = frames
1426
+ # Predicate already holds: advance nothing, stay frozen, report it.
1427
+ if bool(first_value):
1428
+ var sc_result: Dictionary = {
1429
+ "completed": true,
1430
+ "frozen": true,
1431
+ "elapsed_ms": 0,
1432
+ "gameplay_ms": 0,
1433
+ "frames": 0,
1434
+ "physics_ticks": 0,
1435
+ "game_paused": _game_paused,
1436
+ "predicate_met": true,
1437
+ }
1438
+ if not report_compiled.is_empty():
1439
+ sc_result["report"] = _evaluate_report(report_compiled, ctx_inputs)
1440
+ _send_game_time_response("game_time_step_until", sc_result)
1441
+ return
1442
+
1443
+ _step_target_ms = float(max_ms)
1444
+ _step_target_frames = 0
1261
1445
  _step_elapsed_ms = 0.0
1262
1446
  _step_gameplay_ms = 0.0
1263
1447
  _step_frames = 0
1264
1448
  _step_physics_ticks = 0
1265
- _step_events = events
1449
+ _step_events = compiled["events"]
1266
1450
  _step_events_fired = 0
1267
1451
  _step_transitions = []
1268
1452
  _step_needs_settle = false
1269
1453
  _step_finish_pending = false
1270
1454
  _step_wall_exceeded = false
1271
1455
  _step_wall_start = Time.get_ticks_msec()
1456
+ _step_predicate = expr
1457
+ _step_predicate_inputs = ctx_inputs
1458
+ _step_predicate_met = false
1459
+ _step_predicate_error = ""
1460
+ _step_report = report_compiled
1461
+ _step_response_type = "game_time_step_until"
1272
1462
  _step_active = true
1273
1463
 
1274
- # Open the window: restore the game layer's own pause wish for the
1275
- # duration. If the game's menu is holding it paused, the window still
1276
- # elapses (and reports gameplay_ms ~0) — never deadlock waiting for
1277
- # gameplay time that cannot come.
1278
1464
  var tree := get_tree()
1279
1465
  tree.paused = _game_paused
1280
1466
  _step_last_tree_paused = tree.paused
@@ -1325,6 +1511,19 @@ func _step_process(delta: float) -> void:
1325
1511
  done = _step_frames >= _step_target_frames
1326
1512
  else:
1327
1513
  done = _step_elapsed_ms >= _step_target_ms
1514
+
1515
+ # step_until: re-evaluate the predicate each frame against the advancing
1516
+ # game. A truthy result stops the window early; a runtime failure (e.g. a
1517
+ # watched node was freed mid-window) ends it honestly with the error attached.
1518
+ if _step_predicate != null:
1519
+ var v: Variant = _step_predicate.execute(_step_predicate_inputs, self)
1520
+ if _step_predicate.has_execute_failed():
1521
+ _step_predicate_error = _step_predicate.get_error_text()
1522
+ done = true
1523
+ elif bool(v):
1524
+ _step_predicate_met = true
1525
+ done = true
1526
+
1328
1527
  if Time.get_ticks_msec() - _step_wall_start > STEP_WALL_BUDGET_MS:
1329
1528
  # Slow-mo, Engine.time_scale = 0, or a pause-held window can starve
1330
1529
  # the game-time clock; the wall budget guarantees the call returns
@@ -1376,4 +1575,19 @@ func _finish_step() -> void:
1376
1575
  result["pause_transitions"] = _step_transitions
1377
1576
  if _step_wall_exceeded:
1378
1577
  result["wall_budget_exceeded"] = true
1379
- _send_game_time_response("game_time_step", result)
1578
+ if _step_predicate != null:
1579
+ # step_until: predicate_met is the headline. report carries the readings the
1580
+ # agent asked for (the "what advanced" hint, so it need not re-observe). A
1581
+ # non-met return means the cap or wall budget ran out first.
1582
+ result["predicate_met"] = _step_predicate_met
1583
+ if not _step_report.is_empty():
1584
+ result["report"] = _evaluate_report(_step_report, _step_predicate_inputs)
1585
+ if not _step_predicate_error.is_empty():
1586
+ result["predicate_error"] = _step_predicate_error
1587
+
1588
+ # Route the reply to the originating command — the relay correlates by type.
1589
+ var response_type := _step_response_type
1590
+ _step_predicate = null
1591
+ _step_predicate_inputs = []
1592
+ _step_report = []
1593
+ _send_game_time_response(response_type, result)
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.8.0"
6
+ version="3.10.0"
7
7
  script="plugin.gd"
8
8
  godot_version_min="4.5"
@@ -8,10 +8,14 @@ signal client_disconnected()
8
8
 
9
9
  const DEFAULT_PORT := 6550
10
10
  const STALE_CONNECTION_TIMEOUT_MSEC := 45000
11
+ # Upper bound on how long a rejected newcomer is kept around while we hand it a
12
+ # clean 4001 close. Caps the whole reject lifecycle (handshake + close flush) so
13
+ # a peer that never upgrades or never finishes closing can't leak.
14
+ const REJECT_TIMEOUT_MSEC := 5000
11
15
  const CLOSE_CODE_STALE := 4002
12
16
  const CLOSE_REASON_STALE := "Connection timed out (no activity)"
13
- const CLOSE_CODE_REPLACED := 4003
14
- const CLOSE_REASON_REPLACED := "Replaced by new client"
17
+ const CLOSE_CODE_ALREADY_CONNECTED := 4001
18
+ const CLOSE_REASON_ALREADY_CONNECTED := "Another client is already connected"
15
19
 
16
20
  var _server: TCPServer
17
21
  var _peer: StreamPeerTCP
@@ -21,6 +25,10 @@ var _connected_host: String = ""
21
25
  var _connected_port: int = 0
22
26
  var _last_activity_msec: int = 0
23
27
  var _stale_reason: String = ""
28
+ # Newcomers that arrived while a live client already held the bridge. Each entry
29
+ # is { "ws": WebSocketPeer, "tcp": StreamPeerTCP, "since_msec": int, "close_sent": bool }.
30
+ # They are handshaked just far enough to receive a clean 4001 close, then dropped.
31
+ var _rejecting_peers: Array = []
24
32
 
25
33
 
26
34
  func _process(_delta: float) -> void:
@@ -34,6 +42,9 @@ func _process(_delta: float) -> void:
34
42
  _ws_peer.poll()
35
43
  _process_websocket()
36
44
 
45
+ if not _rejecting_peers.is_empty():
46
+ _process_rejecting_peers()
47
+
37
48
 
38
49
  func start_server(port: int = DEFAULT_PORT, bind_address: String = "127.0.0.1") -> Error:
39
50
  _server = TCPServer.new()
@@ -55,6 +66,15 @@ func stop_server() -> void:
55
66
  _peer.disconnect_from_host()
56
67
  _peer = null
57
68
 
69
+ for entry in _rejecting_peers:
70
+ var rej_ws: WebSocketPeer = entry.get("ws")
71
+ if rej_ws:
72
+ rej_ws.close()
73
+ var rej_tcp: StreamPeerTCP = entry.get("tcp")
74
+ if rej_tcp:
75
+ rej_tcp.disconnect_from_host()
76
+ _rejecting_peers.clear()
77
+
58
78
  if _server:
59
79
  _server.stop()
60
80
  _server = null
@@ -89,11 +109,19 @@ func _accept_connection() -> void:
89
109
 
90
110
  if _ws_peer != null:
91
111
  if _is_stale_connection():
112
+ # The incumbent looks dead (TCP dropped, or no activity past the
113
+ # timeout). Hand the bridge to the newcomer so a crashed client can
114
+ # never permanently block reconnection.
92
115
  MCPLog.warn("Replacing stale connection with new client (%s)" % _stale_reason)
93
116
  _force_close_connection()
94
117
  else:
95
- MCPLog.warn("Replacing active connection with new client (previous server likely exited without closing)")
96
- _force_close_connection(CLOSE_CODE_REPLACED, CLOSE_REASON_REPLACED)
118
+ # A live client already holds the bridge. Protect the incumbent and
119
+ # reject the newcomer with a clean close code instead of displacing
120
+ # it. This keeps a transient second client (e.g. a subagent that
121
+ # inherited the same MCP config) from bricking the active session.
122
+ MCPLog.warn("Rejecting new connection from %s:%d: another client is already connected" % [incoming.get_connected_host(), incoming.get_connected_port()])
123
+ _begin_reject(incoming)
124
+ return
97
125
 
98
126
  _peer = incoming
99
127
  _ws_peer = WebSocketPeer.new()
@@ -112,6 +140,64 @@ func _accept_connection() -> void:
112
140
  MCPLog.info("TCP connection received from %s:%d, awaiting WebSocket handshake..." % [_connected_host, _connected_port])
113
141
 
114
142
 
143
+ func _begin_reject(incoming: StreamPeerTCP) -> void:
144
+ # Complete just enough of the WebSocket handshake on a throwaway peer to send
145
+ # a proper coded close frame (4001), so the rejected client gets a clear
146
+ # diagnostic rather than an opaque TCP reset. The incumbent's peer is never
147
+ # touched. Driven to completion in _process_rejecting_peers().
148
+ var ws := WebSocketPeer.new()
149
+ var err := ws.accept_stream(incoming)
150
+ if err != OK:
151
+ # Can't speak WebSocket to this peer; just drop the raw TCP connection.
152
+ incoming.disconnect_from_host()
153
+ return
154
+ _rejecting_peers.append({
155
+ "ws": ws,
156
+ "tcp": incoming,
157
+ "since_msec": Time.get_ticks_msec(),
158
+ "close_sent": false,
159
+ })
160
+
161
+
162
+ func _process_rejecting_peers() -> void:
163
+ var keep: Array = []
164
+ var now := Time.get_ticks_msec()
165
+ for entry in _rejecting_peers:
166
+ var ws: WebSocketPeer = entry["ws"]
167
+ ws.poll()
168
+ var state := ws.get_ready_state()
169
+
170
+ # Hard lifecycle cap: a reject peer should resolve in milliseconds.
171
+ # Anything still around past the timeout (never upgraded, or stuck
172
+ # mid-close because the client vanished) gets dropped so it can't leak.
173
+ if now - int(entry["since_msec"]) > REJECT_TIMEOUT_MSEC:
174
+ _drop_rejecting_peer(entry)
175
+ continue
176
+
177
+ match state:
178
+ WebSocketPeer.STATE_OPEN:
179
+ if not entry["close_sent"]:
180
+ ws.close(CLOSE_CODE_ALREADY_CONNECTED, CLOSE_REASON_ALREADY_CONNECTED)
181
+ entry["close_sent"] = true
182
+ # Keep polling so the close frame is actually flushed.
183
+ keep.append(entry)
184
+ WebSocketPeer.STATE_CLOSED:
185
+ _drop_rejecting_peer(entry)
186
+ _:
187
+ # STATE_CONNECTING (awaiting handshake) or STATE_CLOSING
188
+ # (flushing the close frame) - keep polling until the cap.
189
+ keep.append(entry)
190
+ _rejecting_peers = keep
191
+
192
+
193
+ func _drop_rejecting_peer(entry: Dictionary) -> void:
194
+ var tcp: StreamPeerTCP = entry.get("tcp")
195
+ if tcp:
196
+ tcp.disconnect_from_host()
197
+ entry["ws"] = null
198
+ entry["tcp"] = null
199
+
200
+
115
201
  func _process_websocket() -> void:
116
202
  if not _ws_peer:
117
203
  return
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=websocket.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"websocket.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/connection/websocket.test.ts"],"names":[],"mappings":""}