breakpoint-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +85 -0
- package/dist/bridge.js +189 -0
- package/dist/config.js +71 -0
- package/dist/confirm.js +45 -0
- package/dist/csdap.js +331 -0
- package/dist/cslsp.js +217 -0
- package/dist/dap.js +341 -0
- package/dist/framing.js +125 -0
- package/dist/index.js +110 -0
- package/dist/logger.js +11 -0
- package/dist/lsp.js +219 -0
- package/dist/paths.js +28 -0
- package/dist/schemas.js +587 -0
- package/dist/stdio.js +101 -0
- package/dist/subscriptions.js +141 -0
- package/dist/tasks.js +146 -0
- package/dist/tools/assetgen.js +360 -0
- package/dist/tools/backend.js +532 -0
- package/dist/tools/cli.js +130 -0
- package/dist/tools/csdap.js +377 -0
- package/dist/tools/cslsp.js +285 -0
- package/dist/tools/dap.js +504 -0
- package/dist/tools/editor.js +1919 -0
- package/dist/tools/knowledge.js +517 -0
- package/dist/tools/lsp-common.js +121 -0
- package/dist/tools/lsp.js +576 -0
- package/dist/tools/netcode.js +411 -0
- package/dist/tools/processes.js +103 -0
- package/dist/tools/resources.js +27 -0
- package/dist/tools/runtime.js +125 -0
- package/package.json +57 -0
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { toFsPath } from "../paths.js";
|
|
5
|
+
/**
|
|
6
|
+
* Group K — Knowledge & search (read-only).
|
|
7
|
+
*
|
|
8
|
+
* Four host-side tools that answer "where / what / how" questions about the
|
|
9
|
+
* project and the engine WITHOUT touching the editor bridge or the language
|
|
10
|
+
* server, so they work headlessly and are deterministic:
|
|
11
|
+
* - project_search : ripgrep-style full-text/regex search across project files.
|
|
12
|
+
* - find_symbol : declaration index over the project's GDScript (class_name,
|
|
13
|
+
* func, var, const, signal, enum) — the workspace-symbol
|
|
14
|
+
* answer Godot's LSP does not implement (see gd_workspace_symbols).
|
|
15
|
+
* - find_usages : word-boundary occurrence search for an identifier — the
|
|
16
|
+
* project-wide complement to the position-based gd_references.
|
|
17
|
+
* - example_snippet: curated GDScript idiom lookup.
|
|
18
|
+
*
|
|
19
|
+
* The two ClassDB-backed knowledge tools (class_reference, docs_search) live in
|
|
20
|
+
* tools/editor.ts because they go over the editor bridge.
|
|
21
|
+
*/
|
|
22
|
+
// ---- result envelopes (mirrors the ok()/fail() shape used elsewhere) --------
|
|
23
|
+
function ok(obj) {
|
|
24
|
+
return {
|
|
25
|
+
content: [{ type: "text", text: JSON.stringify(obj, null, 2) }],
|
|
26
|
+
structuredContent: obj,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function fail(message) {
|
|
30
|
+
return {
|
|
31
|
+
isError: true,
|
|
32
|
+
content: [{ type: "text", text: `Search error: ${message}` }],
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
// ---- filesystem walk --------------------------------------------------------
|
|
36
|
+
/** Directories never worth searching (VCS, Godot/import caches, build output). */
|
|
37
|
+
const SKIP_DIRS = new Set([
|
|
38
|
+
".git", ".godot", ".import", "node_modules", ".vs", ".vscode",
|
|
39
|
+
"dist", "dist-test", "obj", "bin", ".mono",
|
|
40
|
+
]);
|
|
41
|
+
const MAX_FILE_BYTES = 2_000_000;
|
|
42
|
+
/** Default extension set for a project-wide text search. */
|
|
43
|
+
const SEARCH_EXTS = ["gd", "cs", "tscn", "tres", "gdshader", "shader", "godot", "cfg", "json", "md", "txt", "import", "csv", "xml"];
|
|
44
|
+
/** Convert an absolute path back to a `res://`-relative path when under the project. */
|
|
45
|
+
function toRes(abs, projectPath) {
|
|
46
|
+
const rel = path.relative(projectPath, abs);
|
|
47
|
+
if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) {
|
|
48
|
+
return "res://" + rel.split(path.sep).join("/");
|
|
49
|
+
}
|
|
50
|
+
return abs;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Recursively collect files under `root` whose extension is in `exts` (or all,
|
|
54
|
+
* when `exts` is null). Directory entries are visited in sorted order so results
|
|
55
|
+
* are stable across runs. Returns absolute paths.
|
|
56
|
+
*/
|
|
57
|
+
function collectFiles(root, exts) {
|
|
58
|
+
const out = [];
|
|
59
|
+
const stack = [root];
|
|
60
|
+
while (stack.length) {
|
|
61
|
+
const dir = stack.pop();
|
|
62
|
+
let entries;
|
|
63
|
+
try {
|
|
64
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
70
|
+
// Push dirs last so the sorted files of this level are processed first (LIFO).
|
|
71
|
+
const dirs = [];
|
|
72
|
+
for (const e of entries) {
|
|
73
|
+
if (e.name.startsWith(".") && e.isDirectory())
|
|
74
|
+
continue;
|
|
75
|
+
if (e.isDirectory()) {
|
|
76
|
+
if (SKIP_DIRS.has(e.name))
|
|
77
|
+
continue;
|
|
78
|
+
dirs.push(path.join(dir, e.name));
|
|
79
|
+
}
|
|
80
|
+
else if (e.isFile()) {
|
|
81
|
+
if (exts) {
|
|
82
|
+
const ext = path.extname(e.name).slice(1).toLowerCase();
|
|
83
|
+
if (!exts.has(ext))
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
out.push(path.join(dir, e.name));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Reverse so sibling directories are also walked in ascending order.
|
|
90
|
+
for (let i = dirs.length - 1; i >= 0; i--)
|
|
91
|
+
stack.push(dirs[i]);
|
|
92
|
+
}
|
|
93
|
+
out.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
/** Read a file as text, skipping oversized or binary content. Returns null to skip. */
|
|
97
|
+
function readText(abs) {
|
|
98
|
+
let buf;
|
|
99
|
+
try {
|
|
100
|
+
const st = fs.statSync(abs);
|
|
101
|
+
if (st.size > MAX_FILE_BYTES)
|
|
102
|
+
return null;
|
|
103
|
+
buf = fs.readFileSync(abs);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
// Binary sniff: a NUL byte in the first 8 KiB.
|
|
109
|
+
const probe = buf.subarray(0, 8192);
|
|
110
|
+
if (probe.includes(0))
|
|
111
|
+
return null;
|
|
112
|
+
return buf.toString("utf8");
|
|
113
|
+
}
|
|
114
|
+
function escapeRegExp(s) {
|
|
115
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
116
|
+
}
|
|
117
|
+
function clip(s, max = 300) {
|
|
118
|
+
const t = s.replace(/\s+$/, "");
|
|
119
|
+
return t.length > max ? t.slice(0, max) + "…" : t;
|
|
120
|
+
}
|
|
121
|
+
/** Resolve the search root and the extension filter shared by the scanners. */
|
|
122
|
+
function resolveRoot(cfg, sub) {
|
|
123
|
+
return sub ? toFsPath(sub, cfg.projectPath) : cfg.projectPath;
|
|
124
|
+
}
|
|
125
|
+
const SNIPPETS = [
|
|
126
|
+
{
|
|
127
|
+
id: "signal_connect",
|
|
128
|
+
title: "Connect and emit a custom signal",
|
|
129
|
+
tags: ["signal", "signals", "connect", "emit", "event", "callback", "observer"],
|
|
130
|
+
code: [
|
|
131
|
+
"signal health_changed(new_value: int)",
|
|
132
|
+
"",
|
|
133
|
+
"func _ready() -> void:",
|
|
134
|
+
"\thealth_changed.connect(_on_health_changed)",
|
|
135
|
+
"",
|
|
136
|
+
"func take_damage(amount: int) -> void:",
|
|
137
|
+
"\thealth -= amount",
|
|
138
|
+
"\thealth_changed.emit(health)",
|
|
139
|
+
"",
|
|
140
|
+
"func _on_health_changed(new_value: int) -> void:",
|
|
141
|
+
"\tprint(\"health is now \", new_value)",
|
|
142
|
+
].join("\n"),
|
|
143
|
+
explanation: "Godot 4 uses first-class Signal objects: declare with `signal`, subscribe with `.connect(Callable)`, and fire with `.emit(...)`. Prefer this over the string-based `connect(\"name\", ...)` API.",
|
|
144
|
+
docs_url: "https://docs.godotengine.org/en/stable/getting_started/step_by_step/signals.html",
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
id: "autoload_singleton",
|
|
148
|
+
title: "Global singleton via an autoload",
|
|
149
|
+
tags: ["autoload", "singleton", "global", "manager", "gamestate"],
|
|
150
|
+
code: [
|
|
151
|
+
"# game_state.gd (registered as the autoload \"GameState\")",
|
|
152
|
+
"extends Node",
|
|
153
|
+
"",
|
|
154
|
+
"var score: int = 0",
|
|
155
|
+
"",
|
|
156
|
+
"func add_score(points: int) -> void:",
|
|
157
|
+
"\tscore += points",
|
|
158
|
+
"",
|
|
159
|
+
"# Anywhere else:",
|
|
160
|
+
"# GameState.add_score(10)",
|
|
161
|
+
].join("\n"),
|
|
162
|
+
explanation: "Register a script under Project > Project Settings > Autoload with a name; Godot instances it once at startup and exposes it as a global by that name. Use it for cross-scene state (score, settings, audio).",
|
|
163
|
+
docs_url: "https://docs.godotengine.org/en/stable/tutorials/scripting/singletons_autoload.html",
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
id: "input_handling",
|
|
167
|
+
title: "Read input actions each frame",
|
|
168
|
+
tags: ["input", "action", "keyboard", "movement", "controller", "is_action_pressed"],
|
|
169
|
+
code: [
|
|
170
|
+
"func _physics_process(delta: float) -> void:",
|
|
171
|
+
"\tvar dir := Input.get_vector(\"move_left\", \"move_right\", \"move_up\", \"move_down\")",
|
|
172
|
+
"\tvelocity = dir * speed",
|
|
173
|
+
"\tmove_and_slide()",
|
|
174
|
+
"",
|
|
175
|
+
"func _unhandled_input(event: InputEvent) -> void:",
|
|
176
|
+
"\tif event.is_action_pressed(\"jump\"):",
|
|
177
|
+
"\t\tvelocity.y = jump_force",
|
|
178
|
+
].join("\n"),
|
|
179
|
+
explanation: "Use `Input.get_vector` for smooth analog movement across four actions, and `_unhandled_input` with `is_action_pressed` for discrete one-shot actions. Define the action names in the Input Map.",
|
|
180
|
+
docs_url: "https://docs.godotengine.org/en/stable/tutorials/inputs/input_examples.html",
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
id: "timer_oneshot",
|
|
184
|
+
title: "Wait without a Timer node (await)",
|
|
185
|
+
tags: ["timer", "wait", "delay", "await", "coroutine", "timeout"],
|
|
186
|
+
code: [
|
|
187
|
+
"func flash() -> void:",
|
|
188
|
+
"\tmodulate = Color.RED",
|
|
189
|
+
"\tawait get_tree().create_timer(0.2).timeout",
|
|
190
|
+
"\tmodulate = Color.WHITE",
|
|
191
|
+
].join("\n"),
|
|
192
|
+
explanation: "`get_tree().create_timer(seconds)` returns a one-shot SceneTreeTimer; `await ...timeout` suspends the coroutine without needing a Timer node in the scene.",
|
|
193
|
+
docs_url: "https://docs.godotengine.org/en/stable/classes/class_scenetree.html#class-scenetree-method-create-timer",
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
id: "tween_property",
|
|
197
|
+
title: "Animate a property with a Tween",
|
|
198
|
+
tags: ["tween", "animation", "animate", "interpolate", "ease", "move"],
|
|
199
|
+
code: [
|
|
200
|
+
"func pop_in() -> void:",
|
|
201
|
+
"\tscale = Vector2.ZERO",
|
|
202
|
+
"\tvar tw := create_tween()",
|
|
203
|
+
"\ttw.tween_property(self, \"scale\", Vector2.ONE, 0.3) \\",
|
|
204
|
+
"\t\t.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)",
|
|
205
|
+
].join("\n"),
|
|
206
|
+
explanation: "`create_tween()` builds a throwaway Tween; `tween_property(object, property, final_value, duration)` interpolates it. Chain `.set_trans`/`.set_ease` for easing and multiple `tween_property` calls run sequentially (use `.set_parallel()` for together).",
|
|
207
|
+
docs_url: "https://docs.godotengine.org/en/stable/classes/class_tween.html",
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
id: "load_scene_change",
|
|
211
|
+
title: "Change to another scene",
|
|
212
|
+
tags: ["scene", "change_scene", "load", "packedscene", "instantiate", "level"],
|
|
213
|
+
code: [
|
|
214
|
+
"# Simple full swap:",
|
|
215
|
+
"get_tree().change_scene_to_file(\"res://levels/level_2.tscn\")",
|
|
216
|
+
"",
|
|
217
|
+
"# Or instance a scene as a child (keep the current one):",
|
|
218
|
+
"var bullet_scene := preload(\"res://actors/bullet.tscn\")",
|
|
219
|
+
"var bullet := bullet_scene.instantiate()",
|
|
220
|
+
"add_child(bullet)",
|
|
221
|
+
].join("\n"),
|
|
222
|
+
explanation: "`change_scene_to_file` replaces the whole running scene. To spawn objects instead, `preload`/`load` a PackedScene and `.instantiate()` it, then `add_child`.",
|
|
223
|
+
docs_url: "https://docs.godotengine.org/en/stable/tutorials/scripting/scene_tree.html",
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
id: "save_load_json",
|
|
227
|
+
title: "Save and load JSON to user://",
|
|
228
|
+
tags: ["save", "load", "json", "file", "persistence", "user"],
|
|
229
|
+
code: [
|
|
230
|
+
"func save_game(data: Dictionary) -> void:",
|
|
231
|
+
"\tvar f := FileAccess.open(\"user://save.json\", FileAccess.WRITE)",
|
|
232
|
+
"\tf.store_string(JSON.stringify(data))",
|
|
233
|
+
"",
|
|
234
|
+
"func load_game() -> Dictionary:",
|
|
235
|
+
"\tif not FileAccess.file_exists(\"user://save.json\"):",
|
|
236
|
+
"\t\treturn {}",
|
|
237
|
+
"\tvar f := FileAccess.open(\"user://save.json\", FileAccess.READ)",
|
|
238
|
+
"\treturn JSON.parse_string(f.get_as_text()) as Dictionary",
|
|
239
|
+
].join("\n"),
|
|
240
|
+
explanation: "Write to `user://` (a per-user writable location) with FileAccess; serialize with `JSON.stringify` / `JSON.parse_string`. `res://` is read-only in exported games, so saves must go to `user://`.",
|
|
241
|
+
docs_url: "https://docs.godotengine.org/en/stable/tutorials/io/saving_games.html",
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
id: "rng",
|
|
245
|
+
title: "Random numbers and picks",
|
|
246
|
+
tags: ["random", "rng", "randi", "randf", "pick", "shuffle", "seed"],
|
|
247
|
+
code: [
|
|
248
|
+
"randomize() # seed once, e.g. in _ready",
|
|
249
|
+
"var roll := randi_range(1, 6)",
|
|
250
|
+
"var chance := randf() < 0.25 # 25% true",
|
|
251
|
+
"var loot := [\"gold\", \"potion\", \"sword\"].pick_random()",
|
|
252
|
+
].join("\n"),
|
|
253
|
+
explanation: "`randi_range`/`randf_range` give bounded integers/floats, `randf()` gives 0–1, and Array has `pick_random()`/`shuffle()`. Call `randomize()` once to seed from the clock (or `seed(n)` for reproducible runs).",
|
|
254
|
+
docs_url: "https://docs.godotengine.org/en/stable/tutorials/math/random_number_generation.html",
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
id: "group_call",
|
|
258
|
+
title: "Call a method on every node in a group",
|
|
259
|
+
tags: ["group", "groups", "call_group", "broadcast", "enemies"],
|
|
260
|
+
code: [
|
|
261
|
+
"# Tag nodes: add_to_group(\"enemies\") (or set a Group in the editor)",
|
|
262
|
+
"func game_over() -> void:",
|
|
263
|
+
"\tget_tree().call_group(\"enemies\", \"freeze\")",
|
|
264
|
+
"\tvar count := get_tree().get_node_count_in_group(\"enemies\")",
|
|
265
|
+
].join("\n"),
|
|
266
|
+
explanation: "Groups are lightweight tags. `get_tree().call_group(group, method, ...)` invokes a method on every member; `get_nodes_in_group` / `get_node_count_in_group` enumerate them. Great for broadcasting to all enemies, pickups, etc.",
|
|
267
|
+
docs_url: "https://docs.godotengine.org/en/stable/tutorials/scripting/groups.html",
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
id: "state_machine_enum",
|
|
271
|
+
title: "Minimal enum state machine",
|
|
272
|
+
tags: ["state", "state machine", "fsm", "enum", "match"],
|
|
273
|
+
code: [
|
|
274
|
+
"enum State { IDLE, RUN, JUMP }",
|
|
275
|
+
"var state: State = State.IDLE",
|
|
276
|
+
"",
|
|
277
|
+
"func _physics_process(delta: float) -> void:",
|
|
278
|
+
"\tmatch state:",
|
|
279
|
+
"\t\tState.IDLE:",
|
|
280
|
+
"\t\t\tif Input.is_action_pressed(\"move_right\"): state = State.RUN",
|
|
281
|
+
"\t\tState.RUN:",
|
|
282
|
+
"\t\t\tif Input.is_action_just_pressed(\"jump\"): state = State.JUMP",
|
|
283
|
+
"\t\tState.JUMP:",
|
|
284
|
+
"\t\t\tif is_on_floor(): state = State.IDLE",
|
|
285
|
+
].join("\n"),
|
|
286
|
+
explanation: "A named `enum` plus a `match` on the current state is the simplest finite state machine — no extra nodes. Transition by assigning `state`; branch behaviour per state in the `match`.",
|
|
287
|
+
docs_url: "https://docs.godotengine.org/en/stable/tutorials/scripting/gdscript/gdscript_basics.html#match",
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
id: "http_request",
|
|
291
|
+
title: "Fetch data over HTTP",
|
|
292
|
+
tags: ["http", "httprequest", "network", "rest", "api", "download", "web"],
|
|
293
|
+
code: [
|
|
294
|
+
"var http := HTTPRequest.new()",
|
|
295
|
+
"",
|
|
296
|
+
"func _ready() -> void:",
|
|
297
|
+
"\tadd_child(http)",
|
|
298
|
+
"\thttp.request_completed.connect(_on_done)",
|
|
299
|
+
"\thttp.request(\"https://example.com/api/scores\")",
|
|
300
|
+
"",
|
|
301
|
+
"func _on_done(result, code, headers, body: PackedByteArray) -> void:",
|
|
302
|
+
"\tvar data = JSON.parse_string(body.get_string_from_utf8())",
|
|
303
|
+
].join("\n"),
|
|
304
|
+
explanation: "Add an HTTPRequest node, connect `request_completed`, then call `.request(url)`. The body arrives as a PackedByteArray — decode with `get_string_from_utf8()` and parse. Works in exported games and the web export.",
|
|
305
|
+
docs_url: "https://docs.godotengine.org/en/stable/tutorials/networking/http_request_class.html",
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
id: "onready_node",
|
|
309
|
+
title: "Cache a child node with @onready",
|
|
310
|
+
tags: ["onready", "get_node", "node reference", "$", "cache"],
|
|
311
|
+
code: [
|
|
312
|
+
"@onready var sprite: Sprite2D = $Sprite2D",
|
|
313
|
+
"@onready var anim: AnimationPlayer = $AnimationPlayer",
|
|
314
|
+
"",
|
|
315
|
+
"func _ready() -> void:",
|
|
316
|
+
"\tanim.play(\"spawn\")",
|
|
317
|
+
].join("\n"),
|
|
318
|
+
explanation: "`@onready var x := $Path` resolves the node once, right before `_ready`, so you avoid repeated `get_node` calls. `$Name` is shorthand for `get_node(\"Name\")`; type the var for autocompletion and safety.",
|
|
319
|
+
docs_url: "https://docs.godotengine.org/en/stable/tutorials/scripting/gdscript/gdscript_basics.html#onready-annotation",
|
|
320
|
+
},
|
|
321
|
+
];
|
|
322
|
+
const SNIPPET_IDS = SNIPPETS.map((s) => s.id);
|
|
323
|
+
function scoreSnippet(s, terms) {
|
|
324
|
+
const hay = (s.id + " " + s.title + " " + s.tags.join(" ")).toLowerCase();
|
|
325
|
+
let score = 0;
|
|
326
|
+
for (const t of terms) {
|
|
327
|
+
if (!t)
|
|
328
|
+
continue;
|
|
329
|
+
if (s.id === t)
|
|
330
|
+
score += 5;
|
|
331
|
+
if (s.tags.includes(t))
|
|
332
|
+
score += 3;
|
|
333
|
+
if (hay.includes(t))
|
|
334
|
+
score += 1;
|
|
335
|
+
}
|
|
336
|
+
return score;
|
|
337
|
+
}
|
|
338
|
+
// ---- registration -----------------------------------------------------------
|
|
339
|
+
export function registerKnowledgeTools(server, cfg) {
|
|
340
|
+
server.registerTool("project_search", {
|
|
341
|
+
title: "Search project files",
|
|
342
|
+
description: "Full-text (or regex) search across the project's source files (ripgrep-style). Read-only. " +
|
|
343
|
+
"Returns one match per line with its res:// path, 1-based line and column, and the matching text. " +
|
|
344
|
+
"Binary and oversized files are skipped; caches (.git/.godot/.import/node_modules) are never searched.",
|
|
345
|
+
inputSchema: {
|
|
346
|
+
query: z.string().describe("Text to find (literal by default; a regular expression when regex=true)"),
|
|
347
|
+
regex: z.boolean().optional().describe("Treat query as a JavaScript regular expression (default false = literal)"),
|
|
348
|
+
ignore_case: z.boolean().optional().describe("Case-insensitive match (default false)"),
|
|
349
|
+
extensions: z.array(z.string()).optional().describe("File extensions to include, without the dot (default: gd,cs,tscn,tres,gdshader,godot,cfg,json,md,txt,…)"),
|
|
350
|
+
path: z.string().optional().describe("Limit the search to a res:// or project-relative subdirectory (default: whole project)"),
|
|
351
|
+
max_results: z.number().int().positive().optional().describe("Cap on returned matches (default 200)"),
|
|
352
|
+
},
|
|
353
|
+
}, async ({ query, regex, ignore_case, extensions, path: sub, max_results }) => {
|
|
354
|
+
const cap = max_results ?? 200;
|
|
355
|
+
let re;
|
|
356
|
+
try {
|
|
357
|
+
re = new RegExp(regex ? query : escapeRegExp(query), ignore_case ? "i" : "");
|
|
358
|
+
}
|
|
359
|
+
catch (err) {
|
|
360
|
+
return fail(`invalid regular expression: ${err.message}`);
|
|
361
|
+
}
|
|
362
|
+
const exts = extensions && extensions.length
|
|
363
|
+
? new Set(extensions.map((e) => e.replace(/^\./, "").toLowerCase()))
|
|
364
|
+
: new Set(SEARCH_EXTS);
|
|
365
|
+
const root = resolveRoot(cfg, sub);
|
|
366
|
+
const files = collectFiles(root, exts);
|
|
367
|
+
const matches = [];
|
|
368
|
+
let truncated = false;
|
|
369
|
+
outer: for (const abs of files) {
|
|
370
|
+
const text = readText(abs);
|
|
371
|
+
if (text === null)
|
|
372
|
+
continue;
|
|
373
|
+
const rel = toRes(abs, cfg.projectPath);
|
|
374
|
+
const lines = text.split("\n");
|
|
375
|
+
for (let i = 0; i < lines.length; i++) {
|
|
376
|
+
const m = re.exec(lines[i]);
|
|
377
|
+
if (m) {
|
|
378
|
+
matches.push({ file: rel, line: i + 1, column: (m.index ?? 0) + 1, text: clip(lines[i]) });
|
|
379
|
+
if (matches.length >= cap) {
|
|
380
|
+
truncated = true;
|
|
381
|
+
break outer;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return ok({ query, regex: Boolean(regex), matches, count: matches.length, truncated });
|
|
387
|
+
});
|
|
388
|
+
const SYMBOL_KINDS = [
|
|
389
|
+
{ kind: "class_name", re: /^\s*class_name\s+([A-Za-z_]\w*)/ },
|
|
390
|
+
{ kind: "class", re: /^\s*class\s+([A-Za-z_]\w*)/ },
|
|
391
|
+
{ kind: "func", re: /^\s*(?:static\s+)?func\s+([A-Za-z_]\w*)/ },
|
|
392
|
+
{ kind: "signal", re: /^\s*signal\s+([A-Za-z_]\w*)/ },
|
|
393
|
+
{ kind: "enum", re: /^\s*enum\s+([A-Za-z_]\w*)/ },
|
|
394
|
+
{ kind: "const", re: /^\s*const\s+([A-Za-z_]\w*)/ },
|
|
395
|
+
{ kind: "var", re: /^\s*(?:@\w+(?:\([^)]*\))?\s+)*(?:static\s+)?var\s+([A-Za-z_]\w*)/ },
|
|
396
|
+
];
|
|
397
|
+
server.registerTool("find_symbol", {
|
|
398
|
+
title: "Find symbol declarations",
|
|
399
|
+
description: "Locate where GDScript symbols are DECLARED across the project — class_name, inner class, func, " +
|
|
400
|
+
"signal, enum, const and var. Read-only project index. This is the workspace-symbol answer Godot's " +
|
|
401
|
+
"language server does not implement (gd_workspace_symbols returns 'unsupported'); for the semantic, " +
|
|
402
|
+
"position-based lookup on a single build use gd_definition instead.",
|
|
403
|
+
inputSchema: {
|
|
404
|
+
name: z.string().describe("Symbol name to look for"),
|
|
405
|
+
exact: z.boolean().optional().describe("Require an exact name match (default false = substring)"),
|
|
406
|
+
kinds: z.array(z.enum(["class_name", "class", "func", "signal", "enum", "const", "var"])).optional().describe("Restrict to these declaration kinds (default: all)"),
|
|
407
|
+
max_results: z.number().int().positive().optional().describe("Cap on returned declarations (default 200)"),
|
|
408
|
+
},
|
|
409
|
+
}, async ({ name, exact, kinds, max_results }) => {
|
|
410
|
+
const cap = max_results ?? 200;
|
|
411
|
+
const wanted = kinds && kinds.length ? new Set(kinds) : null;
|
|
412
|
+
const rules = SYMBOL_KINDS.filter((r) => !wanted || wanted.has(r.kind));
|
|
413
|
+
const files = collectFiles(cfg.projectPath, new Set(["gd"]));
|
|
414
|
+
const out = [];
|
|
415
|
+
let truncated = false;
|
|
416
|
+
outer: for (const abs of files) {
|
|
417
|
+
const text = readText(abs);
|
|
418
|
+
if (text === null)
|
|
419
|
+
continue;
|
|
420
|
+
const rel = toRes(abs, cfg.projectPath);
|
|
421
|
+
const lines = text.split("\n");
|
|
422
|
+
for (let i = 0; i < lines.length; i++) {
|
|
423
|
+
for (const r of rules) {
|
|
424
|
+
const m = r.re.exec(lines[i]);
|
|
425
|
+
if (!m)
|
|
426
|
+
continue;
|
|
427
|
+
const sym = m[1];
|
|
428
|
+
const hit = exact ? sym === name : sym.includes(name);
|
|
429
|
+
if (!hit)
|
|
430
|
+
continue;
|
|
431
|
+
out.push({ file: rel, line: i + 1, kind: r.kind, symbol: sym, text: clip(lines[i]) });
|
|
432
|
+
if (out.length >= cap) {
|
|
433
|
+
truncated = true;
|
|
434
|
+
break outer;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return ok({ name, matches: out, count: out.length, truncated });
|
|
440
|
+
});
|
|
441
|
+
server.registerTool("find_usages", {
|
|
442
|
+
title: "Find identifier usages",
|
|
443
|
+
description: "Find every word-boundary occurrence of an identifier across the project's source files. Read-only. " +
|
|
444
|
+
"The project-wide, build-independent complement to gd_references (which needs the live language server " +
|
|
445
|
+
"and a cursor position). Returns res:// path, 1-based line/column and the line text for each hit.",
|
|
446
|
+
inputSchema: {
|
|
447
|
+
name: z.string().describe("Identifier to find (matched on word boundaries)"),
|
|
448
|
+
extensions: z.array(z.string()).optional().describe("File extensions to include, without the dot (default: gd,cs,tscn,tres,gdshader)"),
|
|
449
|
+
ignore_case: z.boolean().optional().describe("Case-insensitive match (default false)"),
|
|
450
|
+
max_results: z.number().int().positive().optional().describe("Cap on returned usages (default 200)"),
|
|
451
|
+
},
|
|
452
|
+
}, async ({ name, extensions, ignore_case, max_results }) => {
|
|
453
|
+
const cap = max_results ?? 200;
|
|
454
|
+
if (!/\S/.test(name))
|
|
455
|
+
return fail("name must not be empty");
|
|
456
|
+
const re = new RegExp(`\\b${escapeRegExp(name)}\\b`, ignore_case ? "gi" : "g");
|
|
457
|
+
const exts = extensions && extensions.length
|
|
458
|
+
? new Set(extensions.map((e) => e.replace(/^\./, "").toLowerCase()))
|
|
459
|
+
: new Set(["gd", "cs", "tscn", "tres", "gdshader"]);
|
|
460
|
+
const files = collectFiles(cfg.projectPath, exts);
|
|
461
|
+
const out = [];
|
|
462
|
+
let truncated = false;
|
|
463
|
+
outer: for (const abs of files) {
|
|
464
|
+
const text = readText(abs);
|
|
465
|
+
if (text === null)
|
|
466
|
+
continue;
|
|
467
|
+
const rel = toRes(abs, cfg.projectPath);
|
|
468
|
+
const lines = text.split("\n");
|
|
469
|
+
for (let i = 0; i < lines.length; i++) {
|
|
470
|
+
re.lastIndex = 0;
|
|
471
|
+
let m;
|
|
472
|
+
while ((m = re.exec(lines[i])) !== null) {
|
|
473
|
+
out.push({ file: rel, line: i + 1, column: m.index + 1, text: clip(lines[i]) });
|
|
474
|
+
if (out.length >= cap) {
|
|
475
|
+
truncated = true;
|
|
476
|
+
break outer;
|
|
477
|
+
}
|
|
478
|
+
if (m.index === re.lastIndex)
|
|
479
|
+
re.lastIndex++; // guard against zero-width
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return ok({ name, usages: out, count: out.length, truncated });
|
|
484
|
+
});
|
|
485
|
+
server.registerTool("example_snippet", {
|
|
486
|
+
title: "Look up a GDScript idiom",
|
|
487
|
+
description: "Return curated, ready-to-adapt GDScript snippets for common Godot tasks (signals, autoload singletons, " +
|
|
488
|
+
"input, tweens, timers, scene changes, saving, RNG, groups, state machines, HTTP, @onready). Read-only. " +
|
|
489
|
+
"Omit the query to list every available topic id.",
|
|
490
|
+
inputSchema: {
|
|
491
|
+
query: z.string().optional().describe("Topic or keywords, e.g. \"tween\", \"save json\", \"connect signal\". Omit to list all topics."),
|
|
492
|
+
limit: z.number().int().positive().optional().describe("Max snippets to return (default 5)"),
|
|
493
|
+
},
|
|
494
|
+
}, async ({ query, limit }) => {
|
|
495
|
+
const cap = limit ?? 5;
|
|
496
|
+
let chosen;
|
|
497
|
+
if (!query || !query.trim()) {
|
|
498
|
+
chosen = [];
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
const terms = query.toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean);
|
|
502
|
+
chosen = SNIPPETS
|
|
503
|
+
.map((s) => ({ s, score: scoreSnippet(s, terms) }))
|
|
504
|
+
.filter((x) => x.score > 0)
|
|
505
|
+
.sort((a, b) => b.score - a.score)
|
|
506
|
+
.slice(0, cap)
|
|
507
|
+
.map((x) => x.s);
|
|
508
|
+
}
|
|
509
|
+
return ok({
|
|
510
|
+
query: query ?? null,
|
|
511
|
+
count: chosen.length,
|
|
512
|
+
snippets: chosen.map((s) => ({ id: s.id, title: s.title, tags: s.tags, code: s.code, explanation: s.explanation, docs_url: s.docs_url })),
|
|
513
|
+
available: SNIPPET_IDS,
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
//# sourceMappingURL=knowledge.js.map
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Shared, protocol-generic helpers for the LSP tool wrappers. Both the GDScript
|
|
2
|
+
// plane (tools/lsp.ts) and the C#/OmniSharp plane (tools/cslsp.ts) reshape the
|
|
3
|
+
// same standard LSP result types (Location, SymbolKind, CompletionItemKind,
|
|
4
|
+
// MarkupContent), so these live once here rather than being duplicated per plane.
|
|
5
|
+
// The generic MCP success-envelope helper `ok()` is the single shared envelope
|
|
6
|
+
// shape and is imported by the other tool modules too, not only the LSP planes.
|
|
7
|
+
// LSP CompletionItemKind numeric -> readable name.
|
|
8
|
+
export const COMPLETION_KIND = {
|
|
9
|
+
1: "text", 2: "method", 3: "function", 4: "constructor", 5: "field", 6: "variable",
|
|
10
|
+
7: "class", 8: "interface", 9: "module", 10: "property", 11: "unit", 12: "value",
|
|
11
|
+
13: "enum", 14: "keyword", 15: "snippet", 16: "color", 17: "file", 18: "reference",
|
|
12
|
+
19: "folder", 20: "enumMember", 21: "constant", 22: "struct", 23: "event", 24: "operator", 25: "typeParameter",
|
|
13
|
+
};
|
|
14
|
+
// LSP SymbolKind numeric -> readable name.
|
|
15
|
+
export const SYMBOL_KIND = {
|
|
16
|
+
1: "file", 2: "module", 3: "namespace", 4: "package", 5: "class", 6: "method", 7: "property",
|
|
17
|
+
8: "field", 9: "constructor", 10: "enum", 11: "interface", 12: "function", 13: "variable",
|
|
18
|
+
14: "constant", 15: "string", 16: "number", 17: "boolean", 18: "array", 19: "object",
|
|
19
|
+
20: "key", 21: "null", 22: "enumMember", 23: "struct", 24: "event", 25: "operator", 26: "typeParameter",
|
|
20
|
+
};
|
|
21
|
+
/** MCP success envelope: human-readable JSON text plus the structured content. */
|
|
22
|
+
export function ok(obj) {
|
|
23
|
+
return {
|
|
24
|
+
content: [{ type: "text", text: JSON.stringify(obj, null, 2) }],
|
|
25
|
+
structuredContent: obj,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** MCP error envelope for a failed LSP call (never throws to the caller). */
|
|
29
|
+
export function fail(err) {
|
|
30
|
+
const e = err;
|
|
31
|
+
return {
|
|
32
|
+
isError: true,
|
|
33
|
+
content: [{ type: "text", text: `LSP error [${e.code ?? "error"}]: ${e.message ?? String(err)}` }],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Normalize an LSP documentation / MarkupContent field (a plain string, a
|
|
38
|
+
* `{ kind, value }` MarkupContent, or an array of either) down to a single
|
|
39
|
+
* string. Used by hover-style and signature-help results.
|
|
40
|
+
*/
|
|
41
|
+
export function markupToString(c) {
|
|
42
|
+
if (typeof c === "string")
|
|
43
|
+
return c;
|
|
44
|
+
if (Array.isArray(c))
|
|
45
|
+
return c.map((x) => (typeof x === "string" ? x : x?.value ?? "")).join("\n");
|
|
46
|
+
if (c && typeof c === "object")
|
|
47
|
+
return c.value ?? "";
|
|
48
|
+
return "";
|
|
49
|
+
}
|
|
50
|
+
/** True for a JSON-RPC "method not found" (-32601) or an equivalent message. */
|
|
51
|
+
export function isMethodNotFound(err) {
|
|
52
|
+
const e = err;
|
|
53
|
+
return e.code === -32601 || /method not found/i.test(e.message ?? "");
|
|
54
|
+
}
|
|
55
|
+
/** Reshape one-or-many LSP Location / LocationLink results into a flat list. */
|
|
56
|
+
export function normalizeLocations(result) {
|
|
57
|
+
if (!result)
|
|
58
|
+
return [];
|
|
59
|
+
const arr = Array.isArray(result) ? result : [result];
|
|
60
|
+
return arr.map((l) => {
|
|
61
|
+
const loc = l;
|
|
62
|
+
const uri = loc.uri ?? loc.targetUri ?? "";
|
|
63
|
+
const range = loc.range ?? loc.targetSelectionRange ?? {};
|
|
64
|
+
return { uri, line: range.start?.line ?? 0, character: range.start?.character ?? 0 };
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
// ---- WorkspaceEdit application (shared by the gd_* and cs_* rename mutators) --
|
|
68
|
+
// Applying an LSP edit to a file needs a (line, character) -> absolute offset map
|
|
69
|
+
// and then splicing edits back-to-front so earlier edits don't shift later ones.
|
|
70
|
+
// These live here (rather than in one plane's tool file) because the GDScript and
|
|
71
|
+
// C#/OmniSharp rename tools apply identical edit math.
|
|
72
|
+
/** Absolute character offset of a (0-based line, 0-based character) in `text`. */
|
|
73
|
+
export function offsetOf(text, line, character) {
|
|
74
|
+
const lines = text.split("\n");
|
|
75
|
+
let offset = 0;
|
|
76
|
+
for (let i = 0; i < line && i < lines.length; i++)
|
|
77
|
+
offset += lines[i].length + 1;
|
|
78
|
+
return offset + character;
|
|
79
|
+
}
|
|
80
|
+
/** Apply LSP TextEdits to a string, splicing back-to-front so ranges stay valid. */
|
|
81
|
+
export function applyTextEdits(text, edits) {
|
|
82
|
+
const sorted = [...edits].sort((a, b) => {
|
|
83
|
+
const la = a.range.start?.line ?? 0, lb = b.range.start?.line ?? 0;
|
|
84
|
+
if (la !== lb)
|
|
85
|
+
return lb - la;
|
|
86
|
+
return (b.range.start?.character ?? 0) - (a.range.start?.character ?? 0);
|
|
87
|
+
});
|
|
88
|
+
let out = text;
|
|
89
|
+
for (const e of sorted) {
|
|
90
|
+
const start = offsetOf(out, e.range.start?.line ?? 0, e.range.start?.character ?? 0);
|
|
91
|
+
const end = offsetOf(out, e.range.end?.line ?? 0, e.range.end?.character ?? 0);
|
|
92
|
+
out = out.slice(0, start) + e.newText + out.slice(end);
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Normalize an LSP WorkspaceEdit into a plain `uri -> TextEdit[]` map. Handles
|
|
98
|
+
* BOTH encodings a server may return: the legacy `changes` object AND the
|
|
99
|
+
* versioned `documentChanges` array of `TextDocumentEdit`s (what OmniSharp emits
|
|
100
|
+
* for a rename). File resource operations (create/rename/delete) inside
|
|
101
|
+
* `documentChanges` carry no `edits` and are skipped.
|
|
102
|
+
*/
|
|
103
|
+
export function normalizeWorkspaceEdit(edit) {
|
|
104
|
+
const out = {};
|
|
105
|
+
const e = edit;
|
|
106
|
+
if (!e)
|
|
107
|
+
return out;
|
|
108
|
+
if (e.changes) {
|
|
109
|
+
for (const [uri, edits] of Object.entries(e.changes))
|
|
110
|
+
out[uri] = [...(out[uri] ?? []), ...(edits ?? [])];
|
|
111
|
+
}
|
|
112
|
+
if (Array.isArray(e.documentChanges)) {
|
|
113
|
+
for (const dc of e.documentChanges) {
|
|
114
|
+
const uri = dc?.textDocument?.uri;
|
|
115
|
+
if (uri && Array.isArray(dc.edits))
|
|
116
|
+
out[uri] = [...(out[uri] ?? []), ...dc.edits];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
//# sourceMappingURL=lsp-common.js.map
|