breakpoint-mcp 1.28.0 → 1.72.7

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 (45) hide show
  1. package/README.md +1 -1
  2. package/addon/breakpoint_mcp/operations.gd +248 -13
  3. package/addon/breakpoint_mcp/plugin.cfg +1 -1
  4. package/addon/breakpoint_mcp/runtime_bridge.gd +54 -1
  5. package/dist/annotations.js +3 -3
  6. package/dist/cli/doctor.js +1 -1
  7. package/dist/cli/init.js +1 -1
  8. package/dist/cli/tools.js +2 -2
  9. package/dist/csdap.js +88 -6
  10. package/dist/dap.js +172 -10
  11. package/dist/index.js +4 -4
  12. package/dist/path-cohort.js +177 -0
  13. package/dist/paths.js +111 -0
  14. package/dist/recipes.js +1 -1
  15. package/dist/schemas.js +49 -5
  16. package/dist/tools/assetgen.js +17 -1
  17. package/dist/tools/backend.js +18 -1
  18. package/dist/tools/cli.js +41 -4
  19. package/dist/tools/csdap.js +132 -8
  20. package/dist/tools/cslsp.js +110 -5
  21. package/dist/tools/dap.js +316 -59
  22. package/dist/tools/editor/audio.js +16 -2
  23. package/dist/tools/editor/common.js +39 -1
  24. package/dist/tools/editor/filesystem.js +23 -3
  25. package/dist/tools/editor/introspection.js +72 -1
  26. package/dist/tools/editor/node.js +7 -2
  27. package/dist/tools/editor/particles.js +7 -2
  28. package/dist/tools/editor/project_input_test.js +16 -2
  29. package/dist/tools/editor/resource.js +54 -7
  30. package/dist/tools/editor/scene.js +31 -4
  31. package/dist/tools/editor/shader.js +19 -3
  32. package/dist/tools/editor/spatial.js +16 -1
  33. package/dist/tools/editor/tiles.js +16 -1
  34. package/dist/tools/editor/ui.js +29 -2
  35. package/dist/tools/editor.js +20 -13
  36. package/dist/tools/knowledge.js +14 -1
  37. package/dist/tools/lsp-common.js +29 -0
  38. package/dist/tools/lsp.js +87 -8
  39. package/dist/tools/netcode.js +47 -4
  40. package/dist/tools/processes.js +14 -2
  41. package/dist/tools/runtime.js +49 -9
  42. package/dist/tools/tabletop.js +173 -7
  43. package/dist/tools/vcs.js +138 -22
  44. package/dist/toolsets.js +2 -2
  45. package/package.json +8 -2
@@ -1,12 +1,19 @@
1
1
  import { z } from "zod";
2
2
  import { gate } from "../../confirm.js";
3
3
  /** Project FileSystem dock ops: list / scan / move / mkdir. */
4
- export function registerFilesystemTools(server, call) {
4
+ export function registerFilesystemTools(server, call, guard) {
5
5
  server.registerTool("filesystem_list", {
6
6
  title: "List project directory",
7
7
  description: "List the subdirectories and files of a directory in the project filesystem (hidden entries like .godot are skipped). Read-only.",
8
8
  inputSchema: { path: z.string().optional().describe("Directory res:// path (default res://)") },
9
- }, async ({ path }) => call("filesystem.list", path !== undefined ? { path } : {}));
9
+ }, async ({ path }) => {
10
+ // 🔴 MEASURED: this LISTED the contents of a directory outside the project root and
11
+ // named the files it found there. An omitted `path` means res:// and stays legal.
12
+ const escaped = guard(path, "path");
13
+ if (escaped)
14
+ return escaped;
15
+ return call("filesystem.list", path !== undefined ? { path } : {});
16
+ });
10
17
  server.registerTool("filesystem_scan", {
11
18
  title: "Rescan filesystem",
12
19
  description: "Trigger an editor rescan of the project filesystem so newly added or externally-changed files are picked up. Read-only side effect.",
@@ -21,6 +28,13 @@ export function registerFilesystemTools(server, call) {
21
28
  confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
22
29
  },
23
30
  }, async ({ from_path, to_path, confirm }) => {
31
+ // 🔴 THE SHARPEST ROW OF THE MEASUREMENT. This is the only writer in the set
32
+ // that is destructive on BOTH sides: `res://../…` as `to_path` moved a project
33
+ // file OUT of the project, and as `from_path` it moved a file the project never
34
+ // owned IN. Both parameters, therefore, and neither is theoretical.
35
+ const escaped = guard(from_path, "from_path") ?? guard(to_path, "to_path");
36
+ if (escaped)
37
+ return escaped;
24
38
  const blocked = await gate(server, confirm, `Move ${from_path} to ${to_path}`);
25
39
  if (blocked)
26
40
  return blocked;
@@ -30,6 +44,12 @@ export function registerFilesystemTools(server, call) {
30
44
  title: "Create directory",
31
45
  description: "Create a directory (recursively) in the project filesystem, then rescan. No-op if it already exists.",
32
46
  inputSchema: { path: z.string().describe("Directory res:// path to create") },
33
- }, async ({ path }) => call("filesystem.create_dir", { path }));
47
+ }, async ({ path }) => {
48
+ // 🔴 MEASURED: this CREATED directories outside the project root via `res://../`.
49
+ const escaped = guard(path, "path");
50
+ if (escaped)
51
+ return escaped;
52
+ return call("filesystem.create_dir", { path });
53
+ });
34
54
  }
35
55
  //# sourceMappingURL=filesystem.js.map
@@ -1,5 +1,33 @@
1
1
  import { z } from "zod";
2
2
  import { fail } from "./common.js";
3
+ /**
4
+ * Smallest edge, in pixels, a captured editor viewport may have and still be a
5
+ * frame rather than a placeholder.
6
+ *
7
+ * Godot does not tear down the main-screen tab you are not looking at — it
8
+ * collapses that tab's SubViewport to its minimum size and keeps rendering it.
9
+ * `get_texture().get_image()` therefore still succeeds, and the capture returns
10
+ * a real, valid, **2x2** PNG: correct magic bytes, correct mime, an 81-byte
11
+ * payload, and a note reading "Captured 2d viewport (2x2)". Every layer above
12
+ * reports success, and an assistant then *looks at* four pixels and reasons
13
+ * about the scene from them.
14
+ *
15
+ * Measured on Godot 4.7 (session 144), four editor boots, real Metal hardware:
16
+ * with the Script or 3D tab active, `viewport=2d` returned 2x2/81B; with the 2D
17
+ * tab active it returned 1297x492/3.6KB. A fresh editor with no saved layout —
18
+ * the CI condition — boots on the **3D** tab, and opening a `Node2D` scene does
19
+ * not switch it. So the degenerate case is the DEFAULT, not the exception.
20
+ *
21
+ * 8px is deliberately far below any real viewport and far above the collapsed
22
+ * one: it separates the two populations without guessing at a "reasonable" size.
23
+ *
24
+ * Since 1.30.0 the refusal is RECOVERABLE rather than terminal: `main_screen_set`
25
+ * switches the tab, so the error names the tab that is actually active and the
26
+ * call that fixes it, and a caller can act on that instead of giving up. The
27
+ * guard itself is unchanged — this threshold still decides what counts as a
28
+ * frame; the tab tools only give the caller somewhere to go when it bites.
29
+ */
30
+ const MIN_RENDERED_VIEWPORT_PX = 8;
3
31
  /** Editor selection, ClassDB / docs lookups, and viewport screenshot. */
4
32
  export function registerIntrospectionTools(server, call, bridge) {
5
33
  server.registerTool("selection_get", { title: "Get selection", description: "Return the paths of the nodes currently selected in the editor.", inputSchema: {} }, async () => call("selection.get"));
@@ -8,6 +36,23 @@ export function registerIntrospectionTools(server, call, bridge) {
8
36
  description: "Replace the editor's node selection with the given node paths.",
9
37
  inputSchema: { paths: z.array(z.string()).describe("Node paths relative to the scene root") },
10
38
  }, async ({ paths }) => call("selection.set", { paths }));
39
+ server.registerTool("main_screen_get", {
40
+ title: "Get the active main-screen tab",
41
+ description: "Report which main-screen editor tab is active (2D / 3D / Script / …) and which are available. " +
42
+ "Read-only. The active tab decides which viewport screenshot_editor can actually capture: Godot " +
43
+ "collapses the inactive tab's viewport to a few pixels.",
44
+ inputSchema: {},
45
+ }, async () => call("main_screen.get"));
46
+ server.registerTool("main_screen_set", {
47
+ title: "Switch the main-screen tab",
48
+ description: "Switch the editor's main-screen tab (2D / 3D / Script / …), matching the name case-insensitively. " +
49
+ "Use this before screenshot_editor to make the viewport you want to capture the active one — opening " +
50
+ "a scene does NOT switch the tab. Returns the resulting state, read back from the editor rather than " +
51
+ "echoed. An unknown name comes back with the live list of available tabs.",
52
+ inputSchema: {
53
+ name: z.string().describe('Tab name as the editor reports it, case-insensitive — e.g. "2D", "3D", "Script"'),
54
+ },
55
+ }, async ({ name }) => call("main_screen.set", { name }));
11
56
  server.registerTool("classdb_get_class", {
12
57
  title: "Introspect class",
13
58
  description: "Return the parent class, methods, properties, and signals of an engine class via ClassDB.",
@@ -53,11 +98,37 @@ export function registerIntrospectionTools(server, call, bridge) {
53
98
  server.registerTool("screenshot_editor", {
54
99
  title: "Screenshot editor viewport",
55
100
  description: "Capture the 2D or 3D editor viewport as a PNG and return it as image content so the assistant can see the scene. " +
56
- "Requires the matching editor tab (2D/3D) to be active and rendered.",
101
+ "Requires the matching editor tab (2D/3D) to be active: Godot collapses the inactive tab's viewport to a few " +
102
+ "pixels, and this tool returns a viewport_not_rendered error rather than that placeholder frame. " +
103
+ "A fresh editor is on the 3D tab and opening a scene does NOT switch it, so to capture 2d reliably call " +
104
+ 'main_screen_set {"name":"2D"} first — or main_screen_get to see which tab is active.',
57
105
  inputSchema: { viewport: z.enum(["2d", "3d"]).optional().describe("Which viewport (default 3d)") },
58
106
  }, async ({ viewport }) => {
59
107
  try {
60
108
  const r = (await bridge.request("screenshot.editor_viewport", { viewport: viewport ?? "3d" }));
109
+ if (r.width < MIN_RENDERED_VIEWPORT_PX || r.height < MIN_RENDERED_VIEWPORT_PX) {
110
+ // Name the tab that is ACTUALLY active rather than leaving the caller to infer
111
+ // it from a 2x2. Best-effort: this error must survive an addon too old to answer
112
+ // (main_screen.* is 1.9.3+), so a failure here degrades the message instead of
113
+ // replacing a useful "not rendered" error with an unrelated bridge error.
114
+ let active;
115
+ try {
116
+ const s = (await bridge.request("main_screen.get", {}));
117
+ if (s?.active)
118
+ active = s.active;
119
+ }
120
+ catch { /* older addon, or no main-screen container — fall through */ }
121
+ return fail({
122
+ code: "viewport_not_rendered",
123
+ message: `The ${r.viewport} editor viewport measured ${r.width}x${r.height} — a collapsed viewport, not a rendered ` +
124
+ `frame. Godot keeps the inactive main-screen tab's viewport alive at its minimum size, so the capture ` +
125
+ `"succeeds" and returns a placeholder image. ` +
126
+ (active
127
+ ? `The editor is on the "${active}" tab. Call main_screen_set {"name":"${r.viewport.toUpperCase()}"} `
128
+ : `Switch the editor to the ${r.viewport.toUpperCase()} tab with main_screen_set (opening a scene does NOT switch it) `) +
129
+ `and retry — or capture the viewport whose tab is active.`,
130
+ });
131
+ }
61
132
  return {
62
133
  content: [
63
134
  { type: "image", data: r.base64, mimeType: r.mime },
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { gate } from "../../confirm.js";
3
3
  /** Scene-tree node authoring: add / delete / reparent / property / group ops. */
4
- export function registerNodeTools(server, call) {
4
+ export function registerNodeTools(server, call, guard) {
5
5
  server.registerTool("node_add", {
6
6
  title: "Add node",
7
7
  description: "Instance a node of the given class under a parent path (undoable). Returns the new node's path.",
@@ -116,7 +116,12 @@ export function registerNodeTools(server, call) {
116
116
  scene_path: z.string().describe("Scene to instance, e.g. res://actors/enemy.tscn"),
117
117
  name: z.string().optional().describe("Node name (defaults to the instanced scene's root name)"),
118
118
  },
119
- }, async ({ parent_path, scene_path, name }) => call("node.instantiate_scene", name !== undefined ? { parent_path, scene_path, name } : { parent_path, scene_path }));
119
+ }, async ({ parent_path, scene_path, name }) => {
120
+ const escaped = guard(scene_path, "scene_path");
121
+ if (escaped)
122
+ return escaped;
123
+ return call("node.instantiate_scene", name !== undefined ? { parent_path, scene_path, name } : { parent_path, scene_path });
124
+ });
120
125
  server.registerTool("node_move_child", {
121
126
  title: "Move child",
122
127
  description: "Reorder a node among its siblings by index (undoable). Negative indices count from the end (-1 = last).",
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  /** GPUParticles authoring (process material, amount, lifetime, texture). */
3
- export function registerParticleTools(server, call) {
3
+ export function registerParticleTools(server, call, guard) {
4
4
  server.registerTool("particles_create", {
5
5
  title: "Create particles",
6
6
  description: "Add a GPUParticles2D/3D node under a parent in the edited scene (undoable). Optional initial amount (> 0), lifetime (> 0), emitting. In-scene and undoable — not gated.",
@@ -91,6 +91,11 @@ export function registerParticleTools(server, call) {
91
91
  path: z.string().describe("GPUParticles2D node path relative to the scene root"),
92
92
  texture_path: z.string().describe("res:// path to a Texture2D resource"),
93
93
  },
94
- }, async ({ path, texture_path }) => call("particles.set_texture", { path, texture_path }));
94
+ }, async ({ path, texture_path }) => {
95
+ const escaped = guard(texture_path, "texture_path");
96
+ if (escaped)
97
+ return escaped;
98
+ return call("particles.set_texture", { path, texture_path });
99
+ });
95
100
  }
96
101
  //# sourceMappingURL=particles.js.map
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { gate } from "../../confirm.js";
3
3
  /** InputMap, extended project config (autoloads / export presets / main scene / settings), editor settings, and test-framework detection. */
4
- export function registerProjectInputTestTools(server, call) {
4
+ export function registerProjectInputTestTools(server, call, guard) {
5
5
  server.registerTool("inputmap_add_action", {
6
6
  title: "Add input action",
7
7
  description: "Define a project input action (ProjectSettings input/<name>) with an empty event list and a deadzone. Set save=true to persist to project.godot. DESTRUCTIVE (project-wide input map) — gated by confirmation.",
@@ -82,6 +82,9 @@ export function registerProjectInputTestTools(server, call) {
82
82
  confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
83
83
  },
84
84
  }, async ({ name, path, enabled, save, confirm }) => {
85
+ const escaped = guard(path, "path");
86
+ if (escaped)
87
+ return escaped;
85
88
  const blocked = await gate(server, confirm, `Add autoload "${name}" -> ${path}${save ? " and save project.godot" : ""}`);
86
89
  if (blocked)
87
90
  return blocked;
@@ -139,6 +142,9 @@ export function registerProjectInputTestTools(server, call) {
139
142
  confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
140
143
  },
141
144
  }, async ({ path, save, confirm }) => {
145
+ const escaped = guard(path, "path");
146
+ if (escaped)
147
+ return escaped;
142
148
  const blocked = await gate(server, confirm, `Set main scene to ${path}${save ? " and save project.godot" : ""}`);
143
149
  if (blocked)
144
150
  return blocked;
@@ -182,6 +188,14 @@ export function registerProjectInputTestTools(server, call) {
182
188
  inputSchema: {
183
189
  dir: z.string().optional().describe("Directory to search (default res://test)"),
184
190
  },
185
- }, async ({ dir }) => call("test.list", dir !== undefined ? { dir } : {}));
191
+ },
192
+ // 🔴 MEASURED: `res://../example_evil` returned that directory's `test_*.gd`
193
+ // files in the reply. A reader's leak is the LISTING, not a file it wrote.
194
+ async ({ dir }) => {
195
+ const escaped = guard(dir, "dir");
196
+ if (escaped)
197
+ return escaped;
198
+ return call("test.list", dir !== undefined ? { dir } : {});
199
+ });
186
200
  }
187
201
  //# sourceMappingURL=project_input_test.js.map
@@ -1,7 +1,13 @@
1
1
  import { z } from "zod";
2
2
  import { gate } from "../../confirm.js";
3
- /** Resource (.tres/.res) create / load / save / property / import ops. */
4
- export function registerResourceTools(server, call) {
3
+ /**
4
+ * Resource (.tres/.res) create / load / save / property / import ops.
5
+ *
6
+ * 🔴 THE GUARD RUNS BEFORE `gate()`, deliberately (163 §3's shape): a call that can
7
+ * never legally write must not first ask the user to approve writing. It also means
8
+ * the refusal needs no editor at all, which is what makes the unit tests cheap.
9
+ */
10
+ export function registerResourceTools(server, call, guard) {
5
11
  server.registerTool("resource_create", {
6
12
  title: "Create resource",
7
13
  description: "Instantiate a Resource subclass and save it as a new file. DESTRUCTIVE (writes a file) — gated by confirmation. Optional initial properties use the tagged-Variant convention.",
@@ -12,6 +18,9 @@ export function registerResourceTools(server, call) {
12
18
  confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
13
19
  },
14
20
  }, async ({ class_name, to_path, properties, confirm }) => {
21
+ const escaped = guard(to_path, "to_path");
22
+ if (escaped)
23
+ return escaped;
15
24
  const blocked = await gate(server, confirm, `Create ${class_name} resource at ${to_path}`);
16
25
  if (blocked)
17
26
  return blocked;
@@ -21,7 +30,12 @@ export function registerResourceTools(server, call) {
21
30
  title: "Load resource",
22
31
  description: "Load a resource file and return its class, resource_name, and inspector-visible property list. Read-only.",
23
32
  inputSchema: { path: z.string().describe("Resource res:// path") },
24
- }, async ({ path }) => call("resource.load", { path }));
33
+ }, async ({ path }) => {
34
+ const escaped = guard(path, "path");
35
+ if (escaped)
36
+ return escaped;
37
+ return call("resource.load", { path });
38
+ });
25
39
  server.registerTool("resource_save", {
26
40
  title: "Save resource",
27
41
  description: "Load a resource and (re-)save it, optionally to a new path and with ResourceSaver flags. DESTRUCTIVE (writes a file) — gated by confirmation. Shares subresources by reference; use resource_duplicate for an independent copy.",
@@ -32,6 +46,13 @@ export function registerResourceTools(server, call) {
32
46
  confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
33
47
  },
34
48
  }, async ({ from_path, to_path, flags, confirm }) => {
49
+ // 🔴 BOTH parameters. Measured: `from_path: res://../…` LOADED a resource from
50
+ // outside the root and saved a copy inside it. Guarding only the destination
51
+ // would leave this tool half-wired — the failure mode 161, 162 and 163 each
52
+ // re-learned on a different plane.
53
+ const escaped = guard(from_path, "from_path") ?? guard(to_path, "to_path");
54
+ if (escaped)
55
+ return escaped;
35
56
  const blocked = await gate(server, confirm, `Save resource ${from_path}${to_path ? ` to ${to_path}` : ""}`);
36
57
  if (blocked)
37
58
  return blocked;
@@ -52,6 +73,9 @@ export function registerResourceTools(server, call) {
52
73
  confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
53
74
  },
54
75
  }, async ({ path, to_path, deep, confirm }) => {
76
+ const escaped = guard(path, "path") ?? guard(to_path, "to_path");
77
+ if (escaped)
78
+ return escaped;
55
79
  const blocked = await gate(server, confirm, `Duplicate resource ${path} to ${to_path}`);
56
80
  if (blocked)
57
81
  return blocked;
@@ -64,7 +88,12 @@ export function registerResourceTools(server, call) {
64
88
  path: z.string().describe("Resource res:// path"),
65
89
  property: z.string().describe("Property name"),
66
90
  },
67
- }, async ({ path, property }) => call("resource.get_property", { path, property }));
91
+ }, async ({ path, property }) => {
92
+ const escaped = guard(path, "path");
93
+ if (escaped)
94
+ return escaped;
95
+ return call("resource.get_property", { path, property });
96
+ });
68
97
  server.registerTool("resource_set_property", {
69
98
  title: "Set resource property",
70
99
  description: "Set a single property on a resource file and save it. DESTRUCTIVE (writes a file) — gated by confirmation. The value uses the tagged-Variant convention.",
@@ -75,6 +104,12 @@ export function registerResourceTools(server, call) {
75
104
  confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
76
105
  },
77
106
  }, async ({ path, property, value, confirm }) => {
107
+ // 🔴 MEASURED AGAINST A LIVE 4.7 EDITOR: `res://../example_evil/x.tres` had its
108
+ // BYTES REWRITTEN outside the project root, and the reply reported `ok`. The
109
+ // verdict came from the file's hash, not the reply.
110
+ const escaped = guard(path, "path");
111
+ if (escaped)
112
+ return escaped;
78
113
  const blocked = await gate(server, confirm, `Set ${property} on ${path}`);
79
114
  if (blocked)
80
115
  return blocked;
@@ -82,12 +117,17 @@ export function registerResourceTools(server, call) {
82
117
  });
83
118
  server.registerTool("resource_get_import_settings", {
84
119
  title: "Get import settings",
85
- description: "Read an asset's import metadata (.import): the importer and its parameters. Read-only. Returns imported=false when the asset has no .import sidecar.",
120
+ description: "Read an asset's import metadata (.import): the importer and its parameters. Read-only. Returns imported=false when the file exists but has no .import sidecar; errors not_found when the path is not a file (matching resource_load).",
86
121
  inputSchema: { path: z.string().describe("Asset res:// path (e.g. res://icon.png)") },
87
- }, async ({ path }) => call("resource.get_import_settings", { path }));
122
+ }, async ({ path }) => {
123
+ const escaped = guard(path, "path");
124
+ if (escaped)
125
+ return escaped;
126
+ return call("resource.get_import_settings", { path });
127
+ });
88
128
  server.registerTool("resource_set_import_settings", {
89
129
  title: "Set import settings",
90
- description: "Update import parameters in an asset's .import metadata and trigger a reimport. DESTRUCTIVE (rewrites metadata + reimports) — gated by confirmation. Errors if the asset has no .import sidecar.",
130
+ description: "Update import parameters in an asset's .import metadata and trigger a reimport. DESTRUCTIVE (rewrites metadata + reimports) — gated by confirmation. Errors not_found when the path is not a file, not_imported when the file has no .import sidecar. `settings` echoes every key set; `changed` lists only those whose stored value actually moved, so a no-op is distinguishable from a real edit.",
91
131
  inputSchema: {
92
132
  path: z.string().describe("Asset res:// path"),
93
133
  settings: z.record(z.any()).describe("Import params to set (name -> JSON scalar or __type__-tagged Variant)"),
@@ -95,6 +135,13 @@ export function registerResourceTools(server, call) {
95
135
  confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
96
136
  },
97
137
  }, async ({ path, settings, reimport, confirm }) => {
138
+ // 🔴 MEASURED: `res://../example_evil/g166_icon.png` had its `.import` SIDECAR
139
+ // REWRITTEN outside the project root — verdict from the sidecar's hash. The other
140
+ // two spellings answered `reimported: true` while changing nothing out there,
141
+ // which is a separate reporting problem and is NOT what this guard is for.
142
+ const escaped = guard(path, "path");
143
+ if (escaped)
144
+ return escaped;
98
145
  const blocked = await gate(server, confirm, `Set import settings on ${path} and reimport`);
99
146
  if (blocked)
100
147
  return blocked;
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { gate } from "../../confirm.js";
3
3
  /** Scene lifecycle: open / save / new / reload / pack / dependencies. */
4
- export function registerSceneTools(server, call) {
4
+ export function registerSceneTools(server, call, guard) {
5
5
  server.registerTool("scene_get_tree", {
6
6
  title: "Get scene tree",
7
7
  description: "Return the node tree of the currently edited scene (name, type, path, script, children).",
@@ -11,7 +11,12 @@ export function registerSceneTools(server, call) {
11
11
  title: "Open scene",
12
12
  description: "Open an existing scene in the editor by res:// path.",
13
13
  inputSchema: { path: z.string().describe("Scene path, e.g. res://scenes/main.tscn") },
14
- }, async ({ path }) => call("scene.open", { path }));
14
+ }, async ({ path }) => {
15
+ const escaped = guard(path, "path");
16
+ if (escaped)
17
+ return escaped;
18
+ return call("scene.open", { path });
19
+ });
15
20
  server.registerTool("scene_save", { title: "Save scene", description: "Save the currently edited scene to its existing path.", inputSchema: {} }, async () => call("scene.save"));
16
21
  server.registerTool("scene_new", {
17
22
  title: "New scene",
@@ -23,6 +28,9 @@ export function registerSceneTools(server, call) {
23
28
  confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
24
29
  },
25
30
  }, async ({ root_type, path, name, confirm }) => {
31
+ const escaped = guard(path, "path");
32
+ if (escaped)
33
+ return escaped;
26
34
  const blocked = await gate(server, confirm, `Create and overwrite scene file "${path}"`);
27
35
  if (blocked)
28
36
  return blocked;
@@ -41,6 +49,12 @@ export function registerSceneTools(server, call) {
41
49
  confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
42
50
  },
43
51
  }, async ({ path, confirm }) => {
52
+ // 🔴 OPTIONAL — an omitted `path` means "the current edited scene" and must stay
53
+ // legal. `makePathGuard` returns null for undefined; an over-eager mutation that
54
+ // refuses omission is caught by the sweep.
55
+ const escaped = guard(path, "path");
56
+ if (escaped)
57
+ return escaped;
44
58
  const blocked = await gate(server, confirm, `Reload scene ${path ?? "(current)"} from disk, discarding unsaved changes`);
45
59
  if (blocked)
46
60
  return blocked;
@@ -68,6 +82,11 @@ export function registerSceneTools(server, call) {
68
82
  confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
69
83
  },
70
84
  }, async ({ path, to_path, confirm }) => {
85
+ // `path` is a NODE path inside the open scene, not a file path — it cannot
86
+ // express a filesystem escape and is deliberately left alone.
87
+ const escaped = guard(to_path, "to_path");
88
+ if (escaped)
89
+ return escaped;
71
90
  const blocked = await gate(server, confirm, `Pack branch "${path}" to ${to_path}`);
72
91
  if (blocked)
73
92
  return blocked;
@@ -75,9 +94,14 @@ export function registerSceneTools(server, call) {
75
94
  });
76
95
  server.registerTool("scene_get_dependencies", {
77
96
  title: "Scene dependencies",
78
- description: "List the external resource dependencies of a scene file. Read-only. Defaults to the current scene.",
97
+ description: "List the external resource dependencies of a scene file. Read-only. Defaults to the current scene. `dependencies` are loadable res:// paths; `dependencies_raw` carries the engine's own `uid://…::::res://…` encoding and `dependency_uids` the UID alone (\"\" when the dependency has none) — all three index-aligned.",
79
98
  inputSchema: { path: z.string().optional().describe("Scene res:// path; omitted = the current edited scene") },
80
- }, async ({ path }) => call("scene.get_dependencies", path !== undefined ? { path } : {}));
99
+ }, async ({ path }) => {
100
+ const escaped = guard(path, "path");
101
+ if (escaped)
102
+ return escaped;
103
+ return call("scene.get_dependencies", path !== undefined ? { path } : {});
104
+ });
81
105
  server.registerTool("scene_save_as", {
82
106
  title: "Save scene as",
83
107
  description: "Save the current scene to a new res:// path (Save As). DESTRUCTIVE (writes a file) — gated by confirmation.",
@@ -86,6 +110,9 @@ export function registerSceneTools(server, call) {
86
110
  confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
87
111
  },
88
112
  }, async ({ path, confirm }) => {
113
+ const escaped = guard(path, "path");
114
+ if (escaped)
115
+ return escaped;
89
116
  const blocked = await gate(server, confirm, `Save the current scene to ${path}`);
90
117
  if (blocked)
91
118
  return blocked;
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { gate } from "../../confirm.js";
3
3
  /** Shader + ShaderMaterial authoring. */
4
- export function registerShaderTools(server, call) {
4
+ export function registerShaderTools(server, call, guard) {
5
5
  server.registerTool("shader_create", {
6
6
  title: "Create shader",
7
7
  description: "Create a Shader resource with optional initial code and save it as a .gdshader file. DESTRUCTIVE (writes a file) — gated by confirmation.",
@@ -11,6 +11,9 @@ export function registerShaderTools(server, call) {
11
11
  confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
12
12
  },
13
13
  }, async ({ to_path, code, confirm }) => {
14
+ const escaped = guard(to_path, "to_path");
15
+ if (escaped)
16
+ return escaped;
14
17
  const blocked = await gate(server, confirm, `Create shader at ${to_path}`);
15
18
  if (blocked)
16
19
  return blocked;
@@ -25,6 +28,9 @@ export function registerShaderTools(server, call) {
25
28
  confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
26
29
  },
27
30
  }, async ({ path, code, confirm }) => {
31
+ const escaped = guard(path, "path");
32
+ if (escaped)
33
+ return escaped;
28
34
  const blocked = await gate(server, confirm, `Overwrite shader code at ${path}`);
29
35
  if (blocked)
30
36
  return blocked;
@@ -37,7 +43,12 @@ export function registerShaderTools(server, call) {
37
43
  path: z.string().describe("Node path relative to the scene root (a CanvasItem or GeometryInstance3D)"),
38
44
  shader_path: z.string().optional().describe("res:// path to a Shader to assign to the new material"),
39
45
  },
40
- }, async ({ path, shader_path }) => call("shadermaterial.create", shader_path !== undefined ? { path, shader_path } : { path }));
46
+ }, async ({ path, shader_path }) => {
47
+ const escaped = guard(shader_path, "shader_path");
48
+ if (escaped)
49
+ return escaped;
50
+ return call("shadermaterial.create", shader_path !== undefined ? { path, shader_path } : { path });
51
+ });
41
52
  server.registerTool("shadermaterial_set_shader", {
42
53
  title: "Set shader material shader",
43
54
  description: "Load a Shader from a res:// path and assign it to the ShaderMaterial on a node's material slot in the edited scene (undoable). The node must already have a ShaderMaterial. In-scene and undoable — not gated.",
@@ -45,7 +56,12 @@ export function registerShaderTools(server, call) {
45
56
  path: z.string().describe("Node path relative to the scene root"),
46
57
  shader_path: z.string().describe("res:// path to a Shader resource"),
47
58
  },
48
- }, async ({ path, shader_path }) => call("shadermaterial.set_shader", { path, shader_path }));
59
+ }, async ({ path, shader_path }) => {
60
+ const escaped = guard(shader_path, "shader_path");
61
+ if (escaped)
62
+ return escaped;
63
+ return call("shadermaterial.set_shader", { path, shader_path });
64
+ });
49
65
  server.registerTool("shadermaterial_set_param", {
50
66
  title: "Set shader material parameter",
51
67
  description: "Set a shader uniform parameter on the ShaderMaterial of a node's material slot in the edited scene (undoable). The node must already have a ShaderMaterial. The value uses the tagged-Variant convention. In-scene and undoable — not gated.",
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { gate } from "../../confirm.js";
3
3
  /** 3D & navigation. meshinstance/mesh/light/camera/csg/navregion/navagent mutate the EDITED scene and are undoable + ungated (the node_* model). primitive_mesh_create and the two environment_* tools author a resource on disk, so are file-writers gated by confirmation. */
4
- export function registerSpatialTools(server, call) {
4
+ export function registerSpatialTools(server, call, guard) {
5
5
  server.registerTool("meshinstance_create", {
6
6
  title: "Create MeshInstance3D",
7
7
  description: "Add a MeshInstance3D under a parent (undoable). Optional 'mesh_path' loads a Mesh resource (e.g. a primitive_mesh_create output) and assigns it. Returns the new node's path.",
@@ -11,6 +11,9 @@ export function registerSpatialTools(server, call) {
11
11
  mesh_path: z.string().optional().describe("Mesh resource res:// path to assign to the instance's 'mesh'"),
12
12
  },
13
13
  }, async ({ parent_path, name, mesh_path }) => {
14
+ const escaped = guard(mesh_path, "mesh_path");
15
+ if (escaped)
16
+ return escaped;
14
17
  const params = { parent_path };
15
18
  if (name !== undefined)
16
19
  params.name = name;
@@ -27,6 +30,9 @@ export function registerSpatialTools(server, call) {
27
30
  surface: z.number().int().optional().describe("Surface index, or -1 (default) for material_override"),
28
31
  },
29
32
  }, async ({ path, material_path, surface }) => {
33
+ const escaped = guard(material_path, "material_path");
34
+ if (escaped)
35
+ return escaped;
30
36
  const params = { path, material_path };
31
37
  if (surface !== undefined)
32
38
  params.surface = surface;
@@ -41,6 +47,9 @@ export function registerSpatialTools(server, call) {
41
47
  confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
42
48
  },
43
49
  }, async ({ to_path, shape, confirm }) => {
50
+ const escaped = guard(to_path, "to_path");
51
+ if (escaped)
52
+ return escaped;
44
53
  const blocked = await gate(server, confirm, `Create ${shape ?? "box"} PrimitiveMesh at ${to_path}`);
45
54
  if (blocked)
46
55
  return blocked;
@@ -154,6 +163,9 @@ export function registerSpatialTools(server, call) {
154
163
  confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
155
164
  },
156
165
  }, async ({ to_path, background, ambient_color, confirm }) => {
166
+ const escaped = guard(to_path, "to_path");
167
+ if (escaped)
168
+ return escaped;
157
169
  const blocked = await gate(server, confirm, `Create Environment at ${to_path}`);
158
170
  if (blocked)
159
171
  return blocked;
@@ -173,6 +185,9 @@ export function registerSpatialTools(server, call) {
173
185
  confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
174
186
  },
175
187
  }, async ({ path, sky_material, confirm }) => {
188
+ const escaped = guard(path, "path");
189
+ if (escaped)
190
+ return escaped;
176
191
  const blocked = await gate(server, confirm, `Set sky on Environment ${path}`);
177
192
  if (blocked)
178
193
  return blocked;
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { gate } from "../../confirm.js";
3
3
  /** TileSet authoring (disk-backed, gated) + TileMapLayer cell painting. */
4
- export function registerTileTools(server, call) {
4
+ export function registerTileTools(server, call, guard) {
5
5
  server.registerTool("tileset_create", {
6
6
  title: "Create TileSet",
7
7
  description: "Instantiate a TileSet resource and save it as a new .tres file. DESTRUCTIVE (writes a file) — gated by confirmation. tile_size is the base grid cell size in pixels (default 16×16).",
@@ -11,6 +11,9 @@ export function registerTileTools(server, call) {
11
11
  confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
12
12
  },
13
13
  }, async ({ to_path, tile_size, confirm }) => {
14
+ const escaped = guard(to_path, "to_path");
15
+ if (escaped)
16
+ return escaped;
14
17
  const blocked = await gate(server, confirm, `Create TileSet resource at ${to_path}`);
15
18
  if (blocked)
16
19
  return blocked;
@@ -29,6 +32,9 @@ export function registerTileTools(server, call) {
29
32
  confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
30
33
  },
31
34
  }, async ({ tileset_path, texture_path, texture_region_size, source_id, margins, separation, confirm }) => {
35
+ const escaped = guard(tileset_path, "tileset_path") ?? guard(texture_path, "texture_path");
36
+ if (escaped)
37
+ return escaped;
32
38
  const blocked = await gate(server, confirm, `Add atlas source (${texture_path}) to TileSet ${tileset_path}`);
33
39
  if (blocked)
34
40
  return blocked;
@@ -54,6 +60,9 @@ export function registerTileTools(server, call) {
54
60
  confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
55
61
  },
56
62
  }, async ({ tileset_path, source_id, atlas_coords, size, confirm }) => {
63
+ const escaped = guard(tileset_path, "tileset_path");
64
+ if (escaped)
65
+ return escaped;
57
66
  const blocked = await gate(server, confirm, `Add tile ${JSON.stringify(atlas_coords)} to source ${source_id} in ${tileset_path}`);
58
67
  if (blocked)
59
68
  return blocked;
@@ -75,6 +84,9 @@ export function registerTileTools(server, call) {
75
84
  confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
76
85
  },
77
86
  }, async ({ tileset_path, source_id, atlas_coords, polygon, physics_layer, one_way, confirm }) => {
87
+ const escaped = guard(tileset_path, "tileset_path");
88
+ if (escaped)
89
+ return escaped;
78
90
  const blocked = await gate(server, confirm, `Set collision on tile ${JSON.stringify(atlas_coords)} in ${tileset_path}`);
79
91
  if (blocked)
80
92
  return blocked;
@@ -94,6 +106,9 @@ export function registerTileTools(server, call) {
94
106
  tileset_path: z.string().optional().describe("TileSet res:// .tres path to bind as tile_set (e.g. from tileset_create)"),
95
107
  },
96
108
  }, async ({ parent_path, name, tileset_path }) => {
109
+ const escaped = guard(tileset_path, "tileset_path");
110
+ if (escaped)
111
+ return escaped;
97
112
  const params = { parent_path };
98
113
  if (name !== undefined)
99
114
  params.name = name;