@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,211 @@
1
+ import { isReadOnly, isExcludedByReadOnly } from "../security/profiles.js";
2
+ import { setToolRef } from "./toolRefs.js";
3
+ import { isVersionCompatible } from "../shared/version.js";
4
+ import { checkPathGuard } from "../security/pathGuard.js";
5
+ import { toolError } from "../shared/errorContract.js";
6
+ import { isRawJsonSchema, jsonSchemaToZodShape, addStringCoercion } from "../shared/schemaCoercion.js";
7
+ import { callAndWrap, injectSuccessHint } from "./toolDispatch.js";
8
+ /** Global hook pipeline — set once at startup via setGlobalHookPipeline. */
9
+ let globalHookPipeline = undefined;
10
+ /**
11
+ * Set the global hook pipeline. Called once at server startup.
12
+ * @internal
13
+ */
14
+ export function setGlobalHookPipeline(pipeline) {
15
+ globalHookPipeline = pipeline;
16
+ }
17
+ /** Version gate map — populated by registerToolWrapped callers. */
18
+ const versionMap = new Map();
19
+ /**
20
+ * Plain-language, inclusive, range-aware support clause for a version-gated
21
+ * tool's bounds. Used ONLY in the UNSUPPORTED error hint (the runtime version
22
+ * gate below) — never in a success/regular hint, tool-def successHint, or
23
+ * schema description. The gate guarantees at least one bound is set, so the
24
+ * final return covers the max-only case. The "–" between bounds is an en-dash
25
+ * (U+2013).
26
+ * @internal
27
+ */
28
+ export function versionSupportText(min, max) {
29
+ if (min && max)
30
+ return `Supported on Godot ${min}–${max} (inclusive).`;
31
+ if (min)
32
+ return `Requires Godot ${min} or newer.`;
33
+ return `Supported up to Godot ${max} (inclusive).`;
34
+ }
35
+ /**
36
+ * Path-guard map (built-in tools only) — name → declared PathGuards. Consulted
37
+ * in the dispatch choke point (wrappedHandler) to syntactically pre-filter
38
+ * path params before the bridge round-trip. Extension tools register without
39
+ * pathParams, so they never have an entry here (toolkit enforces their guards).
40
+ */
41
+ const pathParamMap = new Map();
42
+ /**
43
+ * Suppress per-tool sendToolListChanged() notifications during a batch
44
+ * operation, then emit a single notification at the end. Use this when
45
+ * registering multiple tools in a tight loop.
46
+ */
47
+ export function batchToolRegistration(server, fn) {
48
+ const orig = server.sendToolListChanged.bind(server);
49
+ server.sendToolListChanged = () => { };
50
+ try {
51
+ fn();
52
+ }
53
+ finally {
54
+ server.sendToolListChanged = orig;
55
+ server.sendToolListChanged();
56
+ }
57
+ }
58
+ /**
59
+ * Register one tool through the wrapped, pre-flighted path — the **only**
60
+ * sanctioned way to install a tool. Wraps the SDK handler with a runtime version
61
+ * gate, a syntactic path pre-filter, and the hook pipeline, then records the tool
62
+ * ref for later lookup and in-place description refresh.
63
+ *
64
+ * @param name - the tool's MCP wire name (what the client calls)
65
+ * @param config - the SDK tool config (description, `inputSchema`, annotations);
66
+ * raw JSON-Schema from extensions is converted to Zod, and string coercion is
67
+ * added so agents may pass JSON-encoded scalars for array/object/number params
68
+ * @param handler - the dispatch function invoked on a call, after every pre-flight check passes
69
+ * @param opts - version bounds, an explicit hook pipeline (falls back to the
70
+ * global one), and path-guard declarations to pre-filter before the bridge round-trip
71
+ *
72
+ * @remarks
73
+ * Version-gated tools are filtered out at registration when the connected Godot
74
+ * version is known and incompatible, and **skipped** when the version is not yet
75
+ * known — the startup reconcile re-runs registration once it resolves. A second,
76
+ * defence-in-depth version check runs per call to catch a reconnect to a different
77
+ * Godot version.
78
+ *
79
+ * @example
80
+ * ```ts
81
+ * registerToolWrapped(
82
+ * server,
83
+ * bridge,
84
+ * "my_tool",
85
+ * { description: "…", inputSchema: { path: z.string() } },
86
+ * (input) => handleMyTool(bridge, input),
87
+ * { godotMinVersion: "4.5", pathParams: [{ param: "path", guard: "project" }] },
88
+ * );
89
+ * ```
90
+ */
91
+ export function registerToolWrapped(server, bridge, name,
92
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- McpServer.registerTool has complex overloaded types
93
+ config, handler, opts = {}) {
94
+ // Convert raw JSON Schema (from extensions) to Zod shape for SDK compat.
95
+ if (config.inputSchema && isRawJsonSchema(config.inputSchema)) {
96
+ config = { ...config, inputSchema: jsonSchemaToZodShape(config.inputSchema) };
97
+ }
98
+ // Add LLM string coercion to all Zod shapes — agents sometimes send
99
+ // JSON-encoded strings for array/object/number params.
100
+ if (config.inputSchema && !isRawJsonSchema(config.inputSchema)) {
101
+ config = { ...config, inputSchema: addStringCoercion(config.inputSchema) };
102
+ }
103
+ if (opts.godotMinVersion != null || opts.godotMaxVersion != null) {
104
+ versionMap.set(name, { min: opts.godotMinVersion, max: opts.godotMaxVersion });
105
+ }
106
+ if (opts.pathParams != null && opts.pathParams.length > 0) {
107
+ pathParamMap.set(name, opts.pathParams);
108
+ }
109
+ // Registration-time version filter: skip version-gated tools when the
110
+ // connected Godot version is known and incompatible.
111
+ if (opts.godotMinVersion != null || opts.godotMaxVersion != null) {
112
+ const connected = bridge.getGodotVersion();
113
+ if (connected == null) {
114
+ // Version unknown — skip the tool (don't register something we can't
115
+ // verify). It is registered once the version resolves: the version-
116
+ // resolved startup reconcile re-runs registration when the editor first
117
+ // reports its version (startup/reconcile.ts maybeStartupReconcile →
118
+ // handleConfigReload — the server-before-editor cold start), and any
119
+ // later reconnect re-runs it through handleConfigReload as well.
120
+ return;
121
+ }
122
+ if (!isVersionCompatible(connected, opts.godotMinVersion, opts.godotMaxVersion)) {
123
+ return;
124
+ }
125
+ }
126
+ // The SDK passes (args, extra) to tool handlers; extra.signal is an
127
+ // AbortSignal that fires when the MCP client sends notifications/cancelled.
128
+ // Defensive: extra may be undefined if the SDK omits it (observed with
129
+ // some client versions) — use optional chaining.
130
+ const wrappedHandler = async (input, extra) => {
131
+ const signal = extra?.signal;
132
+ // Defence-in-depth: runtime version check for version-gated tools
133
+ // (catches reconnect to a different Godot version).
134
+ const verBounds = versionMap.get(name);
135
+ if (verBounds != null) {
136
+ const connected = bridge.getGodotVersion();
137
+ if (connected != null && !isVersionCompatible(connected, verBounds.min, verBounds.max)) {
138
+ const supported = versionSupportText(verBounds.min, verBounds.max);
139
+ return toolError("UNSUPPORTED", `${name} is not supported on this Godot version (connected: ${connected[0]}.${connected[1]})`, `${supported} Use classdb.get_info for alternatives.`);
140
+ }
141
+ }
142
+ // Syntactic path pre-filter (built-in tools only) — fast-fail an
143
+ // out-of-bounds path before the WS round-trip. Strict subset of the
144
+ // toolkit's FileGuard (the authoritative boundary); the invariant lives
145
+ // in src/security/pathGuard.ts.
146
+ const guards = pathParamMap.get(name);
147
+ if (guards) {
148
+ for (const g of guards) {
149
+ const verdict = checkPathGuard(g, input?.[g.param]);
150
+ if (!verdict.ok) {
151
+ return toolError("PATH_DENIED", `path rejected (${g.param}): ${verdict.reason}`, "Use a project-relative res:// path (user:// for save_* tools). The toolkit is the authoritative guard.");
152
+ }
153
+ }
154
+ }
155
+ // Hook pipeline (explicit or global)
156
+ const pipeline = opts.hookPipeline ?? globalHookPipeline;
157
+ if (pipeline) {
158
+ return pipeline.execute({ name, input: (input ?? {}) }, () => handler(input, signal));
159
+ }
160
+ return handler(input, signal);
161
+ };
162
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- handler 2-arg shape doesn't match SDK overloads
163
+ const ref = server.registerTool(name, config, wrappedHandler);
164
+ setToolRef(name, ref);
165
+ }
166
+ /**
167
+ * Bulk-register an array of {@link ToolDef}s, each through
168
+ * {@link registerToolWrapped}. The default handler calls the bridge and
169
+ * JSON-stringifies the result — the path most built-in tool modules follow.
170
+ *
171
+ * @param tools - the tool definitions to register (catalogue order preserved)
172
+ * @param allowedTools - when set, an allowlist: a tool absent from the set is
173
+ * skipped (the per-module surface filter); omit to register every tool
174
+ * @param opts - `handlers` supplies per-tool overrides for modules with custom
175
+ * response shaping (screenshots, summary-first) — these still get `successHint`
176
+ * injection; `hookPipeline` overrides the global pipeline
177
+ *
178
+ * @remarks
179
+ * In read-only mode, tools excluded by their annotations are skipped here — the
180
+ * same gate the live SDK surface enforces.
181
+ */
182
+ export function registerTools(server, bridge, tools, allowedTools, opts = {}) {
183
+ const readOnly = isReadOnly();
184
+ for (const tool of tools) {
185
+ if (allowedTools && !allowedTools.has(tool.name))
186
+ continue;
187
+ if (isExcludedByReadOnly(readOnly, tool.annotations))
188
+ continue;
189
+ const description = tool.description;
190
+ const customHandler = opts.handlers?.get(tool.name);
191
+ let handler = (customHandler ??
192
+ ((input, signal) => callAndWrap(bridge, tool.method, input, { signal, successHint: tool.successHint })));
193
+ // Custom handlers bypass callAndWrap, so inject successHint via wrapper.
194
+ if (customHandler && tool.successHint) {
195
+ const baseHandler = handler;
196
+ const hintText = tool.successHint;
197
+ handler = (async (input, signal) => {
198
+ const result = await baseHandler(input, signal);
199
+ if (!result.isError)
200
+ injectSuccessHint(result, hintText);
201
+ return result;
202
+ });
203
+ }
204
+ registerToolWrapped(server, bridge, tool.name, { description, inputSchema: tool.inputSchema, annotations: tool.annotations }, handler, {
205
+ godotMinVersion: tool.godotMinVersion,
206
+ godotMaxVersion: tool.godotMaxVersion,
207
+ hookPipeline: opts.hookPipeline,
208
+ pathParams: tool.pathParams,
209
+ });
210
+ }
211
+ }
@@ -0,0 +1,291 @@
1
+ /**
2
+ * System-wide project registry reader.
3
+ *
4
+ * Mirrors the GDScript `registry_client.gd` — same file, same schema, same path
5
+ * normalisation. The plugin writes; this module reads. Resolves a project's
6
+ * editor / runtime / LSP endpoints by absolute path, and optionally watches the
7
+ * registry file so runtime-port changes push to the bridge without per-RPC I/O.
8
+ *
9
+ * Reading includes deciding whether an entry's owner is still there: the plugin
10
+ * has no reliable liveness signal of its own and prunes nothing, so entries
11
+ * accumulate. That check is delegated to `registryLiveness` — still a read, kept
12
+ * out of here so the socket mechanics don't blur the schema reader.
13
+ *
14
+ * @module
15
+ */
16
+ import { readFileSync, watch, statSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { homedir } from "node:os";
19
+ import { isPidAlive, wsPortNotRefused } from "./registryLiveness.js";
20
+ // -- Path helpers ------------------------------------------------------------
21
+ /** Canonical path form: forward slashes, no trailing slash, lowercase on Windows. */
22
+ export function normalizePath(p) {
23
+ let n = p.replace(/\\/g, "/").replace(/\/+$/, "");
24
+ // Windows and macOS default filesystems are case-insensitive; lowercase
25
+ // avoids mismatches between Godot's globalize_path and Node.js
26
+ // process.cwd() which may differ in casing.
27
+ if (process.platform === "win32" || process.platform === "darwin")
28
+ n = n.toLowerCase();
29
+ return n;
30
+ }
31
+ /** OS-specific registry file path — must match registry_client.gd. */
32
+ export function registryPath() {
33
+ switch (process.platform) {
34
+ case "win32":
35
+ return join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "godot-mcp-toolkit", "projects.json");
36
+ case "darwin":
37
+ return join(homedir(), "Library", "Application Support", "godot-mcp-toolkit", "projects.json");
38
+ default:
39
+ return join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), "godot-mcp-toolkit", "projects.json");
40
+ }
41
+ }
42
+ // -- Registry I/O ------------------------------------------------------------
43
+ function readRegistry() {
44
+ // Single parse attempt — no retry, no busy-wait. The toolkit writes the
45
+ // registry atomically (.tmp → rename, atomic on POSIX *and* Windows), so a
46
+ // reader always sees a complete prior-or-new file, never a partial one. A
47
+ // parse failure is therefore genuine corruption that re-reading the same
48
+ // bytes cannot fix, and a missing file (ENOENT, first run) is the expected
49
+ // "no registry yet" state. Either way we degrade gracefully to empty.
50
+ try {
51
+ const raw = readFileSync(registryPath(), "utf-8");
52
+ const data = JSON.parse(raw);
53
+ if (data && typeof data.by_path === "object")
54
+ return data;
55
+ }
56
+ catch {
57
+ /* ENOENT (no registry yet) or corrupt file — fall through to empty. */
58
+ }
59
+ return { by_path: {} };
60
+ }
61
+ /**
62
+ * Look up a project by its absolute path. Returns the entry or null.
63
+ * The path is normalised before lookup (backslashes → forward slashes,
64
+ * trailing slash stripped) so Windows CWD and GDScript registry keys match.
65
+ */
66
+ export function lookupProject(projectPath) {
67
+ const key = normalizePath(projectPath);
68
+ const registry = readRegistry();
69
+ return registry.by_path[key] ?? null;
70
+ }
71
+ /**
72
+ * Return the runtime_port for a project, or null if no playtest is active.
73
+ * Re-reads the file on every call so newly-started playtests are picked up.
74
+ */
75
+ export function discoverRuntime(projectPath) {
76
+ const entry = lookupProject(projectPath);
77
+ if (!entry)
78
+ return null;
79
+ const port = entry.runtime_port;
80
+ if (port == null || !Number.isInteger(port) || port < 1024 || port > 65535)
81
+ return null;
82
+ // Skip a port whose owning playtest process is provably dead. The toolkit has
83
+ // no PID-based GC (OS.is_process_running is unreliable on Windows), so a
84
+ // crashed playtest leaves runtime_port set until the next register() clears
85
+ // it; without this gate the bridge would attempt a doomed connect. A null
86
+ // runtime_pid (no recorded owner) does not block.
87
+ //
88
+ // A live PID is all this path asks for — deliberately weaker than the LSP
89
+ // claimant predicate below. A wrong runtime port announces itself (the connect
90
+ // fails into a visible GAME_NOT_RUNNING, and the channel has its own
91
+ // per-instance auth handshake), whereas a wrong LSP port silently returns
92
+ // another project's symbols; and this runs on every runtime RPC, not once per
93
+ // connection, so a socket probe here would tax the whole game_* surface.
94
+ if (entry.runtime_pid != null && !isPidAlive(entry.runtime_pid))
95
+ return null;
96
+ return port;
97
+ }
98
+ // -- LSP endpoint discovery --------------------------------------------------
99
+ //
100
+ // Godot's GDScript LSP binds one machine-wide port (default 6005); the toolkit
101
+ // publishes each editor's setting-derived endpoint into its registry entry.
102
+ // We resolve per-project and detect collisions server-side (the toolkit can't
103
+ // read whether its own bind won). See lsp/lspClient.ts (resolveLspEndpoint).
104
+ /**
105
+ * Every editor still credibly claiming a given LSP port — all of them, not just
106
+ * the newest.
107
+ *
108
+ * A claimant counts only when it is **pid-alive AND the WS command port its own
109
+ * entry advertises does not refuse a connection**. The PID check comes first
110
+ * because it is free and settles the provably-dead entries; the port probe is what
111
+ * establishes *identity*, since the projection never prunes cross-project entries and
112
+ * they all default to the same engine LSP port — so a stale entry whose recorded
113
+ * PID has been recycled to any unrelated process would otherwise resurrect a
114
+ * closed editor as a rival claimant. An inconclusive probe leaves the claimant
115
+ * counted — `registryLiveness.classifyProbeOutcome` carries why that direction is
116
+ * the safe one. Probes run concurrently, so the added latency is one round trip,
117
+ * not one per candidate.
118
+ */
119
+ export async function liveLspClaimants(port) {
120
+ const registry = readRegistry();
121
+ const candidates = [];
122
+ for (const [path, entry] of Object.entries(registry.by_path)) {
123
+ if (entry.lsp_port !== port)
124
+ continue;
125
+ if (!isPidAlive(entry.pid))
126
+ continue;
127
+ candidates.push({ path, entry });
128
+ }
129
+ const notRefused = await Promise.all(candidates.map((c) => wsPortNotRefused(c.entry.port)));
130
+ return candidates.filter((_, i) => notRefused[i]);
131
+ }
132
+ /**
133
+ * Resolve this project's published LSP endpoint, with conservative ownership.
134
+ * { host, port } — we own it: connect (then verify rootUri on 4.5+).
135
+ * { conflict, port } — a corroborated peer started at-or-before us holds the port.
136
+ * null — no usable entry; the caller applies the miss rule (conditional 6005).
137
+ *
138
+ * Connect only if strictly the EARLIEST corroborated claimant: the engine gives
139
+ * the port to whoever listen()s first, and started_at is a safe proxy for starts
140
+ * >~1s apart. A genuine same-second tie fails BOTH sides — never wrong data.
141
+ */
142
+ export async function discoverLspEndpoint(projectPath) {
143
+ const entry = lookupProject(projectPath);
144
+ if (!entry || entry.lsp_port == null)
145
+ return null;
146
+ const port = entry.lsp_port;
147
+ const claimants = await liveLspClaimants(port);
148
+ const peers = claimants.filter((c) => c.entry.pid !== entry.pid);
149
+ if (peers.some((c) => c.entry.started_at <= entry.started_at))
150
+ return { conflict: true, port };
151
+ return { host: entry.lsp_host ?? "127.0.0.1", port };
152
+ }
153
+ // -- Registry watcher ----------------------------------------------------------
154
+ //
155
+ // Watches projects.json via fs.watch and fires callbacks when a project's
156
+ // runtime_port transitions (null→port, port→null, port→different port).
157
+ // Replaces per-RPC file reads with in-memory lookups when active.
158
+ //
159
+ // Safety net: a 30s stat-poll heartbeat catches silent watcher death (rare),
160
+ // inode replacement on Linux/macOS, and the file-not-yet-created case (P2).
161
+ let watcher = undefined;
162
+ let cachedRegistry = { by_path: {} };
163
+ let runtimeDiscoveredCb = undefined;
164
+ let runtimeRemovedCb = undefined;
165
+ let debounceTimer;
166
+ let heartbeatTimer = undefined;
167
+ let lastKnownMtimeMs = 0;
168
+ function diffAndNotify(cached, fresh) {
169
+ const allKeys = new Set([...Object.keys(cached.by_path), ...Object.keys(fresh.by_path)]);
170
+ for (const key of allKeys) {
171
+ const oldPort = cached.by_path[key]?.runtime_port ?? null;
172
+ const newPort = fresh.by_path[key]?.runtime_port ?? null;
173
+ if (oldPort === newPort)
174
+ continue;
175
+ // Tear down before connect so the bridge never holds two channels
176
+ // to the same project simultaneously (important for port-change).
177
+ if (oldPort != null && oldPort > 0) {
178
+ runtimeRemovedCb?.(key);
179
+ }
180
+ if (newPort != null && newPort > 0) {
181
+ runtimeDiscoveredCb?.(key, newPort);
182
+ }
183
+ }
184
+ }
185
+ /** Debounced handler shared by fs.watch and heartbeat-triggered re-watches. */
186
+ function handleRegistryChange() {
187
+ clearTimeout(debounceTimer);
188
+ debounceTimer = setTimeout(() => {
189
+ const fresh = readRegistry();
190
+ diffAndNotify(cachedRegistry, fresh);
191
+ cachedRegistry = fresh;
192
+ // Sync mtime so the heartbeat doesn't re-fire for this same change.
193
+ try {
194
+ lastKnownMtimeMs = statSync(registryPath()).mtimeMs;
195
+ }
196
+ catch {
197
+ /* file may have been deleted — heartbeat handles it */
198
+ }
199
+ }, 100);
200
+ }
201
+ function onWatchError() {
202
+ watcher?.close();
203
+ watcher = undefined;
204
+ }
205
+ /** Try to establish (or re-establish) fs.watch on projects.json. */
206
+ function tryStartWatcher() {
207
+ if (watcher)
208
+ return true;
209
+ try {
210
+ watcher = watch(registryPath(), { persistent: false }, handleRegistryChange);
211
+ watcher.on("error", onWatchError);
212
+ return true;
213
+ }
214
+ catch {
215
+ return false;
216
+ }
217
+ }
218
+ /**
219
+ * 30s stat-poll heartbeat. Covers:
220
+ * - Silent watcher death (all platforms, rare)
221
+ * - Inode replacement on Linux/macOS (file replaced via atomic rename)
222
+ * - File not yet created at startup (P2) — retries watcher start
223
+ */
224
+ function startHeartbeat() {
225
+ try {
226
+ lastKnownMtimeMs = statSync(registryPath()).mtimeMs;
227
+ }
228
+ catch {
229
+ /* file may not exist yet */
230
+ }
231
+ heartbeatTimer = setInterval(() => {
232
+ // Re-establish watcher if it died or never started (P2: ENOENT at init).
233
+ if (!watcher)
234
+ tryStartWatcher();
235
+ try {
236
+ const mtime = statSync(registryPath()).mtimeMs;
237
+ if (mtime !== lastKnownMtimeMs) {
238
+ lastKnownMtimeMs = mtime;
239
+ // Watcher missed this change — force re-read and diff.
240
+ const fresh = readRegistry();
241
+ diffAndNotify(cachedRegistry, fresh);
242
+ cachedRegistry = fresh;
243
+ }
244
+ }
245
+ catch {
246
+ // File gone — if we had entries, treat as full removal.
247
+ if (Object.keys(cachedRegistry.by_path).length > 0) {
248
+ const fresh = { by_path: {} };
249
+ diffAndNotify(cachedRegistry, fresh);
250
+ cachedRegistry = fresh;
251
+ }
252
+ }
253
+ }, 30_000);
254
+ heartbeatTimer.unref?.();
255
+ }
256
+ /**
257
+ * Start watching projects.json for runtime port changes.
258
+ *
259
+ * Falls back silently when fs.watch is unavailable or the file doesn't
260
+ * exist yet — isWatcherActive() returns false and callRuntime uses
261
+ * per-RPC file reads. The heartbeat retries watcher creation every 30s.
262
+ */
263
+ export function watchRegistry(callbacks) {
264
+ runtimeDiscoveredCb = callbacks.onDiscovered;
265
+ runtimeRemovedCb = callbacks.onRemoved;
266
+ cachedRegistry = readRegistry();
267
+ tryStartWatcher();
268
+ startHeartbeat();
269
+ }
270
+ /** Stop watching and clean up. Safe to call even if never started. */
271
+ export function unwatchRegistry() {
272
+ clearTimeout(debounceTimer);
273
+ if (heartbeatTimer) {
274
+ clearInterval(heartbeatTimer);
275
+ heartbeatTimer = undefined;
276
+ }
277
+ watcher?.close();
278
+ watcher = undefined;
279
+ }
280
+ /** True when fs.watch is active and cachedRegistry is kept fresh. */
281
+ export function isWatcherActive() {
282
+ return watcher !== undefined;
283
+ }
284
+ /**
285
+ * Read runtime_port from the in-memory cache (zero I/O).
286
+ * Returns null if no runtime is registered or the watcher hasn't seen one.
287
+ */
288
+ export function getCachedRuntimePort(projectPath) {
289
+ const key = normalizePath(projectPath);
290
+ return cachedRegistry.by_path[key]?.runtime_port ?? null;
291
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Liveness corroboration for registry entries.
3
+ *
4
+ * Answers one question about a `projects.json` entry: is the process it recorded
5
+ * still the editor that wrote it? A PID existence check alone cannot say — PIDs
6
+ * are a bounded, recycled resource, so a dead editor's leftover entry looks live
7
+ * the moment its number is handed to some unrelated process. The corroboration is
8
+ * the entry's own advertised WebSocket command port: a closed editor is not
9
+ * listening there, and a recycled PID cannot fake it.
10
+ *
11
+ * Deliberately knows nothing about the registry schema — it takes plain numbers,
12
+ * so the reader depends on it and never the reverse.
13
+ *
14
+ * @module
15
+ */
16
+ import { createConnection } from "node:net";
17
+ /** The toolkit's WebSocket command server binds loopback only. */
18
+ const PROBE_HOST = "127.0.0.1";
19
+ /**
20
+ * Corroboration probe budget. A loopback `ECONNREFUSED` returns in single-digit
21
+ * milliseconds, so this is ~30x headroom that still bounds the worst case: the
22
+ * probe sits on the once-per-connection LSP resolution path.
23
+ */
24
+ const PROBE_TIMEOUT_MS = 300;
25
+ /**
26
+ * Whether a process is still alive. Returns false only if provably dead.
27
+ *
28
+ * Signal 0 is a no-op "existence probe" — never delivered, it only tests whether
29
+ * the process exists and is signalable. Reliable on Linux, macOS AND Windows
30
+ * (Node/libuv maps it to OpenProcess on Windows, not the unreliable mechanism
31
+ * behind GDScript's OS.is_process_running). Outcomes:
32
+ * - success → the process exists → ALIVE
33
+ * - throw ESRCH → no such process → dead
34
+ * - throw EPERM/EACCES → the process EXISTS but we may not signal it
35
+ * (another user / elevated / protected) → ALIVE
36
+ *
37
+ * Treating EPERM/EACCES as alive is the POSIX-standard robust check (`kill(pid,0)`
38
+ * sets EPERM precisely *because* the target exists). It never triggers for our
39
+ * same-user sibling editors, but it keeps a cross-user/elevated peer from being
40
+ * mis-counted as dead on any platform.
41
+ *
42
+ * A live PID proves only that *some* signalable process holds that number — not
43
+ * that it is a Godot editor, and not that it is the editor of record. Pair it
44
+ * with {@link wsPortNotRefused} whenever identity matters.
45
+ */
46
+ export function isPidAlive(pid) {
47
+ if (!pid || pid <= 0)
48
+ return false;
49
+ try {
50
+ process.kill(pid, 0);
51
+ return true;
52
+ }
53
+ catch (err) {
54
+ const code = err.code;
55
+ return code === "EPERM" || code === "EACCES";
56
+ }
57
+ }
58
+ /**
59
+ * The fail-closed policy for a corroboration probe, as a pure decision.
60
+ *
61
+ * `ECONNREFUSED` is the only positive proof of refusal — nothing is listening, so
62
+ * the editor that advertised the port is gone. Every other outcome (a timeout, a
63
+ * local resource limit, an unrecognised code) says nothing about the peer, and
64
+ * the two failure directions are not symmetric: treating an inconclusive probe as
65
+ * proof of death would drop a genuine rival and silently serve another project's
66
+ * data on Godot 4.2–4.4, which have no root-mismatch backstop. Calling it
67
+ * indeterminate keeps the peer counted, which is at worst today's visible
68
+ * behaviour — never worse.
69
+ *
70
+ * @param code the socket error's `code`, or undefined when the probe timed out
71
+ */
72
+ export function classifyProbeOutcome(code) {
73
+ return code === "ECONNREFUSED" ? "dead" : "indeterminate";
74
+ }
75
+ /**
76
+ * Whether a connection to `port` was **not** positively refused.
77
+ *
78
+ * Named for what it actually establishes. `false` means `ECONNREFUSED` — proof the
79
+ * advertised port has no listener. `true` lumps a successful connect together with
80
+ * every inconclusive outcome ({@link classifyProbeOutcome}), and a port outside the
81
+ * connectable range cannot be probed at all, so `true` is the *absence of proof of
82
+ * death*, never proof of life. Reading it as "the port answered" would invert the
83
+ * fail-closed policy this predicate exists to implement.
84
+ *
85
+ * The socket closes with a graceful FIN rather than a reset: the toolkit wraps
86
+ * every accepted stream in a `WebSocketPeer` and logs `accept_stream failed` when
87
+ * the stream dies before the wrap, which would put probe noise in a real editor's
88
+ * Output dock.
89
+ */
90
+ export function wsPortNotRefused(port) {
91
+ if (!Number.isInteger(port) || port < 1 || port > 65_535)
92
+ return Promise.resolve(true);
93
+ return new Promise((resolve) => {
94
+ const socket = createConnection({ host: PROBE_HOST, port });
95
+ let settled = false;
96
+ const settle = (responds, graceful) => {
97
+ if (settled)
98
+ return;
99
+ settled = true;
100
+ clearTimeout(timer);
101
+ if (graceful)
102
+ socket.end();
103
+ else
104
+ socket.destroy();
105
+ resolve(responds);
106
+ };
107
+ const timer = setTimeout(() => settle(true, false), PROBE_TIMEOUT_MS);
108
+ socket.on("connect", () => settle(true, true));
109
+ // Listen (not once) so a late error after settling is absorbed here instead
110
+ // of surfacing as an unhandled 'error' event.
111
+ socket.on("error", (err) => settle(classifyProbeOutcome(err.code) !== "dead", false));
112
+ });
113
+ }