@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
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
|
+
}
|