@satelliteoflove/godot-mcp 3.13.0 → 3.15.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/addon/command_router.gd +1 -0
- package/addon/commands/debug_commands.gd +9 -0
- package/addon/commands/exec_commands.gd +117 -0
- package/addon/commands/exec_commands.gd.uid +1 -0
- package/addon/commands/input_commands.gd +10 -1
- package/addon/commands/project_commands.gd +9 -1
- package/addon/core/mcp_debugger_plugin.gd +24 -0
- package/addon/core/mcp_utils.gd +171 -0
- package/addon/game_bridge/mcp_exec_guard.gd +225 -0
- package/addon/game_bridge/mcp_exec_guard.gd.uid +1 -0
- package/addon/game_bridge/mcp_game_bridge.gd +233 -1
- package/addon/plugin.cfg +1 -1
- package/dist/__tests__/connection/timeouts.test.js +4 -1
- package/dist/__tests__/connection/timeouts.test.js.map +1 -1
- package/dist/__tests__/tools/editor.test.js +36 -0
- package/dist/__tests__/tools/editor.test.js.map +1 -1
- package/dist/__tests__/tools/exec.test.d.ts +2 -0
- package/dist/__tests__/tools/exec.test.d.ts.map +1 -0
- package/dist/__tests__/tools/exec.test.js +124 -0
- package/dist/__tests__/tools/exec.test.js.map +1 -0
- package/dist/__tests__/tools/input.test.js +33 -0
- package/dist/__tests__/tools/input.test.js.map +1 -1
- package/dist/__tests__/tools/project.test.d.ts +2 -0
- package/dist/__tests__/tools/project.test.d.ts.map +1 -0
- package/dist/__tests__/tools/project.test.js +53 -0
- package/dist/__tests__/tools/project.test.js.map +1 -0
- package/dist/connection/timeouts.d.ts +1 -0
- package/dist/connection/timeouts.d.ts.map +1 -1
- package/dist/connection/timeouts.js +1 -0
- package/dist/connection/timeouts.js.map +1 -1
- package/dist/tools/editor.d.ts.map +1 -1
- package/dist/tools/editor.js +8 -2
- package/dist/tools/editor.js.map +1 -1
- package/dist/tools/exec.d.ts +16 -0
- package/dist/tools/exec.d.ts.map +1 -0
- package/dist/tools/exec.js +102 -0
- package/dist/tools/exec.js.map +1 -0
- package/dist/tools/index.d.ts +1 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +3 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/input.d.ts.map +1 -1
- package/dist/tools/input.js +6 -1
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/project-staleness.d.ts +15 -0
- package/dist/tools/project-staleness.d.ts.map +1 -0
- package/dist/tools/project-staleness.js +16 -0
- package/dist/tools/project-staleness.js.map +1 -0
- package/dist/tools/project.d.ts +2 -0
- package/dist/tools/project.d.ts.map +1 -1
- package/dist/tools/project.js +14 -0
- package/dist/tools/project.js.map +1 -1
- package/package.json +1 -1
package/addon/command_router.gd
CHANGED
|
@@ -23,6 +23,7 @@ func setup(plugin: EditorPlugin) -> void:
|
|
|
23
23
|
_register_handler(MCPProfilerCommands.new(), plugin)
|
|
24
24
|
_register_handler(MCPRuntimeStateCommands.new(), plugin)
|
|
25
25
|
_register_handler(MCPGameTimeCommands.new(), plugin)
|
|
26
|
+
_register_handler(MCPExecCommands.new(), plugin)
|
|
26
27
|
|
|
27
28
|
|
|
28
29
|
func _register_handler(handler: MCPBaseCommand, plugin: EditorPlugin) -> void:
|
|
@@ -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
|
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
@tool
|
|
2
|
+
extends MCPBaseCommand
|
|
3
|
+
class_name MCPExecCommands
|
|
4
|
+
|
|
5
|
+
# godot_exec relay (#243): run / list / remove / clear execute in the game
|
|
6
|
+
# bridge (mcp_game_bridge.gd); this side only forwards over the debugger
|
|
7
|
+
# channel and waits, exactly like game_time_commands.gd. The server derives the
|
|
8
|
+
# timeout cascade from the call's declared budget and pushes relay_timeout_ms
|
|
9
|
+
# in params; the constants are fallbacks for an older server that pushes none.
|
|
10
|
+
# RUN_TIMEOUT is generous because a synchronous user script cannot be aborted
|
|
11
|
+
# mid-flight — the relay waiting is what turns a hung script into a typed
|
|
12
|
+
# TIMEOUT instead of a socket kill.
|
|
13
|
+
const BASE_TIMEOUT := 10.0
|
|
14
|
+
const RUN_TIMEOUT := 28.0
|
|
15
|
+
|
|
16
|
+
var _last_error: Dictionary = {}
|
|
17
|
+
var _call_seq := 0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
func get_commands() -> Dictionary:
|
|
21
|
+
return {
|
|
22
|
+
"exec_run": exec_run,
|
|
23
|
+
"exec_list": exec_list,
|
|
24
|
+
"exec_remove": exec_remove,
|
|
25
|
+
"exec_clear": exec_clear,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
func exec_run(params: Dictionary) -> Dictionary:
|
|
30
|
+
return await _relay("exec_run", params, _relay_timeout(params, RUN_TIMEOUT))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
func exec_list(params: Dictionary) -> Dictionary:
|
|
34
|
+
return await _relay("exec_list", params, BASE_TIMEOUT)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
func exec_remove(params: Dictionary) -> Dictionary:
|
|
38
|
+
return await _relay("exec_remove", params, BASE_TIMEOUT)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
func exec_clear(params: Dictionary) -> Dictionary:
|
|
42
|
+
return await _relay("exec_clear", params, BASE_TIMEOUT)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
func _relay_timeout(params: Dictionary, fallback: float) -> float:
|
|
46
|
+
# Use the server-pushed relay budget when present (#276); the local constant
|
|
47
|
+
# is only a fallback for an older server that does not derive the cascade.
|
|
48
|
+
var ms: float = float(params.get("relay_timeout_ms", fallback * 1000.0))
|
|
49
|
+
return ms / 1000.0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
func _relay(msg_type: String, params: Dictionary, timeout: float) -> Dictionary:
|
|
53
|
+
# Explicit request/response correlation: the debugger plugin keys responses
|
|
54
|
+
# by msg_type alone, so a timed-out call's LATE response (a slow script that
|
|
55
|
+
# finished after we gave up) could otherwise be consumed as the answer to
|
|
56
|
+
# the next call of the same type — wrong result, silently, and every result
|
|
57
|
+
# after it shifted by one. The bridge echoes call_id; the wait loop discards
|
|
58
|
+
# mismatches. A response with no call_id (older addon) is accepted as-is.
|
|
59
|
+
_call_seq += 1
|
|
60
|
+
var call_id := _call_seq
|
|
61
|
+
params = params.duplicate()
|
|
62
|
+
params["call_id"] = call_id
|
|
63
|
+
var response = await _send_and_wait(msg_type, [params], timeout, call_id)
|
|
64
|
+
if response == null:
|
|
65
|
+
return _last_error
|
|
66
|
+
if response is Dictionary:
|
|
67
|
+
response.erase("call_id") # transport detail, not part of the result
|
|
68
|
+
if response.has("error"):
|
|
69
|
+
return _error("EXEC_ERROR", str(response["error"]))
|
|
70
|
+
return _success(response)
|
|
71
|
+
return _success({"data": response})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
func _send_and_wait(msg_type: String, args: Array, timeout: float, call_id: int):
|
|
75
|
+
if not EditorInterface.is_playing_scene():
|
|
76
|
+
_last_error = _error("NOT_RUNNING", "No game is currently running")
|
|
77
|
+
return null
|
|
78
|
+
|
|
79
|
+
var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
|
|
80
|
+
if debugger_plugin == null or not debugger_plugin.has_active_session():
|
|
81
|
+
_last_error = _error("NO_SESSION", "No active debug session")
|
|
82
|
+
return null
|
|
83
|
+
|
|
84
|
+
var sent: bool = debugger_plugin.send_game_message(msg_type, args)
|
|
85
|
+
if not sent:
|
|
86
|
+
_last_error = _error("SEND_FAILED", "Failed to send message to game")
|
|
87
|
+
return null
|
|
88
|
+
|
|
89
|
+
var start_time := Time.get_ticks_msec()
|
|
90
|
+
while true:
|
|
91
|
+
await Engine.get_main_loop().process_frame
|
|
92
|
+
# A hard runtime error (or failed assert) in exec'd code breaks the game
|
|
93
|
+
# into the editor debugger, suspending the bridge handler mid-call —
|
|
94
|
+
# left alone, the game sits paused and this relay times out.
|
|
95
|
+
# Auto-continue: the handler resumes, the error lands in its logger
|
|
96
|
+
# window, and the response arrives with runtime_errors as designed.
|
|
97
|
+
# Honest limit: the break REASON is not queryable, so this resumes ANY
|
|
98
|
+
# break in the window — including a developer's own breakpoint hit by
|
|
99
|
+
# unrelated game code while an exec call is in flight. Accepted for the
|
|
100
|
+
# dev-tooling threat model and surfaced in the tool description.
|
|
101
|
+
if debugger_plugin.is_session_breaked():
|
|
102
|
+
debugger_plugin.continue_session()
|
|
103
|
+
if debugger_plugin.has_response(msg_type):
|
|
104
|
+
var response = debugger_plugin.get_response(msg_type)
|
|
105
|
+
debugger_plugin.clear_response(msg_type)
|
|
106
|
+
if response is Dictionary and response.has("call_id") \
|
|
107
|
+
and int(response["call_id"]) != call_id:
|
|
108
|
+
continue # a previous call's late response — discard, keep waiting for ours
|
|
109
|
+
return response
|
|
110
|
+
if (Time.get_ticks_msec() - start_time) / 1000.0 > timeout:
|
|
111
|
+
debugger_plugin.clear_response(msg_type)
|
|
112
|
+
var hint := ""
|
|
113
|
+
if debugger_plugin.is_session_breaked():
|
|
114
|
+
hint = " (the game is paused in the editor debugger and did not resume; press Continue in the editor or run godot_editor stop)"
|
|
115
|
+
_last_error = _error("TIMEOUT", "Timed out waiting for %s response%s" % [msg_type, hint])
|
|
116
|
+
return null
|
|
117
|
+
return null # unreachable; satisfies the parser
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://clbw4kvqpf2py
|
|
@@ -101,7 +101,16 @@ func _get_editor_input_map() -> Dictionary:
|
|
|
101
101
|
"name": action_name,
|
|
102
102
|
"events": event_strings,
|
|
103
103
|
})
|
|
104
|
-
|
|
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)
|
|
105
114
|
|
|
106
115
|
|
|
107
116
|
func _event_to_string(event: InputEvent) -> String:
|
|
@@ -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"),
|
|
@@ -326,3 +326,27 @@ func toggle_frame_profiler(enable: bool) -> void:
|
|
|
326
326
|
var session := get_session(_active_session_id)
|
|
327
327
|
if session:
|
|
328
328
|
session.toggle_profiler("mcp_frame_profiler", enable)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# True when the debugger has paused the running game (script error, failed
|
|
332
|
+
# assert, or breakpoint). A break suspends whatever bridge handler was running
|
|
333
|
+
# mid-call, so relays that would otherwise time out can detect and recover.
|
|
334
|
+
func is_session_breaked() -> bool:
|
|
335
|
+
if _active_session_id < 0:
|
|
336
|
+
return false
|
|
337
|
+
var session := get_session(_active_session_id)
|
|
338
|
+
return session != null and session.is_breaked()
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# Resume a debugger-paused game. EditorDebuggerSession exposes no continue API,
|
|
342
|
+
# but the raw "continue" command is the same wire message the editor's Continue
|
|
343
|
+
# button sends (ScriptEditorDebugger::_put_msg), and the game's RemoteDebugger
|
|
344
|
+
# handles it in its break loop. Returns false when there is nothing to resume.
|
|
345
|
+
func continue_session() -> bool:
|
|
346
|
+
if _active_session_id < 0:
|
|
347
|
+
return false
|
|
348
|
+
var session := get_session(_active_session_id)
|
|
349
|
+
if session == null or not session.is_breaked():
|
|
350
|
+
return false
|
|
351
|
+
session.send_message("continue", [])
|
|
352
|
+
return true
|
package/addon/core/mcp_utils.gd
CHANGED
|
@@ -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
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
extends RefCounted
|
|
2
|
+
class_name MCPExecGuard
|
|
3
|
+
|
|
4
|
+
## Pure helpers behind godot_exec (#243): the static denylist scan and the
|
|
5
|
+
## function-body wrapper that turn agent-provided GDScript into something the
|
|
6
|
+
## game bridge can compile and run. No engine-state access — everything here is
|
|
7
|
+
## headless-testable (test/exec_guard_headless_test.gd).
|
|
8
|
+
##
|
|
9
|
+
## The scan is an ACCIDENT GUARD, not a security boundary: GDScript cannot be
|
|
10
|
+
## sandboxed, and a determined author can trivially evade a token scan (string-
|
|
11
|
+
## built calls, indirection). Its job is to stop a well-meaning agent from
|
|
12
|
+
## casually reaching outside the game process (spawning processes, writing
|
|
13
|
+
## files, persisting project damage) — the threat model of local dev tooling
|
|
14
|
+
## where the agent already has shell access anyway.
|
|
15
|
+
|
|
16
|
+
# Tokens that reach outside the game process or persist damage to disk.
|
|
17
|
+
# Granularity rationale:
|
|
18
|
+
# - OS.* spawn/kill/shell methods individually (the rest of OS is harmless and
|
|
19
|
+
# frequently legitimate: get_ticks, environment reads, ...).
|
|
20
|
+
# - DirAccess whole-class: every directory mutation lives there; losing
|
|
21
|
+
# read-only listing is acceptable collateral.
|
|
22
|
+
# - FileAccess at the WRITE-constant level: any direct write-mode open must
|
|
23
|
+
# name one of these constants, while read access (loading a save file to
|
|
24
|
+
# inspect it) stays available — which is why FileAccess is not class-blocked.
|
|
25
|
+
# - ResourceSaver whole-class: writes res:// and user:// resources.
|
|
26
|
+
# - EditorInterface: belt-and-suspenders; not normally reachable from a game.
|
|
27
|
+
const DENYLIST: Array[String] = [
|
|
28
|
+
"OS.execute",
|
|
29
|
+
"OS.execute_with_pipe",
|
|
30
|
+
"OS.create_process",
|
|
31
|
+
"OS.create_instance",
|
|
32
|
+
"OS.kill",
|
|
33
|
+
"OS.shell_open",
|
|
34
|
+
"OS.shell_show_in_file_manager",
|
|
35
|
+
"OS.move_to_trash",
|
|
36
|
+
"DirAccess",
|
|
37
|
+
"FileAccess.WRITE",
|
|
38
|
+
"FileAccess.READ_WRITE",
|
|
39
|
+
"FileAccess.WRITE_READ",
|
|
40
|
+
"ResourceSaver",
|
|
41
|
+
"ProjectSettings.save",
|
|
42
|
+
"ProjectSettings.save_custom",
|
|
43
|
+
"EditorInterface",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
# Wrapped user code must compile under projects that escalate warnings to
|
|
47
|
+
# errors, and snippets legitimately ignore parameters, shadow autoload names,
|
|
48
|
+
# discard return values, etc. An invalid warning name here is itself a parse
|
|
49
|
+
# error — pinned by the headless compile test on the Godot version in use.
|
|
50
|
+
const WRAPPER_WARNING_IGNORES := "@warning_ignore(\"unused_parameter\", \"shadowed_global_identifier\", \"shadowed_variable\", \"shadowed_variable_base_class\", \"standalone_expression\", \"return_value_discarded\", \"unused_variable\", \"unreachable_code\", \"integer_division\")"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
## Scan agent-provided source BEFORE compiling. Comments and string contents
|
|
54
|
+
## are stripped first, so `print("OS.execute")` passes while `OS.execute(...)`
|
|
55
|
+
## is caught. Returns {ok: true} or {ok: false, token, message}.
|
|
56
|
+
static func scan_source(source: String) -> Dictionary:
|
|
57
|
+
var lex := _lex(_normalize(source))
|
|
58
|
+
var stripped: String = lex["stripped"]
|
|
59
|
+
if stripped.strip_edges().is_empty():
|
|
60
|
+
return {
|
|
61
|
+
"ok": false,
|
|
62
|
+
"token": "",
|
|
63
|
+
"message": "NO_CODE: exec source contains no executable code (comments only or empty)",
|
|
64
|
+
}
|
|
65
|
+
# Normalize the SCAN copy (never the wrapper input) so the two formatter-
|
|
66
|
+
# plausible token splits — `OS . execute` and a line continuation after the
|
|
67
|
+
# dot — still match. Over-matching here only over-blocks, which is the safe
|
|
68
|
+
# direction for an accident guard.
|
|
69
|
+
stripped = stripped.replace("\\\n", "")
|
|
70
|
+
var dot_ws := RegEx.new()
|
|
71
|
+
dot_ws.compile("\\s*\\.\\s*")
|
|
72
|
+
stripped = dot_ws.sub(stripped, ".", true)
|
|
73
|
+
if _token_re("await").search(stripped) != null:
|
|
74
|
+
return {
|
|
75
|
+
"ok": false,
|
|
76
|
+
"token": "await",
|
|
77
|
+
"message": "SYNC_ONLY: exec source is synchronous-only; 'await' is not allowed. " +
|
|
78
|
+
"For waiting on game state, compose with godot_game_time step/step_until; " +
|
|
79
|
+
"for sustained behavior, attach a node under `holder`.",
|
|
80
|
+
}
|
|
81
|
+
for token in DENYLIST:
|
|
82
|
+
if _token_re(token).search(stripped) != null:
|
|
83
|
+
return {
|
|
84
|
+
"ok": false,
|
|
85
|
+
"token": token,
|
|
86
|
+
"message": "DENIED_TOKEN: source contains '%s' — blocked by the exec denylist " % token +
|
|
87
|
+
"(an accident guard against process/file-write escape, not a security boundary). " +
|
|
88
|
+
"Exec is for mutating the running game's state, not the system around it.",
|
|
89
|
+
}
|
|
90
|
+
return {"ok": true}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
## Wrap user source as the body of `_mcp_run(<binding_names>)` on a RefCounted,
|
|
94
|
+
## so bindings are bare names (`G.wave = 5` just works) and multi-statement
|
|
95
|
+
## code with control flow compiles. Lines inside multiline strings are left
|
|
96
|
+
## untouched (prefixing them would corrupt string content); everything else is
|
|
97
|
+
## prefixed with one indent unit matched to the user's own indent character, so
|
|
98
|
+
## the wrapper can never introduce a mixed-tabs-and-spaces parse error.
|
|
99
|
+
static func build_wrapper(source: String, binding_names: PackedStringArray) -> String:
|
|
100
|
+
var normalized := _normalize(source)
|
|
101
|
+
var lex := _lex(normalized)
|
|
102
|
+
var in_string_lines: Array = lex["in_string_lines"]
|
|
103
|
+
var lines := normalized.split("\n")
|
|
104
|
+
var indent := _detect_indent_unit(lines, in_string_lines, lex["in_bracket_lines"])
|
|
105
|
+
var body: Array = []
|
|
106
|
+
for i in lines.size():
|
|
107
|
+
var line: String = lines[i]
|
|
108
|
+
var starts_in_string: bool = in_string_lines[i] if i < in_string_lines.size() else false
|
|
109
|
+
if starts_in_string or line.strip_edges().is_empty():
|
|
110
|
+
body.append(line)
|
|
111
|
+
else:
|
|
112
|
+
body.append(indent + line)
|
|
113
|
+
return "extends RefCounted\n\n%s\nfunc _mcp_run(%s):\n%s\n" % [
|
|
114
|
+
WRAPPER_WARNING_IGNORES,
|
|
115
|
+
", ".join(binding_names),
|
|
116
|
+
"\n".join(body),
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
static func _normalize(source: String) -> String:
|
|
121
|
+
return source.replace("\r\n", "\n").replace("\r", "\n")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
static func _token_re(token: String) -> RegEx:
|
|
125
|
+
# Word-boundary match so `OS.execute` does not hit `MyOS.executed` — and,
|
|
126
|
+
# because `_` is a word character, `OS.execute` does NOT match
|
|
127
|
+
# `OS.execute_with_pipe` (which has its own entry).
|
|
128
|
+
var re := RegEx.new()
|
|
129
|
+
re.compile("\\b%s\\b" % token.replace(".", "\\."))
|
|
130
|
+
return re
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
## One lexical pass tracking string/comment/bracket state. Returns:
|
|
134
|
+
## stripped: source with comments removed and string CONTENTS removed
|
|
135
|
+
## (quotes kept), line structure preserved — the scan target
|
|
136
|
+
## in_string_lines: per line, whether it STARTS inside a (triple-quoted)
|
|
137
|
+
## string — those lines must not be indent-prefixed
|
|
138
|
+
## in_bracket_lines: per line, whether it STARTS inside an open ()/[]/{} —
|
|
139
|
+
## bracket interiors have free-form indentation, so these
|
|
140
|
+
## lines must not drive indent-unit detection
|
|
141
|
+
## Known accepted miss: raw strings (r"...") treat backslash as an escape here
|
|
142
|
+
## though GDScript does not — the failure mode is over-blocking or an honest
|
|
143
|
+
## compile error, never silent corruption.
|
|
144
|
+
static func _lex(source: String) -> Dictionary:
|
|
145
|
+
var stripped := ""
|
|
146
|
+
var in_string_lines: Array = [false]
|
|
147
|
+
var in_bracket_lines: Array = [false]
|
|
148
|
+
var quote := "" # active quote: '"', "'", '"""', or "'''"
|
|
149
|
+
var depth := 0 # open-bracket depth, tracked outside strings/comments
|
|
150
|
+
var i := 0
|
|
151
|
+
var n := source.length()
|
|
152
|
+
while i < n:
|
|
153
|
+
var c := source[i]
|
|
154
|
+
if quote != "":
|
|
155
|
+
if c == "\n":
|
|
156
|
+
stripped += "\n"
|
|
157
|
+
in_string_lines.append(true)
|
|
158
|
+
in_bracket_lines.append(depth > 0)
|
|
159
|
+
i += 1
|
|
160
|
+
elif c == "\\":
|
|
161
|
+
# An escaped char never ends the string — but an escaped
|
|
162
|
+
# NEWLINE (a line continuation inside the string) still starts
|
|
163
|
+
# a new physical line. Record it, or every following line would
|
|
164
|
+
# be mis-indexed and build_wrapper would prefix indent into the
|
|
165
|
+
# string's runtime value: silent corruption.
|
|
166
|
+
if i + 1 < n and source[i + 1] == "\n":
|
|
167
|
+
stripped += "\n"
|
|
168
|
+
in_string_lines.append(true)
|
|
169
|
+
in_bracket_lines.append(depth > 0)
|
|
170
|
+
i += 2
|
|
171
|
+
elif source.substr(i, quote.length()) == quote:
|
|
172
|
+
stripped += quote
|
|
173
|
+
i += quote.length()
|
|
174
|
+
quote = ""
|
|
175
|
+
else:
|
|
176
|
+
i += 1
|
|
177
|
+
continue
|
|
178
|
+
if c == "#":
|
|
179
|
+
while i < n and source[i] != "\n":
|
|
180
|
+
i += 1
|
|
181
|
+
continue
|
|
182
|
+
if c == "\"" or c == "'":
|
|
183
|
+
quote = c.repeat(3) if source.substr(i, 3) == c.repeat(3) else c
|
|
184
|
+
stripped += quote
|
|
185
|
+
i += quote.length()
|
|
186
|
+
continue
|
|
187
|
+
if c == "(" or c == "[" or c == "{":
|
|
188
|
+
depth += 1
|
|
189
|
+
elif c == ")" or c == "]" or c == "}":
|
|
190
|
+
depth = maxi(0, depth - 1)
|
|
191
|
+
if c == "\n":
|
|
192
|
+
stripped += "\n"
|
|
193
|
+
in_string_lines.append(false)
|
|
194
|
+
in_bracket_lines.append(depth > 0)
|
|
195
|
+
i += 1
|
|
196
|
+
continue
|
|
197
|
+
stripped += c
|
|
198
|
+
i += 1
|
|
199
|
+
return {
|
|
200
|
+
"stripped": stripped,
|
|
201
|
+
"in_string_lines": in_string_lines,
|
|
202
|
+
"in_bracket_lines": in_bracket_lines,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
static func _detect_indent_unit(lines: PackedStringArray, in_string_lines: Array, in_bracket_lines: Array) -> String:
|
|
207
|
+
# Match the user's indent character: GDScript rejects mixed tabs/spaces in
|
|
208
|
+
# one indentation run, so the wrapper prefix must agree with the body. Four
|
|
209
|
+
# spaces composes with any space width (nesting only has to increase).
|
|
210
|
+
# Bracket-continuation lines are skipped: their indentation is free-form
|
|
211
|
+
# (a space-aligned array literal in otherwise tab-indented code is legal),
|
|
212
|
+
# so they must not decide the prefix for real block lines.
|
|
213
|
+
for i in lines.size():
|
|
214
|
+
if i < in_string_lines.size() and in_string_lines[i]:
|
|
215
|
+
continue
|
|
216
|
+
if i < in_bracket_lines.size() and in_bracket_lines[i]:
|
|
217
|
+
continue
|
|
218
|
+
var line := lines[i]
|
|
219
|
+
if line.strip_edges().is_empty():
|
|
220
|
+
continue
|
|
221
|
+
if line.begins_with("\t"):
|
|
222
|
+
return "\t"
|
|
223
|
+
if line.begins_with(" "):
|
|
224
|
+
return " "
|
|
225
|
+
return "\t"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://cdscqjpsiv412
|