@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 +7 -3
- package/addon/commands/debug_commands.gd +5 -13
- package/addon/commands/game_time_commands.gd +10 -5
- package/addon/commands/system_commands.gd +23 -0
- package/addon/core/mcp_logger.gd +59 -0
- package/addon/game_bridge/mcp_game_bridge.gd +227 -13
- package/addon/plugin.cfg +1 -1
- package/addon/websocket_server.gd +90 -4
- package/dist/__tests__/connection/websocket.test.d.ts +2 -0
- package/dist/__tests__/connection/websocket.test.d.ts.map +1 -0
- package/dist/__tests__/connection/websocket.test.js +108 -0
- package/dist/__tests__/connection/websocket.test.js.map +1 -0
- package/dist/__tests__/tools/editor.test.js +76 -3
- package/dist/__tests__/tools/editor.test.js.map +1 -1
- package/dist/__tests__/tools/game-time.test.js +70 -0
- package/dist/__tests__/tools/game-time.test.js.map +1 -1
- package/dist/connection/websocket.d.ts.map +1 -1
- package/dist/connection/websocket.js +19 -16
- package/dist/connection/websocket.js.map +1 -1
- package/dist/tools/editor.d.ts +9 -0
- package/dist/tools/editor.d.ts.map +1 -1
- package/dist/tools/editor.js +52 -4
- package/dist/tools/editor.js.map +1 -1
- package/dist/tools/game-time.d.ts +10 -0
- package/dist/tools/game-time.d.ts.map +1 -1
- package/dist/tools/game-time.js +34 -1
- package/dist/tools/game-time.js.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
|
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
|
|
6
|
-
# bridge (see mcp_game_bridge.gd); this side only forwards over the
|
|
7
|
-
# channel and waits. Timeout cascade:
|
|
8
|
-
# game time and the bridge's wall budget returns by 25s, so the
|
|
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")
|
package/addon/core/mcp_logger.gd
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
1260
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
|
14
|
-
const
|
|
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
|
-
|
|
96
|
-
|
|
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 @@
|
|
|
1
|
+
{"version":3,"file":"websocket.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/connection/websocket.test.ts"],"names":[],"mappings":""}
|