@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,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
|
+
}
|
package/dist/registry.js
ADDED
|
@@ -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
|
+
}
|