@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
package/dist/index.js ADDED
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Composition root + CLI entry (the npm `bin`). Constructs and wires the whole
4
+ * server in dependency order — bridge, MCP server, hook pipeline, the built-in
5
+ * tool surface, groups, prompts/resources/roots, the extension manager, and the
6
+ * config/version reconciler — then connects the stdio transport LAST so nothing is
7
+ * advertised before its guards are in place.
8
+ *
9
+ * @remarks
10
+ * Owns sequencing and wiring only — no domain logic (that lives in the modules it
11
+ * composes). The ordering is load-bearing: preflight may `process.exit` (Node
12
+ * version check, `--help` / a CLI parse error / `--tools-count` / `--list-eager`); the transport
13
+ * connects only after the full tool surface and the notification router are ready.
14
+ *
15
+ * @module
16
+ */
17
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
18
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
19
+ import { createBridge } from "./transport/bridge.js";
20
+ import { isReadOnly } from "./security/profiles.js";
21
+ import { toolRefCount } from "./registration/toolRefs.js";
22
+ import { createHookPipeline } from "./startup/hooks.js";
23
+ import { getServerVersion } from "./shared/version.js";
24
+ import { registerPrompts } from "./mcp/prompts.js";
25
+ import { registerResources } from "./mcp/resources.js";
26
+ import { init as initRoots, registerRoots } from "./mcp/roots.js";
27
+ import { setGlobalHookPipeline } from "./registration/toolRegistry.js";
28
+ import * as startupEnv from "./startup/startupEnv.js";
29
+ import { parseCliArgs } from "./startup/cliArgs.js";
30
+ import { resolvePortConfigOrExit, logResolvedPortConfig } from "./startup/portConfig.js";
31
+ import { setLspOverride } from "./lsp/lspClient.js";
32
+ import { MODULE_ALLOWED } from "./startup/serverMode.js";
33
+ import * as registrars from "./startup/registrars.js";
34
+ import { createExtensionManager } from "./extensions/extensions.js";
35
+ import { createReconciler } from "./startup/reconcile.js";
36
+ import { createLspStatusReporter } from "./lsp/lspStatusReporter.js";
37
+ import { installProcessHandlers } from "./startup/lifecycle.js";
38
+ // ── Preflight (may exit) ─────────────────────────────────────────────
39
+ startupEnv.enforceNodeVersion();
40
+ const cli = parseCliArgs(process.argv.slice(2));
41
+ startupEnv.applyCliMetaGates(cli); // --help / parse error / --tools-count / --list-eager
42
+ // ── Bridge setup ─────────────────────────────────────────────────────
43
+ const projectPath = process.env.GODOT_MCP_PROJECT_PATH ?? process.cwd();
44
+ const ports = resolvePortConfigOrExit(cli, projectPath); // invalid pin → exit 1
45
+ logResolvedPortConfig(ports);
46
+ const caps = startupEnv.resolveResponseCaps();
47
+ // Feed the CLI LSP override into the lazy per-connect LSP resolution (CLI wins
48
+ // over env there; env stays live so a config reload can still change it).
49
+ setLspOverride({ port: cli.lspPort, host: cli.lspHost });
50
+ const bridge = createBridge(`ws://127.0.0.1:${ports.editorPort}`, {
51
+ projectPath,
52
+ explicitRuntimePort: ports.runtimePort,
53
+ explicitEditorPort: ports.editorPinned,
54
+ scriptReadLimitBytes: caps.scriptReadLimitBytes,
55
+ wsBufferLimitBytes: caps.wsBufferLimitBytes,
56
+ });
57
+ startupEnv.warnConfigVersion();
58
+ // ── Server + hook pipeline ───────────────────────────────────────────
59
+ const server = new McpServer({ name: "godot-mcp-toolkit", version: getServerVersion() }, {
60
+ capabilities: { tools: { listChanged: true } },
61
+ });
62
+ const hookPipeline = createHookPipeline();
63
+ setGlobalHookPipeline(hookPipeline);
64
+ // ── Subsystem construction ───────────────────────────────────────────
65
+ // The extension subsystem owns extension discovery (single-flight), live
66
+ // reconciliation on extensions.changed, and the always-on extensions_refresh
67
+ // tool. getReadOnly is injected (a live read of profiles.isReadOnly) so extensions.ts
68
+ // imports no other composition module.
69
+ const extensions = createExtensionManager({ server, bridge, getReadOnly: isReadOnly });
70
+ // The reconciler keeps the advertised surface consistent with config + version:
71
+ // the debounced config_reloaded reload and the one-shot startup reconcile.
72
+ // discover is injected (extensions.discoverExtensions, a closure method — no
73
+ // `this`) so reconcile.ts imports no other composition module (acyclic graph).
74
+ const reconciler = createReconciler({ server, bridge, projectPath, discover: extensions.discoverExtensions });
75
+ function logStartup(extTimedOut = false) {
76
+ const suffix = extTimedOut ? " (ext discovery timed out — extensions_refresh available)" : "";
77
+ process.stderr.write(`[godot-mcp] readOnly=${isReadOnly()} tools=${toolRefCount()} hooks=${hookPipeline.length} caps=${caps.scriptReadLimitBytes / 1024}KB/${caps.wsBufferLimitBytes / 1024}KB${suffix}\n`);
78
+ }
79
+ // ── Initial registration ────────────────────────────────────────────
80
+ // Snapshot whether the Godot version is unknown at eager registration. When it
81
+ // is, the registration-time version gate filters out version-gated tools
82
+ // (scene_close) — leaving the startup surface incomplete. The startup reconcile
83
+ // (below) completes it once the version resolves. Pre-populated from the
84
+ // registry in the common dogfood flow → false → no reconcile needed.
85
+ const versionNullAtEagerRegistration = bridge.getGodotVersion() == null;
86
+ registrars.registerBuiltinModules(server, bridge, MODULE_ALLOWED);
87
+ registrars.registerGroups(server, bridge, isReadOnly());
88
+ extensions.registerRefreshTool(); // always in initial tools/list
89
+ // ── Prompts, resources, roots ────────────────────────────────────────
90
+ registerPrompts(server);
91
+ registerResources(server, bridge);
92
+ initRoots(projectPath);
93
+ registerRoots(server);
94
+ // ── Eager extension discovery ──────────────────────────────────────
95
+ // Discover extensions BEFORE transport connects so they're in the
96
+ // initial tools/list. Deadline prevents blocking if editor is slow.
97
+ // Note: the editor's FIRST connect delivers no notification (config_reloaded
98
+ // is reconnect-only — see bridge.ts performAuth). The version becomes known
99
+ // via bridge.onGodotVersionKnown; if discovery timed out here or the version
100
+ // was unknown at eager registration, the startup reconcile (armStartupReconcile,
101
+ // below) completes the tool surface once the editor is reachable.
102
+ const { timedOut } = await extensions.discoverEagerly();
103
+ logStartup(timedOut);
104
+ // ── Live config reload + notification routing ────────────────────────
105
+ // LSP status reporter — pushes the GDScript-LSP verdict to the editor dock,
106
+ // de-duped (the server computes the verdict; the editor has no engine API to
107
+ // read its own LSP bind status). Constructed here so its setLspStatusReporter +
108
+ // setGodotVersionGetter wiring fires before transport connect; the notification
109
+ // router below calls reportRegistryVerdict() on reconnect.
110
+ const lspReporter = createLspStatusReporter({ bridge, projectPath });
111
+ bridge.onNotification((type, params) => {
112
+ if (type === "config_reloaded") {
113
+ // Push the authoritative LSP verdict to the editor dock.
114
+ lspReporter.reportRegistryVerdict();
115
+ // config_reloaded is only ever emitted on a RECONNECT ({reconnect:true},
116
+ // bridge.ts performAuth) — the editor's FIRST connect sends no notification.
117
+ // An incomplete first-connect surface is completed by the startup reconcile
118
+ // (reconciler.armStartupReconcile), not here. So this is the live reconnect
119
+ // handler: re-read config + re-register tools, debounced (300 ms).
120
+ reconciler.scheduleConfigReload();
121
+ }
122
+ else if (type === "extensions.changed") {
123
+ extensions.handleExtensionsChanged(params);
124
+ }
125
+ else if (type === "game_stopped") {
126
+ // Proactive runtime teardown: editor detected game-stop/crash and
127
+ // notified us. Clear the runtime channel immediately so the next
128
+ // callRuntime() fails with GAME_NOT_RUNNING in 0ms (no TCP probe).
129
+ bridge.clearRuntime?.();
130
+ }
131
+ });
132
+ // ── Startup reconcile ────────────────────────────────────────────────
133
+ // Arm the one-shot startup reconcile: the eagerly-registered surface is
134
+ // INCOMPLETE when the Godot version was unknown at eager registration
135
+ // (version-gated tools like scene_close were filtered) or extension discovery
136
+ // timed out (extension tools never registered) — the server-before-editor cold
137
+ // start. The reconciler completes it EXACTLY ONCE (immediately if the version is
138
+ // already known, else via onGodotVersionKnown) and is a strict no-op when the
139
+ // eager surface was already complete. See reconcile.ts for the latch + triggers.
140
+ reconciler.armStartupReconcile({ versionNullAtEagerRegistration, extDiscoveryTimedOut: timedOut });
141
+ // ── Lifecycle + transport (last) ─────────────────────────────────────
142
+ installProcessHandlers(bridge);
143
+ const transport = new StdioServerTransport();
144
+ await server.connect(transport);
@@ -0,0 +1,509 @@
1
+ /**
2
+ * Lightweight LSP client for Godot's built-in GDScript language server.
3
+ * The endpoint is discovered PER PROJECT from the registry at connect time
4
+ * (GODOT_MCP_LSP_PORT/_HOST override it); a collision fails visibly rather than
5
+ * silently reaching the wrong editor. Lazy connection — the first request
6
+ * triggers connect + the initialize handshake.
7
+ *
8
+ * @module
9
+ */
10
+ import { createConnection } from "node:net";
11
+ import { discoverLspEndpoint, liveLspClaimants } from "../registry.js";
12
+ import { getServerVersion, isVersionCompatible } from "../shared/version.js";
13
+ import { isValidPort } from "../startup/portConfig.js";
14
+ import { normalizeUri } from "./lspUri.js";
15
+ // ── Constants ────────────────────────────────────────────────────────
16
+ const DEFAULT_LSP_PORT = 6005;
17
+ const CONNECT_TIMEOUT_MS = 5_000;
18
+ const REQUEST_TIMEOUT_MS = 10_000;
19
+ const HEADER_SEPARATOR = "\r\n\r\n";
20
+ // Substring of the GDScript LSP's workspace-root-mismatch warning (window/
21
+ // showMessage), shipped in Godot 4.5+ (PR #104401). Engine-verified against
22
+ // gdscript_language_protocol.cpp on 4.5/4.6; absent 4.2-4.4. We send our real
23
+ // rootUri in initialize, so this fires only when we reached the WRONG editor.
24
+ const ROOT_MISMATCH_SUBSTRING = "might not work correctly with other projects";
25
+ // Conflict hint, tailored to the connected Godot version (getter injected at
26
+ // startup). The recovery differs by version, and the auto-rebind window is closed
27
+ // at BOTH ends: only 4.5 retries the LSP bind, so only there does closing the other
28
+ // editor recover the port. Every other supported version needs distinct ports or an
29
+ // editor restart. Giving the LLM only the applicable path keeps the hint actionable.
30
+ let godotVersionGetter = undefined;
31
+ /** Inject the connected-version getter that tailors the conflict hint (startup wiring). @internal */
32
+ export function setGodotVersionGetter(cb) {
33
+ godotVersionGetter = cb;
34
+ }
35
+ // CLI-sourced LSP override, injected once at startup. The composition root
36
+ // resolves CLI > env and injects only the CLI part, so an env value stays
37
+ // live-re-readable across a config reload. undefined until injected; when its
38
+ // port is set it outranks both env and the registry in resolveLspEndpoint.
39
+ let lspCliOverride = undefined;
40
+ /** Inject the CLI `--lsp-port` / `--lsp-host` override (startup wiring). @internal */
41
+ export function setLspOverride(override) {
42
+ lspCliOverride = override;
43
+ }
44
+ function lspConflictHint() {
45
+ const v = godotVersionGetter?.();
46
+ // A closed window, not an open-ended floor: 4.5 re-runs start() every
47
+ // internal-process frame while the bind has not succeeded, so it takes the port
48
+ // as soon as the other editor frees it. 4.6 latched that behind a
49
+ // start_attempted flag set before the call, making the bind one-shot again.
50
+ // Bounds are inclusive and compared on major.minor, so every 4.5 patch qualifies.
51
+ if (v != null && isVersionCompatible(v, "4.5", "4.5")) {
52
+ return ("Another editor holds this project's GDScript LSP port. Close the other editor — " +
53
+ "this editor's LSP then rebinds the port automatically — or give each editor a " +
54
+ "distinct --lsp-port + GODOT_MCP_LSP_PORT (see the toolkit addon's addons/godot_mcp_toolkit/docs/multi-instance.md).");
55
+ }
56
+ if (v != null) {
57
+ // No bind retry on this version — 4.2-4.4 never had one, and 4.6 onward latched
58
+ // it to a single attempt — so closing the other editor won't recover this one.
59
+ // The text is deliberately version-agnostic, which is why both ranges share it.
60
+ return ("Another editor holds this project's GDScript LSP port. On this Godot version, give " +
61
+ "each editor a distinct --lsp-port + GODOT_MCP_LSP_PORT (see the toolkit addon's addons/godot_mcp_toolkit/docs/multi-instance.md) — " +
62
+ "closing the other editor won't recover this LSP without restarting this editor.");
63
+ }
64
+ // Version unknown — lead with the lever that works everywhere, and make the one
65
+ // positive version claim narrow enough to stay true.
66
+ return ("Another editor holds this project's GDScript LSP port. Either give each editor a " +
67
+ "distinct --lsp-port + GODOT_MCP_LSP_PORT (see the toolkit addon's addons/godot_mcp_toolkit/docs/multi-instance.md), or close the other " +
68
+ "editor (only Godot 4.5 rebinds automatically; on every other version, restart this editor afterwards).");
69
+ }
70
+ const LSP_UNAVAILABLE_HINT = "GDScript LSP not reachable. Ensure the Godot editor is running with the toolkit " +
71
+ "plugin enabled. With multiple editors open, give each a distinct --lsp-port + " +
72
+ "GODOT_MCP_LSP_PORT (see the toolkit addon's addons/godot_mcp_toolkit/docs/multi-instance.md).";
73
+ /** Resolution failure carrying a specific tool error code + actionable hint. */
74
+ export class LspResolutionError extends Error {
75
+ code;
76
+ hint;
77
+ port;
78
+ constructor(code, message, hint, port) {
79
+ super(message);
80
+ this.code = code;
81
+ this.hint = hint;
82
+ this.port = port;
83
+ this.name = "LspResolutionError";
84
+ }
85
+ }
86
+ /**
87
+ * Resolve a project's LSP endpoint at connect time. Priority:
88
+ * 1. --lsp-port / GODOT_MCP_LSP_PORT (+ --lsp-host / GODOT_MCP_LSP_HOST) —
89
+ * explicit override (CLI wins over env), top priority, bypasses the registry
90
+ * (the documented multi-instance lever).
91
+ * 2. discoverLspEndpoint(projectPath) — registry hit (with conflict guard).
92
+ * 3. miss → 6005 ONLY if no live editor holds it; else unavailable.
93
+ * Throws LspResolutionError on a conflict or an ambiguous miss — never a blind
94
+ * 6005 fallback (that is what kept comparable tools returning the wrong project).
95
+ * An invalid override value is skipped LOUDLY (stderr warning, fall through to
96
+ * discovery): the env var is re-read live on every connect, so a config reload
97
+ * can rewrite it mid-session after the startup validation gate has passed.
98
+ */
99
+ export async function resolveLspEndpoint(projectPath) {
100
+ const overridePort = lspCliOverride?.port ?? process.env.GODOT_MCP_LSP_PORT;
101
+ if (overridePort) {
102
+ if (isValidPort(overridePort)) {
103
+ const host = lspCliOverride?.host ?? process.env.GODOT_MCP_LSP_HOST;
104
+ return { host: host || "127.0.0.1", port: parseInt(overridePort, 10) };
105
+ }
106
+ process.stderr.write(`[godot-mcp] ignoring invalid LSP port override "${overridePort}" ` +
107
+ `(expected an integer 1–65535); falling through to registry discovery\n`);
108
+ }
109
+ const disc = await discoverLspEndpoint(projectPath);
110
+ if (disc) {
111
+ if ("conflict" in disc) {
112
+ throw new LspResolutionError("LSP_PORT_CONFLICT", `Another live editor owns GDScript LSP port ${disc.port}; refusing to return its results.`, lspConflictHint(), disc.port);
113
+ }
114
+ return disc;
115
+ }
116
+ // Registry miss — fall back to 6005 only when no live editor holds it.
117
+ if ((await liveLspClaimants(DEFAULT_LSP_PORT)).length === 0) {
118
+ return { host: "127.0.0.1", port: DEFAULT_LSP_PORT };
119
+ }
120
+ throw new LspResolutionError("LSP_UNAVAILABLE", `No registry LSP endpoint for this project and port ${DEFAULT_LSP_PORT} is held by another editor.`, LSP_UNAVAILABLE_HINT, DEFAULT_LSP_PORT);
121
+ }
122
+ /**
123
+ * The authoritative LSP verdict for a project, computed without opening an LSP
124
+ * connection (resolution + registry ownership only — cross-platform PID liveness
125
+ * corroborated by a peer's own WS command port). The toolkit can't determine this
126
+ * itself (no engine API for its own LSP bind status), so the server reports it to
127
+ * the editor dock via editor.set_lsp_status. "active" = this editor owns the port
128
+ * (per registry / env override); a later editor or a non-registry holder →
129
+ * conflict / unavailable.
130
+ */
131
+ export async function getLspStatus(projectPath) {
132
+ try {
133
+ const ep = await resolveLspEndpoint(projectPath);
134
+ return { state: "active", host: ep.host, port: ep.port, detail: "Owns the GDScript LSP port." };
135
+ }
136
+ catch (err) {
137
+ if (err instanceof LspResolutionError) {
138
+ return {
139
+ state: err.code === "LSP_PORT_CONFLICT" ? "conflict" : "unavailable",
140
+ host: "127.0.0.1",
141
+ port: err.port,
142
+ detail: err.message,
143
+ };
144
+ }
145
+ return { state: "unavailable", host: "127.0.0.1", port: DEFAULT_LSP_PORT, detail: String(err) };
146
+ }
147
+ }
148
+ // ── LspClient ────────────────────────────────────────────────────────
149
+ export class LspClient {
150
+ socket = undefined;
151
+ nextId = 1;
152
+ pending = new Map();
153
+ buffer = "";
154
+ initialized = false;
155
+ connecting = undefined;
156
+ host = "127.0.0.1";
157
+ port = DEFAULT_LSP_PORT;
158
+ projectPath;
159
+ // Set when the LSP emits the 4.5+ root-mismatch warning during initialize —
160
+ // means we reached an editor open on a different project. Reset each connect.
161
+ rootMismatch = false;
162
+ // Document tracking — which files have been opened via didOpen.
163
+ openDocuments = new Set();
164
+ // Notification storage — keyed by URI, stores latest diagnostics.
165
+ diagnosticsByUri = new Map();
166
+ diagnosticWaiters = new Map();
167
+ /** Optional per-instance override for the `initialize` request timeout only
168
+ * (all other requests keep {@link REQUEST_TIMEOUT_MS}). Undefined = default.
169
+ * Consumed by the smoke harness: Godot 4.2 answers the FIRST initialize only
170
+ * after a synchronous main-thread workspace scan that can take far longer
171
+ * than the default budget on slow runners (measured ~5.3s warm-hardware /
172
+ * 100s+ mute on 2-core windows-latest); a patient first handshake absorbs
173
+ * that one-time cost. Product callers pass nothing and are unaffected. */
174
+ initializeTimeoutMs;
175
+ constructor(projectPath, opts) {
176
+ this.projectPath = projectPath;
177
+ this.initializeTimeoutMs = opts?.initializeTimeoutMs;
178
+ }
179
+ /** file:// URI for the project root — sent as rootUri so the 4.5+ LSP can
180
+ * warn (window/showMessage) when we reached an editor open on a different
181
+ * project, i.e. a port collision reached the wrong editor. */
182
+ projectRootUri() {
183
+ const norm = this.projectPath.replace(/\\/g, "/").replace(/\/+$/, "");
184
+ return /^[A-Za-z]:/.test(norm) ? `file:///${norm}` : `file://${norm}`;
185
+ }
186
+ /** Ensure connection is established. Lazy — connects on first call. */
187
+ async ensureConnected() {
188
+ if (this.initialized && this.socket && !this.socket.destroyed)
189
+ return;
190
+ if (this.connecting)
191
+ return this.connecting;
192
+ this.connecting = this.doConnect();
193
+ try {
194
+ await this.connecting;
195
+ }
196
+ finally {
197
+ this.connecting = undefined;
198
+ }
199
+ }
200
+ async doConnect() {
201
+ // Reset state from any previous connection.
202
+ this.cleanup();
203
+ this.rootMismatch = false;
204
+ // Resolve fresh each connect so a reconnect picks up a changed port/host.
205
+ // Throws LspResolutionError on a conflict / ambiguous miss (no blind 6005).
206
+ const endpoint = await resolveLspEndpoint(this.projectPath);
207
+ this.host = endpoint.host;
208
+ this.port = endpoint.port;
209
+ await new Promise((resolve, reject) => {
210
+ const timer = setTimeout(() => {
211
+ socket.destroy();
212
+ reject(new Error(`LSP connect timeout (${this.host}:${this.port})`));
213
+ }, CONNECT_TIMEOUT_MS);
214
+ const socket = createConnection({ host: this.host, port: this.port }, () => {
215
+ clearTimeout(timer);
216
+ this.socket = socket;
217
+ resolve();
218
+ });
219
+ socket.on("error", (err) => {
220
+ clearTimeout(timer);
221
+ reject(new Error(`LSP connect failed (${this.host}:${this.port}): ${err.message}`));
222
+ });
223
+ socket.on("data", (chunk) => this.onData(chunk.toString("utf-8")));
224
+ socket.on("close", () => this.onClose());
225
+ });
226
+ // LSP initialize handshake. Send our REAL rootUri (not null): on Godot 4.5+
227
+ // the server emits a window/showMessage root-mismatch warning when the URI
228
+ // resolves to a different open project — i.e. we reached the wrong editor.
229
+ // The engine sends that warning BEFORE the initialize response
230
+ // (gdscript_language_protocol.cpp), so rootMismatch is already set when this
231
+ // await resolves.
232
+ const initResult = await this.sendRequest("initialize", {
233
+ processId: process.pid,
234
+ capabilities: {},
235
+ rootUri: this.projectRootUri(),
236
+ clientInfo: { name: "godot-mcp-server", version: getServerVersion() },
237
+ }, this.initializeTimeoutMs);
238
+ if (!initResult || typeof initResult !== "object") {
239
+ throw new Error("LSP initialize failed: no capabilities returned");
240
+ }
241
+ if (this.rootMismatch) {
242
+ this.cleanup();
243
+ throw new LspResolutionError("LSP_PORT_CONFLICT", `Reached an editor open on a different project on LSP port ${this.port} (root mismatch).`, lspConflictHint(), this.port);
244
+ }
245
+ // Send initialized notification.
246
+ this.sendNotification("initialized", {});
247
+ this.initialized = true;
248
+ }
249
+ /** Send a JSON-RPC request and await the response.
250
+ * `timeoutMs` overrides {@link REQUEST_TIMEOUT_MS} for this request only
251
+ * (used for the patient first `initialize`); omitted = default. */
252
+ async sendRequest(method, params, timeoutMs) {
253
+ if (!this.socket || this.socket.destroyed) {
254
+ throw new Error("LSP not connected");
255
+ }
256
+ const id = this.nextId++;
257
+ const request = { jsonrpc: "2.0", id, method, params };
258
+ const body = JSON.stringify(request);
259
+ return new Promise((resolve, reject) => {
260
+ const timer = setTimeout(() => {
261
+ this.pending.delete(id);
262
+ reject(new Error(`LSP request timeout: ${method}`));
263
+ }, timeoutMs ?? REQUEST_TIMEOUT_MS);
264
+ this.pending.set(id, { resolve, reject, timer });
265
+ this.writeMessage(body);
266
+ });
267
+ }
268
+ /** Send a JSON-RPC notification (no response expected). */
269
+ sendNotification(method, params) {
270
+ if (!this.socket || this.socket.destroyed)
271
+ return;
272
+ const notification = { jsonrpc: "2.0", method, params };
273
+ this.writeMessage(JSON.stringify(notification));
274
+ }
275
+ /** Open a document in the LSP (or update if already open). */
276
+ async openDocument(uri, content) {
277
+ // Drop any diagnostics stored for this URI before (re)sending content: an
278
+ // entry present now predates the new text, and a late publish of it could
279
+ // otherwise poison the next waitForDiagnostics for this file (the batch
280
+ // scan multiplies that hazard). The map is keyed by normalizeUri (see
281
+ // handleNotification), so normalize here too.
282
+ this.diagnosticsByUri.delete(normalizeUri(uri));
283
+ if (this.openDocuments.has(uri)) {
284
+ // Already open — send didChange with full content.
285
+ this.sendNotification("textDocument/didChange", {
286
+ textDocument: { uri, version: this.nextId++ },
287
+ contentChanges: [{ text: content }],
288
+ });
289
+ }
290
+ else {
291
+ // Open shaders as "gdshader", not "gdscript": Godot's LSP has no shader
292
+ // documentSymbol/diagnostics provider, so a "gdscript" languageId makes the
293
+ // engine parse valid shader source as GDScript and emit bogus parse-error
294
+ // diagnostics. A non-GDScript languageId makes it skip parsing — clean empty
295
+ // symbols + no false diagnostics, uniform across 4.2-4.7.
296
+ const languageId = uri.endsWith(".gdshader") || uri.endsWith(".gdshaderinc") ? "gdshader" : "gdscript";
297
+ this.sendNotification("textDocument/didOpen", {
298
+ textDocument: { uri, languageId, version: 1, text: content },
299
+ });
300
+ this.openDocuments.add(uri);
301
+ }
302
+ }
303
+ /** Close a document in the LSP, clearing its open + diagnostics state.
304
+ *
305
+ * Keeps client and server open-state in lockstep — the 4.7 GDScript LSP
306
+ * erases per-peer parser state on didClose and ERR_FAILs a re-didOpen of a
307
+ * file it still thinks is open, so a batch that reopens files must close
308
+ * each first. Call only AFTER collecting a URI's diagnostics — never while a
309
+ * waitForDiagnostics for it is still pending. */
310
+ async closeDocument(uri) {
311
+ if (this.openDocuments.has(uri)) {
312
+ this.sendNotification("textDocument/didClose", { textDocument: { uri } });
313
+ }
314
+ this.openDocuments.delete(uri);
315
+ this.diagnosticsByUri.delete(normalizeUri(uri));
316
+ }
317
+ /** Wait for a diagnostics notification for a URI (with timeout).
318
+ *
319
+ * Tri-state: a received notification returns its {@link DiagnosticEntry}
320
+ * array — which may be `[]` for a clean file (a clean file DOES publish an
321
+ * empty-diagnostics notification on every supported Godot version). A
322
+ * timeout with no notification returns `undefined` — status unknown, which
323
+ * the caller must NOT conflate with clean. The distinction is load-bearing
324
+ * for the project scan: a timed-out file is never counted clean. */
325
+ async waitForDiagnostics(uri, timeoutMs = 5000) {
326
+ const normUri = normalizeUri(uri);
327
+ // Check if we already have diagnostics from the notification. `!== undefined`
328
+ // distinguishes a stored empty array (clean) from nothing stored (unknown).
329
+ const existing = this.diagnosticsByUri.get(normUri);
330
+ if (existing !== undefined) {
331
+ this.diagnosticsByUri.delete(normUri);
332
+ return existing;
333
+ }
334
+ // Wait for the notification to arrive.
335
+ return new Promise((resolve) => {
336
+ const timer = setTimeout(() => {
337
+ this.diagnosticWaiters.delete(normUri);
338
+ // Final check — a late publish may have stored diagnostics under
339
+ // normUri between the wait starting and the timer firing. Present =
340
+ // received (return it, possibly `[]`); absent = no notification arrived
341
+ // (return undefined — timed out, NOT clean).
342
+ const late = this.diagnosticsByUri.get(normUri);
343
+ this.diagnosticsByUri.delete(normUri);
344
+ resolve(late);
345
+ }, timeoutMs);
346
+ this.diagnosticWaiters.set(normUri, {
347
+ resolve: () => {
348
+ clearTimeout(timer);
349
+ const diags = this.diagnosticsByUri.get(normUri);
350
+ this.diagnosticsByUri.delete(normUri);
351
+ resolve(diags);
352
+ },
353
+ timer,
354
+ });
355
+ });
356
+ }
357
+ /** Check if the client is currently connected. */
358
+ isConnected() {
359
+ return this.initialized && !!this.socket && !this.socket.destroyed;
360
+ }
361
+ /** Whether `uri` is currently tracked as open (post-didOpen, pre-didClose).
362
+ * Exposes the open-document bookkeeping for the close-lifecycle tests. @internal */
363
+ hasOpenDocument(uri) {
364
+ return this.openDocuments.has(uri);
365
+ }
366
+ /** The host:port resolved for the most recent connect attempt (valid after
367
+ * doConnect set it — i.e. when a connect was attempted, success or failure). */
368
+ getEndpoint() {
369
+ return { host: this.host, port: this.port };
370
+ }
371
+ /** Graceful shutdown. */
372
+ async close() {
373
+ if (!this.socket || this.socket.destroyed)
374
+ return;
375
+ try {
376
+ await this.sendRequest("shutdown", null);
377
+ this.sendNotification("exit");
378
+ }
379
+ catch {
380
+ // Best-effort shutdown.
381
+ }
382
+ this.cleanup();
383
+ }
384
+ // ── Private ────────────────────────────────────────────────────────
385
+ writeMessage(body) {
386
+ const header = `Content-Length: ${Buffer.byteLength(body, "utf-8")}${HEADER_SEPARATOR}`;
387
+ this.socket.write(header + body, "utf-8");
388
+ }
389
+ onData(chunk) {
390
+ this.buffer += chunk;
391
+ this.processBuffer();
392
+ }
393
+ processBuffer() {
394
+ while (true) {
395
+ const headerEnd = this.buffer.indexOf(HEADER_SEPARATOR);
396
+ if (headerEnd === -1)
397
+ break;
398
+ // Parse Content-Length from headers.
399
+ const headerBlock = this.buffer.slice(0, headerEnd);
400
+ const match = headerBlock.match(/Content-Length:\s*(\d+)/i);
401
+ if (!match) {
402
+ // Malformed — skip this header block.
403
+ this.buffer = this.buffer.slice(headerEnd + HEADER_SEPARATOR.length);
404
+ continue;
405
+ }
406
+ const contentLength = parseInt(match[1], 10);
407
+ const bodyStart = headerEnd + HEADER_SEPARATOR.length;
408
+ // Check if we have the full body.
409
+ if (Buffer.byteLength(this.buffer.slice(bodyStart), "utf-8") < contentLength)
410
+ break;
411
+ // Extract body by byte length.
412
+ const bodyBytes = Buffer.from(this.buffer.slice(bodyStart), "utf-8");
413
+ const body = bodyBytes.slice(0, contentLength).toString("utf-8");
414
+ const remainderBytes = bodyBytes.slice(contentLength);
415
+ this.buffer = remainderBytes.toString("utf-8");
416
+ this.handleMessage(body);
417
+ }
418
+ }
419
+ handleMessage(body) {
420
+ let msg;
421
+ try {
422
+ msg = JSON.parse(body);
423
+ }
424
+ catch {
425
+ return;
426
+ }
427
+ // Response to a request we sent.
428
+ if ("id" in msg && msg.id != null) {
429
+ const resp = msg;
430
+ const pending = this.pending.get(resp.id);
431
+ if (pending) {
432
+ clearTimeout(pending.timer);
433
+ this.pending.delete(resp.id);
434
+ if (resp.error) {
435
+ pending.reject(new Error(`LSP error [${resp.error.code}]: ${resp.error.message}`));
436
+ }
437
+ else {
438
+ pending.resolve(resp.result);
439
+ }
440
+ }
441
+ return;
442
+ }
443
+ // Server notification (no id).
444
+ if ("method" in msg) {
445
+ this.handleNotification(msg);
446
+ }
447
+ }
448
+ handleNotification(notification) {
449
+ if (notification.method === "window/showMessage") {
450
+ // Godot 4.5+ root-mismatch warning → we reached an editor open on a
451
+ // different project (port collision). doConnect checks this after init.
452
+ const params = notification.params;
453
+ if (params?.message && params.message.includes(ROOT_MISMATCH_SUBSTRING)) {
454
+ this.rootMismatch = true;
455
+ }
456
+ return;
457
+ }
458
+ if (notification.method === "textDocument/publishDiagnostics") {
459
+ const params = notification.params;
460
+ if (!params?.uri)
461
+ return;
462
+ const uri = normalizeUri(params.uri);
463
+ const diagnostics = (params.diagnostics ?? []).map((d) => {
464
+ const diag = d;
465
+ return {
466
+ line: diag.range?.start?.line ?? 0,
467
+ character: diag.range?.start?.character ?? 0,
468
+ severity: diag.severity ?? 1,
469
+ message: diag.message ?? "",
470
+ code: diag.code,
471
+ };
472
+ });
473
+ this.diagnosticsByUri.set(uri, diagnostics);
474
+ // Wake any waiter for this URI.
475
+ const waiter = this.diagnosticWaiters.get(uri);
476
+ if (waiter) {
477
+ this.diagnosticWaiters.delete(uri);
478
+ waiter.resolve();
479
+ }
480
+ }
481
+ }
482
+ onClose() {
483
+ this.initialized = false;
484
+ this.openDocuments.clear();
485
+ // Reject all pending requests.
486
+ for (const [, pending] of this.pending) {
487
+ clearTimeout(pending.timer);
488
+ pending.reject(new Error("LSP connection closed"));
489
+ }
490
+ this.pending.clear();
491
+ }
492
+ cleanup() {
493
+ if (this.socket) {
494
+ this.socket.destroy();
495
+ this.socket = undefined;
496
+ }
497
+ this.initialized = false;
498
+ this.openDocuments.clear();
499
+ this.diagnosticsByUri.clear();
500
+ for (const [, waiter] of this.diagnosticWaiters) {
501
+ clearTimeout(waiter.timer);
502
+ }
503
+ this.diagnosticWaiters.clear();
504
+ for (const [, pending] of this.pending) {
505
+ clearTimeout(pending.timer);
506
+ }
507
+ this.pending.clear();
508
+ }
509
+ }