@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.
Files changed (129) hide show
  1. package/ATTRIBUTIONS.md +141 -0
  2. package/LICENSE +28 -0
  3. package/README.md +386 -4
  4. package/dist/extensions/extensionChanges.js +131 -0
  5. package/dist/extensions/extensionCommand.js +25 -0
  6. package/dist/extensions/extensionDiscovery.js +141 -0
  7. package/dist/extensions/extensionRegistrar.js +82 -0
  8. package/dist/extensions/extensions.js +33 -0
  9. package/dist/groups/builtinGroups.js +60 -0
  10. package/dist/groups/defs/3dTools.js +17 -0
  11. package/dist/groups/defs/animationAuthoring.js +16 -0
  12. package/dist/groups/defs/assetOps.js +6 -0
  13. package/dist/groups/defs/audio.js +6 -0
  14. package/dist/groups/defs/classdb.js +6 -0
  15. package/dist/groups/defs/cleanup.js +6 -0
  16. package/dist/groups/defs/debugger.js +6 -0
  17. package/dist/groups/defs/editorAdvanced.js +16 -0
  18. package/dist/groups/defs/inputMap.js +6 -0
  19. package/dist/groups/defs/layerNaming.js +6 -0
  20. package/dist/groups/defs/lspCodeAnalysis.js +23 -0
  21. package/dist/groups/defs/lspCodeNavigation.js +15 -0
  22. package/dist/groups/defs/navigation.js +17 -0
  23. package/dist/groups/defs/particles.js +21 -0
  24. package/dist/groups/defs/pathEditing.js +22 -0
  25. package/dist/groups/defs/placeholders.js +28 -0
  26. package/dist/groups/defs/procedural.js +6 -0
  27. package/dist/groups/defs/resourceIo.js +6 -0
  28. package/dist/groups/defs/runtimeAdvanced.js +17 -0
  29. package/dist/groups/defs/sceneAdvanced.js +6 -0
  30. package/dist/groups/defs/sceneInheritance.js +6 -0
  31. package/dist/groups/defs/signals.js +6 -0
  32. package/dist/groups/defs/spriteframes.js +16 -0
  33. package/dist/groups/defs/theme.js +6 -0
  34. package/dist/groups/defs/tilemap.js +6 -0
  35. package/dist/groups/defs/tileset.js +22 -0
  36. package/dist/groups/defs/tilesetEdit.js +21 -0
  37. package/dist/groups/defs/userData.js +6 -0
  38. package/dist/groups/extensionGroups.js +182 -0
  39. package/dist/groups/groupActivation.js +210 -0
  40. package/dist/groups/groupCatalogue.js +52 -0
  41. package/dist/groups/groupMatch.js +182 -0
  42. package/dist/groups/groupResult.js +16 -0
  43. package/dist/groups/groupState.js +15 -0
  44. package/dist/groups/groupToolHandlers.js +103 -0
  45. package/dist/groups/groupTypes.js +10 -0
  46. package/dist/groups/groups.js +205 -0
  47. package/dist/index.js +144 -0
  48. package/dist/lsp/lspClient.js +509 -0
  49. package/dist/lsp/lspLabels.js +105 -0
  50. package/dist/lsp/lspProjectScan.js +156 -0
  51. package/dist/lsp/lspSession.js +139 -0
  52. package/dist/lsp/lspStatusReporter.js +76 -0
  53. package/dist/lsp/lspUri.js +68 -0
  54. package/dist/mcp/prompts.js +59 -0
  55. package/dist/mcp/resources.js +113 -0
  56. package/dist/mcp/roots.js +30 -0
  57. package/dist/registration/catalogue.js +123 -0
  58. package/dist/registration/extensionCollision.js +38 -0
  59. package/dist/registration/operations.js +67 -0
  60. package/dist/registration/screenshotResponse.js +71 -0
  61. package/dist/registration/toolDispatch.js +76 -0
  62. package/dist/registration/toolMeta.js +104 -0
  63. package/dist/registration/toolRefs.js +48 -0
  64. package/dist/registration/toolRegistry.js +211 -0
  65. package/dist/registry.js +291 -0
  66. package/dist/registryLiveness.js +113 -0
  67. package/dist/security/pathGuard.js +97 -0
  68. package/dist/security/profiles.js +104 -0
  69. package/dist/security/untrusted.js +23 -0
  70. package/dist/shared/errorContract.js +154 -0
  71. package/dist/shared/errors.js +21 -0
  72. package/dist/shared/pagination.js +135 -0
  73. package/dist/shared/schemaCoercion.js +173 -0
  74. package/dist/shared/stableJson.js +27 -0
  75. package/dist/shared/types.js +1 -0
  76. package/dist/shared/version.js +82 -0
  77. package/dist/startup/cliArgs.js +103 -0
  78. package/dist/startup/configReload.js +57 -0
  79. package/dist/startup/hooks.js +89 -0
  80. package/dist/startup/lifecycle.js +22 -0
  81. package/dist/startup/portConfig.js +127 -0
  82. package/dist/startup/reconcile.js +81 -0
  83. package/dist/startup/registrars.js +59 -0
  84. package/dist/startup/serverMode.js +19 -0
  85. package/dist/startup/startupEnv.js +142 -0
  86. package/dist/tools/animation.js +88 -0
  87. package/dist/tools/asset.js +58 -0
  88. package/dist/tools/assetWrite.js +16 -0
  89. package/dist/tools/audio.js +40 -0
  90. package/dist/tools/classdb.js +43 -0
  91. package/dist/tools/collision.js +23 -0
  92. package/dist/tools/debug.js +46 -0
  93. package/dist/tools/diff.js +18 -0
  94. package/dist/tools/editor.js +222 -0
  95. package/dist/tools/file.js +38 -0
  96. package/dist/tools/folder.js +27 -0
  97. package/dist/tools/inputMap.js +54 -0
  98. package/dist/tools/layerNames.js +29 -0
  99. package/dist/tools/lsp.js +524 -0
  100. package/dist/tools/navigation.js +24 -0
  101. package/dist/tools/node.js +145 -0
  102. package/dist/tools/nodeManagement.js +82 -0
  103. package/dist/tools/particles.js +83 -0
  104. package/dist/tools/path.js +30 -0
  105. package/dist/tools/playtest.js +99 -0
  106. package/dist/tools/procedural.js +101 -0
  107. package/dist/tools/resource.js +47 -0
  108. package/dist/tools/runtime.js +338 -0
  109. package/dist/tools/save.js +64 -0
  110. package/dist/tools/scene.js +102 -0
  111. package/dist/tools/sceneInheritance.js +19 -0
  112. package/dist/tools/sceneQuery.js +38 -0
  113. package/dist/tools/script.js +75 -0
  114. package/dist/tools/signals.js +53 -0
  115. package/dist/tools/sound.js +31 -0
  116. package/dist/tools/spatial.js +41 -0
  117. package/dist/tools/spriteframes.js +94 -0
  118. package/dist/tools/texture.js +35 -0
  119. package/dist/tools/theme.js +35 -0
  120. package/dist/tools/threeD.js +116 -0
  121. package/dist/tools/tilemap.js +51 -0
  122. package/dist/tools/tileset.js +226 -0
  123. package/dist/transport/authHandshake.js +43 -0
  124. package/dist/transport/bridge.js +225 -0
  125. package/dist/transport/channel.js +352 -0
  126. package/dist/transport/heartbeat.js +54 -0
  127. package/dist/transport/runtimeConnection.js +239 -0
  128. package/dist/transport/tokenPath.js +98 -0
  129. 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
+ }