@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.
Files changed (53) hide show
  1. package/addon/command_router.gd +1 -0
  2. package/addon/commands/debug_commands.gd +9 -0
  3. package/addon/commands/exec_commands.gd +117 -0
  4. package/addon/commands/exec_commands.gd.uid +1 -0
  5. package/addon/commands/input_commands.gd +10 -1
  6. package/addon/commands/project_commands.gd +9 -1
  7. package/addon/core/mcp_debugger_plugin.gd +24 -0
  8. package/addon/core/mcp_utils.gd +171 -0
  9. package/addon/game_bridge/mcp_exec_guard.gd +225 -0
  10. package/addon/game_bridge/mcp_exec_guard.gd.uid +1 -0
  11. package/addon/game_bridge/mcp_game_bridge.gd +233 -1
  12. package/addon/plugin.cfg +1 -1
  13. package/dist/__tests__/connection/timeouts.test.js +4 -1
  14. package/dist/__tests__/connection/timeouts.test.js.map +1 -1
  15. package/dist/__tests__/tools/editor.test.js +36 -0
  16. package/dist/__tests__/tools/editor.test.js.map +1 -1
  17. package/dist/__tests__/tools/exec.test.d.ts +2 -0
  18. package/dist/__tests__/tools/exec.test.d.ts.map +1 -0
  19. package/dist/__tests__/tools/exec.test.js +124 -0
  20. package/dist/__tests__/tools/exec.test.js.map +1 -0
  21. package/dist/__tests__/tools/input.test.js +33 -0
  22. package/dist/__tests__/tools/input.test.js.map +1 -1
  23. package/dist/__tests__/tools/project.test.d.ts +2 -0
  24. package/dist/__tests__/tools/project.test.d.ts.map +1 -0
  25. package/dist/__tests__/tools/project.test.js +53 -0
  26. package/dist/__tests__/tools/project.test.js.map +1 -0
  27. package/dist/connection/timeouts.d.ts +1 -0
  28. package/dist/connection/timeouts.d.ts.map +1 -1
  29. package/dist/connection/timeouts.js +1 -0
  30. package/dist/connection/timeouts.js.map +1 -1
  31. package/dist/tools/editor.d.ts.map +1 -1
  32. package/dist/tools/editor.js +8 -2
  33. package/dist/tools/editor.js.map +1 -1
  34. package/dist/tools/exec.d.ts +16 -0
  35. package/dist/tools/exec.d.ts.map +1 -0
  36. package/dist/tools/exec.js +102 -0
  37. package/dist/tools/exec.js.map +1 -0
  38. package/dist/tools/index.d.ts +1 -0
  39. package/dist/tools/index.d.ts.map +1 -1
  40. package/dist/tools/index.js +3 -0
  41. package/dist/tools/index.js.map +1 -1
  42. package/dist/tools/input.d.ts.map +1 -1
  43. package/dist/tools/input.js +6 -1
  44. package/dist/tools/input.js.map +1 -1
  45. package/dist/tools/project-staleness.d.ts +15 -0
  46. package/dist/tools/project-staleness.d.ts.map +1 -0
  47. package/dist/tools/project-staleness.js +16 -0
  48. package/dist/tools/project-staleness.js.map +1 -0
  49. package/dist/tools/project.d.ts +2 -0
  50. package/dist/tools/project.d.ts.map +1 -1
  51. package/dist/tools/project.js +14 -0
  52. package/dist/tools/project.js.map +1 -1
  53. package/package.json +1 -1
@@ -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
- return _success({"actions": actions, "source": "editor"})
104
+ # This map is read from the editor's in-memory InputMap, which is loaded at
105
+ # startup and goes stale if project.godot's [input] section is edited on disk
106
+ # (#245). Flag that so the caller knows the map may be incomplete and can
107
+ # recover with `godot_editor restart`. The game-running path above reads fresh
108
+ # from the bridge, so it never carries this.
109
+ var result := {"actions": actions, "source": "editor"}
110
+ var staleness := MCPUtils.detect_project_staleness()
111
+ if staleness.get("stale", false):
112
+ result["staleness"] = staleness
113
+ return _success(result)
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
@@ -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