@npgamedev/godot-mcp-server 0.0.1 → 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/ATTRIBUTIONS.md +141 -0
- package/LICENSE +28 -0
- package/README.md +386 -4
- package/dist/extensions/extensionChanges.js +131 -0
- package/dist/extensions/extensionCommand.js +25 -0
- package/dist/extensions/extensionDiscovery.js +141 -0
- package/dist/extensions/extensionRegistrar.js +82 -0
- package/dist/extensions/extensions.js +33 -0
- package/dist/groups/builtinGroups.js +60 -0
- package/dist/groups/defs/3dTools.js +17 -0
- package/dist/groups/defs/animationAuthoring.js +16 -0
- package/dist/groups/defs/assetOps.js +6 -0
- package/dist/groups/defs/audio.js +6 -0
- package/dist/groups/defs/classdb.js +6 -0
- package/dist/groups/defs/cleanup.js +6 -0
- package/dist/groups/defs/debugger.js +6 -0
- package/dist/groups/defs/editorAdvanced.js +16 -0
- package/dist/groups/defs/inputMap.js +6 -0
- package/dist/groups/defs/layerNaming.js +6 -0
- package/dist/groups/defs/lspCodeAnalysis.js +23 -0
- package/dist/groups/defs/lspCodeNavigation.js +15 -0
- package/dist/groups/defs/navigation.js +17 -0
- package/dist/groups/defs/particles.js +21 -0
- package/dist/groups/defs/pathEditing.js +22 -0
- package/dist/groups/defs/placeholders.js +28 -0
- package/dist/groups/defs/procedural.js +6 -0
- package/dist/groups/defs/resourceIo.js +6 -0
- package/dist/groups/defs/runtimeAdvanced.js +17 -0
- package/dist/groups/defs/sceneAdvanced.js +6 -0
- package/dist/groups/defs/sceneInheritance.js +6 -0
- package/dist/groups/defs/signals.js +6 -0
- package/dist/groups/defs/spriteframes.js +16 -0
- package/dist/groups/defs/theme.js +6 -0
- package/dist/groups/defs/tilemap.js +6 -0
- package/dist/groups/defs/tileset.js +22 -0
- package/dist/groups/defs/tilesetEdit.js +21 -0
- package/dist/groups/defs/userData.js +6 -0
- package/dist/groups/extensionGroups.js +182 -0
- package/dist/groups/groupActivation.js +210 -0
- package/dist/groups/groupCatalogue.js +52 -0
- package/dist/groups/groupMatch.js +182 -0
- package/dist/groups/groupResult.js +16 -0
- package/dist/groups/groupState.js +15 -0
- package/dist/groups/groupToolHandlers.js +103 -0
- package/dist/groups/groupTypes.js +10 -0
- package/dist/groups/groups.js +205 -0
- package/dist/index.js +144 -0
- package/dist/lsp/lspClient.js +509 -0
- package/dist/lsp/lspLabels.js +105 -0
- package/dist/lsp/lspProjectScan.js +156 -0
- package/dist/lsp/lspSession.js +139 -0
- package/dist/lsp/lspStatusReporter.js +76 -0
- package/dist/lsp/lspUri.js +68 -0
- package/dist/mcp/prompts.js +59 -0
- package/dist/mcp/resources.js +113 -0
- package/dist/mcp/roots.js +30 -0
- package/dist/registration/catalogue.js +123 -0
- package/dist/registration/extensionCollision.js +38 -0
- package/dist/registration/operations.js +67 -0
- package/dist/registration/screenshotResponse.js +71 -0
- package/dist/registration/toolDispatch.js +76 -0
- package/dist/registration/toolMeta.js +104 -0
- package/dist/registration/toolRefs.js +48 -0
- package/dist/registration/toolRegistry.js +211 -0
- package/dist/registry.js +291 -0
- package/dist/registryLiveness.js +113 -0
- package/dist/security/pathGuard.js +97 -0
- package/dist/security/profiles.js +104 -0
- package/dist/security/untrusted.js +23 -0
- package/dist/shared/errorContract.js +154 -0
- package/dist/shared/errors.js +21 -0
- package/dist/shared/pagination.js +135 -0
- package/dist/shared/schemaCoercion.js +173 -0
- package/dist/shared/stableJson.js +27 -0
- package/dist/shared/types.js +1 -0
- package/dist/shared/version.js +82 -0
- package/dist/startup/cliArgs.js +103 -0
- package/dist/startup/configReload.js +57 -0
- package/dist/startup/hooks.js +89 -0
- package/dist/startup/lifecycle.js +22 -0
- package/dist/startup/portConfig.js +127 -0
- package/dist/startup/reconcile.js +81 -0
- package/dist/startup/registrars.js +59 -0
- package/dist/startup/serverMode.js +19 -0
- package/dist/startup/startupEnv.js +142 -0
- package/dist/tools/animation.js +88 -0
- package/dist/tools/asset.js +58 -0
- package/dist/tools/assetWrite.js +16 -0
- package/dist/tools/audio.js +40 -0
- package/dist/tools/classdb.js +43 -0
- package/dist/tools/collision.js +23 -0
- package/dist/tools/debug.js +46 -0
- package/dist/tools/diff.js +18 -0
- package/dist/tools/editor.js +222 -0
- package/dist/tools/file.js +38 -0
- package/dist/tools/folder.js +27 -0
- package/dist/tools/inputMap.js +54 -0
- package/dist/tools/layerNames.js +29 -0
- package/dist/tools/lsp.js +524 -0
- package/dist/tools/navigation.js +24 -0
- package/dist/tools/node.js +145 -0
- package/dist/tools/nodeManagement.js +82 -0
- package/dist/tools/particles.js +83 -0
- package/dist/tools/path.js +30 -0
- package/dist/tools/playtest.js +99 -0
- package/dist/tools/procedural.js +101 -0
- package/dist/tools/resource.js +47 -0
- package/dist/tools/runtime.js +338 -0
- package/dist/tools/save.js +64 -0
- package/dist/tools/scene.js +102 -0
- package/dist/tools/sceneInheritance.js +19 -0
- package/dist/tools/sceneQuery.js +38 -0
- package/dist/tools/script.js +75 -0
- package/dist/tools/signals.js +53 -0
- package/dist/tools/sound.js +31 -0
- package/dist/tools/spatial.js +41 -0
- package/dist/tools/spriteframes.js +94 -0
- package/dist/tools/texture.js +35 -0
- package/dist/tools/theme.js +35 -0
- package/dist/tools/threeD.js +116 -0
- package/dist/tools/tilemap.js +51 -0
- package/dist/tools/tileset.js +226 -0
- package/dist/transport/authHandshake.js +43 -0
- package/dist/transport/bridge.js +225 -0
- package/dist/transport/channel.js +352 -0
- package/dist/transport/heartbeat.js +54 -0
- package/dist/transport/runtimeConnection.js +239 -0
- package/dist/transport/tokenPath.js +98 -0
- package/package.json +97 -4
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/** Severity number → human-readable label. */
|
|
2
|
+
export function severityLabel(severity) {
|
|
3
|
+
switch (severity) {
|
|
4
|
+
case 1:
|
|
5
|
+
return "Error";
|
|
6
|
+
case 2:
|
|
7
|
+
return "Warning";
|
|
8
|
+
case 3:
|
|
9
|
+
return "Information";
|
|
10
|
+
case 4:
|
|
11
|
+
return "Hint";
|
|
12
|
+
default:
|
|
13
|
+
return "Unknown";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Map a raw {@link DiagnosticEntry} to its tool-response shape — the single
|
|
18
|
+
* source of the diagnostic-to-JSON contract shared by the single-file and
|
|
19
|
+
* project-wide diagnostics tools. Converts the LSP's 0-based line/character to
|
|
20
|
+
* 1-based for display, labels the numeric severity, and includes `code` only
|
|
21
|
+
* when the server supplied one.
|
|
22
|
+
*/
|
|
23
|
+
export function formatDiagnostic(d) {
|
|
24
|
+
return {
|
|
25
|
+
line: d.line + 1,
|
|
26
|
+
character: d.character + 1,
|
|
27
|
+
severity: severityLabel(d.severity),
|
|
28
|
+
message: d.message,
|
|
29
|
+
...(d.code != null ? { code: d.code } : {}),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export function formatSymbol(sym) {
|
|
33
|
+
const s = sym;
|
|
34
|
+
const result = {
|
|
35
|
+
name: s.name ?? "",
|
|
36
|
+
kind: symbolKindLabel(s.kind),
|
|
37
|
+
start_line: (s.range?.start?.line ?? 0) + 1,
|
|
38
|
+
end_line: (s.range?.end?.line ?? 0) + 1,
|
|
39
|
+
};
|
|
40
|
+
if (s.children && s.children.length > 0) {
|
|
41
|
+
result.children = s.children.map(formatSymbol);
|
|
42
|
+
}
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
export function symbolKindLabel(kind) {
|
|
46
|
+
const kinds = {
|
|
47
|
+
1: "File",
|
|
48
|
+
2: "Module",
|
|
49
|
+
3: "Namespace",
|
|
50
|
+
4: "Package",
|
|
51
|
+
5: "Class",
|
|
52
|
+
6: "Method",
|
|
53
|
+
7: "Property",
|
|
54
|
+
8: "Field",
|
|
55
|
+
9: "Constructor",
|
|
56
|
+
10: "Enum",
|
|
57
|
+
11: "Interface",
|
|
58
|
+
12: "Function",
|
|
59
|
+
13: "Variable",
|
|
60
|
+
14: "Constant",
|
|
61
|
+
15: "String",
|
|
62
|
+
16: "Number",
|
|
63
|
+
17: "Boolean",
|
|
64
|
+
18: "Array",
|
|
65
|
+
19: "Object",
|
|
66
|
+
20: "Key",
|
|
67
|
+
21: "Null",
|
|
68
|
+
22: "EnumMember",
|
|
69
|
+
23: "Struct",
|
|
70
|
+
24: "Event",
|
|
71
|
+
25: "Operator",
|
|
72
|
+
26: "TypeParameter",
|
|
73
|
+
};
|
|
74
|
+
return kinds[kind ?? 0] ?? "Unknown";
|
|
75
|
+
}
|
|
76
|
+
export function completionKindLabel(kind) {
|
|
77
|
+
const kinds = {
|
|
78
|
+
1: "Text",
|
|
79
|
+
2: "Method",
|
|
80
|
+
3: "Function",
|
|
81
|
+
4: "Constructor",
|
|
82
|
+
5: "Field",
|
|
83
|
+
6: "Variable",
|
|
84
|
+
7: "Class",
|
|
85
|
+
8: "Interface",
|
|
86
|
+
9: "Module",
|
|
87
|
+
10: "Property",
|
|
88
|
+
11: "Unit",
|
|
89
|
+
12: "Value",
|
|
90
|
+
13: "Enum",
|
|
91
|
+
14: "Keyword",
|
|
92
|
+
15: "Snippet",
|
|
93
|
+
16: "Color",
|
|
94
|
+
17: "File",
|
|
95
|
+
18: "Reference",
|
|
96
|
+
19: "Folder",
|
|
97
|
+
20: "EnumMember",
|
|
98
|
+
21: "Constant",
|
|
99
|
+
22: "Struct",
|
|
100
|
+
23: "Event",
|
|
101
|
+
24: "Operator",
|
|
102
|
+
25: "TypeParameter",
|
|
103
|
+
};
|
|
104
|
+
return kinds[kind ?? 0] ?? "Unknown";
|
|
105
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project-wide GDScript enumeration + scan-result aggregation for the
|
|
3
|
+
* `lsp_project_diagnostics` tool. Two separable concerns kept apart so the
|
|
4
|
+
* shaping is unit-testable without a filesystem or a live LSP:
|
|
5
|
+
*
|
|
6
|
+
* 1. {@link enumerateGdFiles} — a `node:fs` recursive walk that lists the
|
|
7
|
+
* `.gd` files the Godot editor would index. A disk walk (not `asset.list`)
|
|
8
|
+
* is deliberate: the LSP subsystem is bridge-free, and a walk also sees
|
|
9
|
+
* just-written files the EditorFileSystem has not rescanned yet.
|
|
10
|
+
* 2. {@link aggregateScan} — folds per-file classification records into the
|
|
11
|
+
* compact response payload and asserts the scan invariant.
|
|
12
|
+
*
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
15
|
+
import { readdir } from "node:fs/promises";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { formatDiagnostic } from "./lspLabels.js";
|
|
18
|
+
// LSP diagnostic severity 1 = Error (2 = Warning, 3 = Information, 4 = Hint).
|
|
19
|
+
// The errors-only default keeps a file with only warnings classified as clean.
|
|
20
|
+
const SEVERITY_ERROR = 1;
|
|
21
|
+
// ── File enumeration ───────────────────────────────────────────────────
|
|
22
|
+
/**
|
|
23
|
+
* List every `.gd` file under `projectRoot` that Godot's editor would index,
|
|
24
|
+
* as `res://`-relative paths. Pure I/O — no LSP, no bridge.
|
|
25
|
+
*
|
|
26
|
+
* Exclusions mirror the engine's own indexing so the scan targets exactly the
|
|
27
|
+
* files the LSP can compile:
|
|
28
|
+
* - `.gd` only — shaders (no real LSP diagnostics) and `.cs` (external
|
|
29
|
+
* toolchain) are skipped.
|
|
30
|
+
* - Dot-directories (`.godot/`, `.git/`, `.import/`, …) — never indexed.
|
|
31
|
+
* - Any directory holding a `.gdignore` file — engine parity
|
|
32
|
+
* (`EditorFileSystem` skips these subtrees).
|
|
33
|
+
* - The top-level `res://addons/` unless `includeAddons` is true (a nested
|
|
34
|
+
* `foo/addons/` is not special).
|
|
35
|
+
* - Symlinked/junction directory entries — not followed (avoids cycles and
|
|
36
|
+
* walking outside the project).
|
|
37
|
+
*
|
|
38
|
+
* @param projectRoot absolute path to the Godot project root
|
|
39
|
+
* @param opts.includeAddons also scan the top-level `res://addons/` subtree
|
|
40
|
+
* @returns deduplicated `res://`-relative paths, order-insensitive
|
|
41
|
+
*/
|
|
42
|
+
export async function enumerateGdFiles(projectRoot, opts) {
|
|
43
|
+
const found = new Set();
|
|
44
|
+
await walkDir(projectRoot, "", found, opts.includeAddons, true);
|
|
45
|
+
return [...found];
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Recurse one directory, appending discovered `.gd` files to `found`.
|
|
49
|
+
* `relPrefix` is the `res://`-relative path of `dirAbs` (empty at the root).
|
|
50
|
+
* `atRoot` marks the project root so the `addons/` exclusion applies only to
|
|
51
|
+
* the top-level directory.
|
|
52
|
+
*/
|
|
53
|
+
async function walkDir(dirAbs, relPrefix, found, includeAddons, atRoot) {
|
|
54
|
+
let entries;
|
|
55
|
+
try {
|
|
56
|
+
entries = await readdir(dirAbs, { withFileTypes: true });
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// An unreadable directory (permissions, vanished mid-walk) contributes no
|
|
60
|
+
// files — the scan continues rather than failing wholesale.
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
// A `.gdignore` anywhere in this directory excludes the whole subtree, per
|
|
64
|
+
// EditorFileSystem — so decide before descending into any child.
|
|
65
|
+
if (entries.some((e) => e.isFile() && e.name === ".gdignore"))
|
|
66
|
+
return;
|
|
67
|
+
for (const entry of entries) {
|
|
68
|
+
const name = entry.name;
|
|
69
|
+
const rel = relPrefix ? `${relPrefix}/${name}` : name;
|
|
70
|
+
if (entry.isDirectory()) {
|
|
71
|
+
if (name.startsWith("."))
|
|
72
|
+
continue; // .godot/, .git/, .import/, …
|
|
73
|
+
if (atRoot && name === "addons" && !includeAddons)
|
|
74
|
+
continue;
|
|
75
|
+
await walkDir(join(dirAbs, name), rel, found, includeAddons, false);
|
|
76
|
+
}
|
|
77
|
+
else if (entry.isFile()) {
|
|
78
|
+
// Shaders (.gdshader/.gdshaderinc) and .cs are intentionally excluded —
|
|
79
|
+
// the LSP produces no real diagnostics for them.
|
|
80
|
+
if (name.endsWith(".gd"))
|
|
81
|
+
found.add(`res://${rel}`);
|
|
82
|
+
}
|
|
83
|
+
// Symlink/junction dirents fall through here: isDirectory()/isFile() are
|
|
84
|
+
// both false for a symlink Dirent (withFileTypes does not stat-follow), so
|
|
85
|
+
// they are skipped without a stat that would follow the link.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Fold per-file scan results into the compact response payload.
|
|
90
|
+
*
|
|
91
|
+
* Classification is already decided per file (the caller applied the
|
|
92
|
+
* `include_warnings` filter before building each record, so a `diagnostics`
|
|
93
|
+
* record here is non-empty and non-clean by construction). This function only
|
|
94
|
+
* counts and shapes: `clean` counts clean files, dirty files carry their
|
|
95
|
+
* mapped diagnostics, and `timed_out`/`read_failed` collect their paths.
|
|
96
|
+
*
|
|
97
|
+
* @returns the payload with empty `timed_out`/`read_failed`/`note` omitted
|
|
98
|
+
* @throws Error if the scan invariant
|
|
99
|
+
* `scanned === clean + files_with_diagnostics.length + timed_out.length + read_failed.length`
|
|
100
|
+
* does not hold — a programming error in the caller's bookkeeping
|
|
101
|
+
*/
|
|
102
|
+
export function aggregateScan(results, timeoutSeconds) {
|
|
103
|
+
const filesWithDiagnostics = [];
|
|
104
|
+
const timedOut = [];
|
|
105
|
+
const readFailed = [];
|
|
106
|
+
let clean = 0;
|
|
107
|
+
let totalDiagnostics = 0;
|
|
108
|
+
for (const r of results) {
|
|
109
|
+
switch (r.kind) {
|
|
110
|
+
case "clean":
|
|
111
|
+
clean++;
|
|
112
|
+
break;
|
|
113
|
+
case "diagnostics":
|
|
114
|
+
filesWithDiagnostics.push({ file_path: r.filePath, diagnostics: r.diagnostics.map(formatDiagnostic) });
|
|
115
|
+
totalDiagnostics += r.diagnostics.length;
|
|
116
|
+
break;
|
|
117
|
+
case "timed_out":
|
|
118
|
+
timedOut.push(r.filePath);
|
|
119
|
+
break;
|
|
120
|
+
case "read_failed":
|
|
121
|
+
readFailed.push(r.filePath);
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const scanned = results.length;
|
|
126
|
+
const accounted = clean + filesWithDiagnostics.length + timedOut.length + readFailed.length;
|
|
127
|
+
if (scanned !== accounted) {
|
|
128
|
+
throw new Error(`project scan invariant violated: scanned ${scanned} != accounted ${accounted}`);
|
|
129
|
+
}
|
|
130
|
+
const payload = {
|
|
131
|
+
success: true,
|
|
132
|
+
scanned,
|
|
133
|
+
clean,
|
|
134
|
+
files_with_diagnostics: filesWithDiagnostics,
|
|
135
|
+
total_diagnostics: totalDiagnostics,
|
|
136
|
+
};
|
|
137
|
+
if (timedOut.length > 0) {
|
|
138
|
+
payload.timed_out = timedOut;
|
|
139
|
+
payload.note = `${timedOut.length} file(s) produced no diagnostics notification within ${timeoutSeconds}s — status unknown, NOT clean.`;
|
|
140
|
+
}
|
|
141
|
+
if (readFailed.length > 0)
|
|
142
|
+
payload.read_failed = readFailed;
|
|
143
|
+
return payload;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Keep only Error-severity diagnostics when warnings are not requested.
|
|
147
|
+
* A file left with zero entries after this filter classifies as clean.
|
|
148
|
+
*
|
|
149
|
+
* @param diagnostics the raw diagnostics for one file
|
|
150
|
+
* @param includeWarnings when false, drop Warning/Info/Hint (severity > 1)
|
|
151
|
+
*/
|
|
152
|
+
export function filterBySeverity(diagnostics, includeWarnings) {
|
|
153
|
+
if (includeWarnings)
|
|
154
|
+
return diagnostics;
|
|
155
|
+
return diagnostics.filter((d) => d.severity === SEVERITY_ERROR);
|
|
156
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LSP session layer — the stateful connection core behind the LSP tools.
|
|
3
|
+
*
|
|
4
|
+
* Owns the lazy singleton {@link LspClient}, the verified-verdict status reporter
|
|
5
|
+
* wired by the status-reporter module, the connect prologue (ensureLsp) with its
|
|
6
|
+
* code/hint mapping, and the file-read + document-open helpers the tool handlers
|
|
7
|
+
* build on. The thin LSP tool surface sits over {@link withLspDoc}.
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
import { readFile } from "node:fs/promises";
|
|
12
|
+
import { toolError } from "../shared/errorContract.js";
|
|
13
|
+
import { LspClient, LspResolutionError } from "./lspClient.js";
|
|
14
|
+
import { resToAbsolute, absoluteToFileUri } from "./lspUri.js";
|
|
15
|
+
// ── Shared validation ──────────────────────────────────────────────
|
|
16
|
+
function validateGdscriptPath(filePath) {
|
|
17
|
+
if (!filePath.startsWith("res://")) {
|
|
18
|
+
return toolError("INVALID_PATH", "file_path must start with res://");
|
|
19
|
+
}
|
|
20
|
+
if (filePath.endsWith(".gd") || filePath.endsWith(".gdshader") || filePath.endsWith(".gdshaderinc")) {
|
|
21
|
+
return undefined; // Supported.
|
|
22
|
+
}
|
|
23
|
+
// Unsupported file type — Godot's built-in LSP only serves GDScript and shaders.
|
|
24
|
+
if (filePath.endsWith(".cs")) {
|
|
25
|
+
return toolError("UNSUPPORTED_FILE_TYPE", "Godot's built-in LSP only covers GDScript (.gd) and shaders (.gdshader). " +
|
|
26
|
+
"C# (.cs) diagnostics come from the .NET language server in your IDE (VS Code, Rider).");
|
|
27
|
+
}
|
|
28
|
+
return toolError("UNSUPPORTED_FILE_TYPE", "Godot's built-in LSP only covers .gd and .gdshader/.gdshaderinc files. " +
|
|
29
|
+
"Other languages (C++, Rust, Python via GDExtension) use external toolchains with no Godot LSP integration.");
|
|
30
|
+
}
|
|
31
|
+
// ── Connection state ───────────────────────────────────────────────
|
|
32
|
+
/** Singleton LSP client (lazy, shared across all LSP tool calls). */
|
|
33
|
+
let lspClient = undefined;
|
|
34
|
+
function getLspClient(projectPath) {
|
|
35
|
+
if (!lspClient)
|
|
36
|
+
lspClient = new LspClient(projectPath);
|
|
37
|
+
return lspClient;
|
|
38
|
+
}
|
|
39
|
+
/** Set by index.ts to push the VERIFIED LSP verdict (the actual connection
|
|
40
|
+
* result) to the editor dock after each connection attempt — so the dock
|
|
41
|
+
* reflects reality on actual use: it flips to active once a closed editor frees
|
|
42
|
+
* the port and this LSP rebinds (4.5 only), or to unavailable on every other
|
|
43
|
+
* version, which has no bind retry. */
|
|
44
|
+
let statusReporter = undefined;
|
|
45
|
+
/** Inject the dock status reporter (startup wiring). @internal */
|
|
46
|
+
export function setLspStatusReporter(cb) {
|
|
47
|
+
statusReporter = cb;
|
|
48
|
+
}
|
|
49
|
+
// ── Connection prologue ────────────────────────────────────────────
|
|
50
|
+
/**
|
|
51
|
+
* Build the connect-failure hint for the LSP_UNAVAILABLE branch below. Causes
|
|
52
|
+
* are ordered by likelihood given the editor is usually up — the calling agent
|
|
53
|
+
* is already using other MCP tools that need it — so the "editor not running"
|
|
54
|
+
* theory (the cause most callers can already rule out) comes LAST. The common
|
|
55
|
+
* real cause is the GDScript LSP not listening on the port we tried: the editor
|
|
56
|
+
* may have been launched with --lsp-port (which the registry can't see), so the
|
|
57
|
+
* server connected to the default and got ECONNREFUSED. Pure + exported so the
|
|
58
|
+
* ordering/contents are unit-testable without a live client.
|
|
59
|
+
* @internal
|
|
60
|
+
*/
|
|
61
|
+
export function lspConnectFailureHint(port) {
|
|
62
|
+
return (`Could not reach the GDScript LSP on port ${port}. Most likely the LSP is listening on a ` +
|
|
63
|
+
`different port — the editor may have been launched with --lsp-port, or its ` +
|
|
64
|
+
`network/language_server/remote_port setting differs from ${port}; set GODOT_MCP_LSP_PORT to ` +
|
|
65
|
+
`the actual LSP port to match. The LSP may also still be initializing — retry shortly. ` +
|
|
66
|
+
`Only if no other MCP tool works at all is the editor not running.`);
|
|
67
|
+
}
|
|
68
|
+
/** Connect prologue: return the connected singleton {@link LspClient}, or a
|
|
69
|
+
* tool-error result carrying the LSP failure code + hint. Exported for the
|
|
70
|
+
* project-scan handler, which drives the client directly over many files. @internal */
|
|
71
|
+
export async function ensureLsp(projectPath) {
|
|
72
|
+
const client = getLspClient(projectPath);
|
|
73
|
+
try {
|
|
74
|
+
await client.ensureConnected();
|
|
75
|
+
const ep = client.getEndpoint();
|
|
76
|
+
statusReporter?.({ state: "active", host: ep.host, port: ep.port, detail: "Connected and verified." });
|
|
77
|
+
return client;
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
// Resolution errors carry a specific code + hint (LSP_PORT_CONFLICT /
|
|
81
|
+
// LSP_UNAVAILABLE); a raw connect failure is a generic LSP_UNAVAILABLE.
|
|
82
|
+
if (err instanceof LspResolutionError) {
|
|
83
|
+
statusReporter?.({
|
|
84
|
+
state: err.code === "LSP_PORT_CONFLICT" ? "conflict" : "unavailable",
|
|
85
|
+
host: "127.0.0.1",
|
|
86
|
+
port: err.port,
|
|
87
|
+
detail: err.message,
|
|
88
|
+
});
|
|
89
|
+
return toolError(err.code, err.message, err.hint);
|
|
90
|
+
}
|
|
91
|
+
// Connect failure (e.g. ECONNREFUSED) — report the endpoint we actually tried.
|
|
92
|
+
const ep = client.getEndpoint();
|
|
93
|
+
statusReporter?.({ state: "unavailable", host: ep.host, port: ep.port, detail: err.message });
|
|
94
|
+
return toolError("LSP_UNAVAILABLE", `GDScript LSP unavailable: ${err.message}.`, lspConnectFailureHint(ep.port));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// ── Document I/O ───────────────────────────────────────────────────
|
|
98
|
+
async function readFileContent(filePath, projectPath) {
|
|
99
|
+
const absPath = resToAbsolute(filePath, projectPath);
|
|
100
|
+
try {
|
|
101
|
+
return await readFile(absPath, "utf-8");
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
return toolError("READ_FAILED", `Cannot read ${filePath}: ${err.message}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** Read a `res://` file and open it in the LSP, returning its `file://` URI —
|
|
108
|
+
* or a tool-error result if the read failed (e.g. the file vanished after the
|
|
109
|
+
* walk). Exported for the project-scan handler's per-file open loop. @internal */
|
|
110
|
+
export async function openDocInLsp(client, filePath, projectPath) {
|
|
111
|
+
const content = await readFileContent(filePath, projectPath);
|
|
112
|
+
if (typeof content !== "string")
|
|
113
|
+
return content; // Error result.
|
|
114
|
+
const absPath = resToAbsolute(filePath, projectPath);
|
|
115
|
+
const uri = absoluteToFileUri(absPath);
|
|
116
|
+
await client.openDocument(uri, content);
|
|
117
|
+
return { uri };
|
|
118
|
+
}
|
|
119
|
+
// ── Prologue fold ──────────────────────────────────────────────────
|
|
120
|
+
/**
|
|
121
|
+
* The shared LSP-tool prologue, folded into a single call: validate the path,
|
|
122
|
+
* ensure the LSP connection, then open the document. Returns the connected
|
|
123
|
+
* client together with the opened document URI, or the first error result
|
|
124
|
+
* (checked in order: path → connect → open). Every LSP handler runs this before
|
|
125
|
+
* issuing its request.
|
|
126
|
+
*/
|
|
127
|
+
export async function withLspDoc(filePath, projectPath) {
|
|
128
|
+
const pathErr = validateGdscriptPath(filePath);
|
|
129
|
+
if (pathErr)
|
|
130
|
+
return pathErr;
|
|
131
|
+
const clientOrErr = await ensureLsp(projectPath);
|
|
132
|
+
if ("content" in clientOrErr)
|
|
133
|
+
return clientOrErr;
|
|
134
|
+
const client = clientOrErr;
|
|
135
|
+
const openResult = await openDocInLsp(client, filePath, projectPath);
|
|
136
|
+
if ("content" in openResult)
|
|
137
|
+
return openResult;
|
|
138
|
+
return { client, uri: openResult.uri };
|
|
139
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LSP status reporter — pushes the GDScript-LSP verdict to the editor dock
|
|
3
|
+
* (editor.set_lsp_status). The editor can't read its own LSP bind status, so
|
|
4
|
+
* the server reports it.
|
|
5
|
+
*
|
|
6
|
+
* Two verdict sources share a single lastLspKey dedup so frequent LSP calls
|
|
7
|
+
* don't spam the bridge:
|
|
8
|
+
* - Verified verdicts from actual LSP tool calls — wired via setLspStatusReporter
|
|
9
|
+
* at construction (the real connection result, accurate across versions).
|
|
10
|
+
* - The registry-derived verdict (reportRegistryVerdict) — no LSP handshake,
|
|
11
|
+
* just resolution; pushed on bridge connect/reconnect so a freshly-connected
|
|
12
|
+
* editor gets the current status, later refined by the verified result.
|
|
13
|
+
*
|
|
14
|
+
* Construction also wires the version-tailored conflict-hint getter
|
|
15
|
+
* (setGodotVersionGetter: 4.5 auto-rebind vs distinct-port everywhere else).
|
|
16
|
+
*/
|
|
17
|
+
import { getLspStatus, setGodotVersionGetter } from "./lspClient.js";
|
|
18
|
+
import { setLspStatusReporter } from "../tools/lsp.js";
|
|
19
|
+
/** Wires the verified-verdict reporter (setLspStatusReporter, de-duped via
|
|
20
|
+
* lastLspKey) and the version-tailored conflict-hint getter (setGodotVersionGetter)
|
|
21
|
+
* at construction, then returns the registry-verdict pusher. */
|
|
22
|
+
export function createLspStatusReporter(deps) {
|
|
23
|
+
const { bridge, projectPath } = deps;
|
|
24
|
+
// Push the GDScript LSP verdict to the editor dock (editor.set_lsp_status) —
|
|
25
|
+
// the editor can't read its own LSP bind status, so the server reports it.
|
|
26
|
+
function sendLspStatus(s) {
|
|
27
|
+
try {
|
|
28
|
+
void bridge.call("editor.set_lsp_status", s, 3000).catch(() => { });
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
/* never let UI status reporting disrupt the bridge */
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// Verified verdicts from actual LSP tool calls (the real connection result —
|
|
35
|
+
// accurate across versions), de-duped so frequent LSP calls don't spam the bridge.
|
|
36
|
+
let lastLspKey = "";
|
|
37
|
+
// Counts verified verdicts, NOT pushes: it is bumped before the de-dupe check, so
|
|
38
|
+
// a verdict that merely confirms the current key still registers. A registry
|
|
39
|
+
// verdict resolving later compares this to the value it captured at launch, which
|
|
40
|
+
// lastLspKey alone cannot express — a confirming verified verdict leaves the key
|
|
41
|
+
// byte-identical, and the registry verdict would then overwrite verified truth.
|
|
42
|
+
let verifiedVerdicts = 0;
|
|
43
|
+
setLspStatusReporter((s) => {
|
|
44
|
+
verifiedVerdicts++;
|
|
45
|
+
const key = `${s.state}:${s.host}:${s.port}`;
|
|
46
|
+
if (key === lastLspKey)
|
|
47
|
+
return;
|
|
48
|
+
lastLspKey = key;
|
|
49
|
+
sendLspStatus(s);
|
|
50
|
+
});
|
|
51
|
+
// Version-tailored LSP conflict hints (4.5 auto-rebind vs distinct-port everywhere else).
|
|
52
|
+
setGodotVersionGetter(() => bridge.getGodotVersion());
|
|
53
|
+
return {
|
|
54
|
+
/** On bridge connect/reconnect: push the registry verdict (resolution only, no
|
|
55
|
+
* LSP handshake) so a freshly-connected editor gets the current status; later
|
|
56
|
+
* LSP tool calls refine it with the verified result. */
|
|
57
|
+
reportRegistryVerdict() {
|
|
58
|
+
// Fire-and-forget: computing the verdict probes peer WS ports, so it is
|
|
59
|
+
// async, but a dock status push must never delay or fail the bridge connect
|
|
60
|
+
// that triggered it.
|
|
61
|
+
const verdictsAtLaunch = verifiedVerdicts;
|
|
62
|
+
void getLspStatus(projectPath)
|
|
63
|
+
.then((s) => {
|
|
64
|
+
// Any verified verdict that landed while the probes were in flight came
|
|
65
|
+
// from a real connection, so it outranks this one — drop ours rather than
|
|
66
|
+
// regress the dock to a registry-derived guess. A single fail-closed
|
|
67
|
+
// indeterminate probe is enough to turn this verdict into `conflict`.
|
|
68
|
+
if (verifiedVerdicts !== verdictsAtLaunch)
|
|
69
|
+
return;
|
|
70
|
+
lastLspKey = `${s.state}:${s.host}:${s.port}`;
|
|
71
|
+
sendLspStatus(s);
|
|
72
|
+
})
|
|
73
|
+
.catch(() => { });
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure URI / path translation between Godot's `res://` virtual paths and
|
|
3
|
+
* `file://` URIs (and back), plus URI normalization for diagnostics map
|
|
4
|
+
* lookups. Leaf module — zero project dependencies; shared by the LSP tool
|
|
5
|
+
* layer and the LSP client.
|
|
6
|
+
*/
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
/** Translate a `res://` virtual path to an absolute filesystem path under the project root. */
|
|
9
|
+
export function resToAbsolute(resPath, projectPath) {
|
|
10
|
+
// res://foo/bar.gd → <projectPath>/foo/bar.gd
|
|
11
|
+
const relative = resPath.replace(/^res:\/\//, "");
|
|
12
|
+
return join(projectPath, relative);
|
|
13
|
+
}
|
|
14
|
+
/** Convert an absolute filesystem path to a `file://` URI (drive-letter and POSIX forms). */
|
|
15
|
+
export function absoluteToFileUri(absPath) {
|
|
16
|
+
// Windows: C:\foo\bar.gd → file:///C:/foo/bar.gd
|
|
17
|
+
// Unix: /foo/bar.gd → file:///foo/bar.gd
|
|
18
|
+
const normalized = absPath.replace(/\\/g, "/");
|
|
19
|
+
if (/^[A-Za-z]:/.test(normalized)) {
|
|
20
|
+
return `file:///${normalized}`;
|
|
21
|
+
}
|
|
22
|
+
return `file://${normalized}`;
|
|
23
|
+
}
|
|
24
|
+
/** Map a `file://` URI back to a `res://` path when it falls inside the project; return the input unchanged otherwise. */
|
|
25
|
+
export function fileUriToRes(uri, projectPath) {
|
|
26
|
+
// file:///C:/project/foo.gd → res://foo.gd (Windows)
|
|
27
|
+
// file:///home/project/foo.gd → res://foo.gd (POSIX)
|
|
28
|
+
if (!uri.startsWith("file://")) {
|
|
29
|
+
return uri; // Not a file URI, return as-is.
|
|
30
|
+
}
|
|
31
|
+
let absPath = uri.slice(7); // Strip "file://"; a drive form keeps a leading "/".
|
|
32
|
+
// Decode percent-encoding BEFORE the drive-letter test below. Godot's LSP
|
|
33
|
+
// emits the Windows drive colon as %3A (file:///C%3A/…); decoding first
|
|
34
|
+
// makes it a literal ":" so the `/<letter>:` drive form is recognized.
|
|
35
|
+
// Testing the still-encoded URI would miss %3A, keep the spurious leading
|
|
36
|
+
// slash, and break the project-prefix match — leaking a raw file:// URI for
|
|
37
|
+
// an in-project file.
|
|
38
|
+
absPath = decodeURIComponent(absPath);
|
|
39
|
+
// A Windows drive-letter URI (file:///C:/…) carries a leading slash that a
|
|
40
|
+
// POSIX URI (file:///home/…) MUST keep — or the project-prefix test below
|
|
41
|
+
// never matches — but the drive form must shed. Drop it only for the drive
|
|
42
|
+
// form; host-independent, so the same URI converts identically on Windows
|
|
43
|
+
// and POSIX.
|
|
44
|
+
if (/^\/[A-Za-z]:/.test(absPath)) {
|
|
45
|
+
absPath = absPath.slice(1);
|
|
46
|
+
}
|
|
47
|
+
// Normalize slashes.
|
|
48
|
+
const normalizedProject = projectPath.replace(/\\/g, "/").replace(/\/$/, "");
|
|
49
|
+
const normalizedPath = absPath.replace(/\\/g, "/");
|
|
50
|
+
// Strip project prefix to get res:// path.
|
|
51
|
+
if (normalizedPath.toLowerCase().startsWith(normalizedProject.toLowerCase())) {
|
|
52
|
+
const relative = normalizedPath.slice(normalizedProject.length);
|
|
53
|
+
return "res:/" + relative; // normalizedPath starts with / after project path
|
|
54
|
+
}
|
|
55
|
+
return uri; // Outside project — return raw.
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Normalize a file URI for map lookups. Godot's LSP may return URIs
|
|
59
|
+
* with different drive-letter casing or percent-encoding than we send.
|
|
60
|
+
*/
|
|
61
|
+
export function normalizeUri(uri) {
|
|
62
|
+
let norm = decodeURIComponent(uri).replace(/\\/g, "/");
|
|
63
|
+
// Lowercase Windows drive letter: file:///C: → file:///c:
|
|
64
|
+
if (/^file:\/\/\/[A-Z]:/.test(norm)) {
|
|
65
|
+
norm = "file:///" + norm[8].toLowerCase() + norm.slice(9);
|
|
66
|
+
}
|
|
67
|
+
return norm;
|
|
68
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** Register the built-in prompt templates (`debug-scene`, `write-test`) on the server. */
|
|
3
|
+
export function registerPrompts(server) {
|
|
4
|
+
// ── debug-scene ──────────────────────────────────────────────────────
|
|
5
|
+
server.prompt("debug-scene", "Inspect a scene subtree and diagnose common issues", { node_path: z.string().describe("Path to the root node to inspect (e.g. '.' for scene root)") }, async ({ node_path }) => ({
|
|
6
|
+
messages: [
|
|
7
|
+
{
|
|
8
|
+
role: "user",
|
|
9
|
+
content: {
|
|
10
|
+
type: "text",
|
|
11
|
+
text: [
|
|
12
|
+
`Inspect the scene subtree starting at node path "${node_path}".`,
|
|
13
|
+
"",
|
|
14
|
+
"Steps:",
|
|
15
|
+
"1. Call scene_get_tree with depth 4 and include_properties true.",
|
|
16
|
+
"2. Check for common issues:",
|
|
17
|
+
" - Nodes without scripts that probably need one",
|
|
18
|
+
" - Missing collision shapes on physics bodies",
|
|
19
|
+
" - Sprites without textures assigned",
|
|
20
|
+
" - Signals that are connected but point to missing methods",
|
|
21
|
+
"3. Call editor_get_console to see if there are compile errors.",
|
|
22
|
+
"4. Summarize findings with suggested fixes.",
|
|
23
|
+
].join("\n"),
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
}));
|
|
28
|
+
// ── write-test ───────────────────────────────────────────────────────
|
|
29
|
+
server.prompt("write-test", "Generate a GDScript test for a file using GUT or GdUnit4", {
|
|
30
|
+
file_path: z.string().describe("Path to the GDScript file to test (e.g. res://player.gd)"),
|
|
31
|
+
framework: z.enum(["gut", "gdunit4"]).optional().describe("Test framework (default: gut)"),
|
|
32
|
+
}, async ({ file_path, framework }) => {
|
|
33
|
+
const fw = framework ?? "gut";
|
|
34
|
+
return {
|
|
35
|
+
messages: [
|
|
36
|
+
{
|
|
37
|
+
role: "user",
|
|
38
|
+
content: {
|
|
39
|
+
type: "text",
|
|
40
|
+
text: [
|
|
41
|
+
`Generate a ${fw.toUpperCase()} test file for "${file_path}".`,
|
|
42
|
+
"",
|
|
43
|
+
"Steps:",
|
|
44
|
+
`1. Read the source file with script_read.`,
|
|
45
|
+
"2. Identify public functions, signals, and exported properties.",
|
|
46
|
+
`3. Write a test file following ${fw.toUpperCase()} conventions:`,
|
|
47
|
+
fw === "gut"
|
|
48
|
+
? " - Extend GutTest, prefix test functions with test_"
|
|
49
|
+
: " - Extend GdUnitTestSuite, use assert_that() matchers",
|
|
50
|
+
"4. Cover at least: initialization, each public method, edge cases.",
|
|
51
|
+
`5. Save the test file next to the source with a _test suffix.`,
|
|
52
|
+
"6. Run editor_get_console to verify no syntax issues.",
|
|
53
|
+
].join("\n"),
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
}
|