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.
- package/README.md +1 -1
- package/addon/breakpoint_mcp/operations.gd +248 -13
- package/addon/breakpoint_mcp/plugin.cfg +1 -1
- package/addon/breakpoint_mcp/runtime_bridge.gd +54 -1
- package/dist/annotations.js +3 -3
- package/dist/cli/doctor.js +1 -1
- package/dist/cli/init.js +1 -1
- package/dist/cli/tools.js +2 -2
- package/dist/csdap.js +88 -6
- package/dist/dap.js +172 -10
- package/dist/index.js +4 -4
- package/dist/path-cohort.js +177 -0
- package/dist/paths.js +111 -0
- package/dist/recipes.js +1 -1
- package/dist/schemas.js +49 -5
- package/dist/tools/assetgen.js +17 -1
- package/dist/tools/backend.js +18 -1
- package/dist/tools/cli.js +41 -4
- package/dist/tools/csdap.js +132 -8
- package/dist/tools/cslsp.js +110 -5
- package/dist/tools/dap.js +316 -59
- package/dist/tools/editor/audio.js +16 -2
- package/dist/tools/editor/common.js +39 -1
- package/dist/tools/editor/filesystem.js +23 -3
- package/dist/tools/editor/introspection.js +72 -1
- package/dist/tools/editor/node.js +7 -2
- package/dist/tools/editor/particles.js +7 -2
- package/dist/tools/editor/project_input_test.js +16 -2
- package/dist/tools/editor/resource.js +54 -7
- package/dist/tools/editor/scene.js +31 -4
- package/dist/tools/editor/shader.js +19 -3
- package/dist/tools/editor/spatial.js +16 -1
- package/dist/tools/editor/tiles.js +16 -1
- package/dist/tools/editor/ui.js +29 -2
- package/dist/tools/editor.js +20 -13
- package/dist/tools/knowledge.js +14 -1
- package/dist/tools/lsp-common.js +29 -0
- package/dist/tools/lsp.js +87 -8
- package/dist/tools/netcode.js +47 -4
- package/dist/tools/processes.js +14 -2
- package/dist/tools/runtime.js +49 -9
- package/dist/tools/tabletop.js +173 -7
- package/dist/tools/vcs.js +138 -22
- package/dist/toolsets.js +2 -2
- 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 }) =>
|
|
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 }) =>
|
|
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
|
|
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 }) =>
|
|
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 }) =>
|
|
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
|
-
},
|
|
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
|
-
/**
|
|
4
|
-
|
|
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 }) =>
|
|
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 }) =>
|
|
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
|
|
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 }) =>
|
|
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
|
|
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 }) =>
|
|
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 }) =>
|
|
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 }) =>
|
|
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 }) =>
|
|
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;
|