@mmerterden/multi-agent-toolkit-mcp 3.7.1 → 3.11.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.
@@ -0,0 +1,159 @@
1
+ /**
2
+ * kotlin.js - JetBrains kotlin-lsp, behind a probe, and labelled as Alpha.
3
+ *
4
+ * WHAT IS VERIFIED AND WHAT IS NOT, stated here because a green test suite would
5
+ * otherwise imply something false. The probe below is verified: on a machine
6
+ * without kotlin-lsp it reports the absence and installs nothing. The SERVER
7
+ * path is not verified by any gate in this repository - kotlin-lsp is not
8
+ * installed here and CI has no JVM - so every Kotlin answer carries
9
+ * `confidence: "alpha"` and the README says the same thing.
10
+ *
11
+ * Upstream state at the time of writing: Alpha, partially closed source, Android
12
+ * Gradle Plugin support experimental. A 4,294-file, 85-module Gradle project is
13
+ * the realistic target here, and importing one of those is the expensive step -
14
+ * hence an initialize budget measured in minutes, not the 20 seconds a Swift
15
+ * request gets.
16
+ *
17
+ * @module tools/code-intel/kotlin
18
+ */
19
+
20
+ import { existsSync } from "node:fs";
21
+ import { dirname, join } from "node:path";
22
+ import { execFileSync, spawnSync } from "node:child_process";
23
+
24
+ const INSTALL = [
25
+ "brew tap JetBrains/utils",
26
+ "brew install kotlin-lsp",
27
+ "requires a JVM, Java 17 or newer",
28
+ ];
29
+
30
+ const CANDIDATES = [
31
+ "/opt/homebrew/bin/kotlin-lsp",
32
+ "/usr/local/bin/kotlin-lsp",
33
+ ];
34
+
35
+ let cached;
36
+
37
+ /**
38
+ * Is there a Kotlin language server on this machine?
39
+ *
40
+ * Absence is a RESULT, not an error: `code_index_status` exists to be run when
41
+ * something is missing, so it must answer rather than fail. Nothing here spawns
42
+ * the server.
43
+ */
44
+ export function probeKotlin() {
45
+ if (cached) return cached;
46
+ const bin = findBin();
47
+ if (!bin) {
48
+ return (cached = {
49
+ available: false,
50
+ reason: "kotlin-lsp is not on PATH",
51
+ install: INSTALL,
52
+ });
53
+ }
54
+ const java = javaMajor();
55
+ if (java === null) {
56
+ return (cached = {
57
+ available: false,
58
+ bin,
59
+ reason: "kotlin-lsp is installed but no JVM was found",
60
+ install: INSTALL,
61
+ });
62
+ }
63
+ if (java < 17) {
64
+ return (cached = {
65
+ available: false,
66
+ bin,
67
+ javaVersion: java,
68
+ reason: `kotlin-lsp needs Java 17 or newer, found ${java}`,
69
+ install: INSTALL,
70
+ });
71
+ }
72
+ return (cached = { available: true, bin, javaVersion: java, confidence: "alpha" });
73
+ }
74
+
75
+ export function resetKotlinProbeCache() {
76
+ cached = undefined;
77
+ }
78
+
79
+ function findBin() {
80
+ const explicit = process.env.KOTLIN_LSP_PATH;
81
+ if (explicit && existsSync(explicit)) return explicit;
82
+ try {
83
+ const p = execFileSync("/usr/bin/which", ["kotlin-lsp"], {
84
+ encoding: "utf8",
85
+ stdio: ["ignore", "pipe", "ignore"],
86
+ timeout: 5000,
87
+ }).trim();
88
+ if (p && existsSync(p)) return p;
89
+ } catch {
90
+ /* not on PATH */
91
+ }
92
+ return CANDIDATES.find(existsSync) || null;
93
+ }
94
+
95
+ /**
96
+ * The JVM's major version, or null.
97
+ *
98
+ * `java -version` writes to STDERR and exits 0. execFileSync returns stdout, so
99
+ * reading it there captured an empty string, matched nothing, and reported "no
100
+ * JVM was found" on a machine running Java 17 - and the catch that reads
101
+ * e.stderr never ran, because there was no error. spawnSync is used to get both
102
+ * streams; `--version` (stdout, JDK 9+) is deliberately not used instead,
103
+ * because on a JDK 8 it fails and the answer needed there is "8", not nothing.
104
+ */
105
+ function javaMajor() {
106
+ const r = spawnSync("java", ["-version"], { encoding: "utf8", timeout: 10000 });
107
+ const out = `${r.stderr || ""}${r.stdout || ""}`;
108
+ const m = /version "(\d+)(?:\.(\d+))?/.exec(out);
109
+ if (!m) return null;
110
+ const major = Number(m[1]);
111
+ // 1.8 style for anything before 9.
112
+ return major === 1 ? Number(m[2] || 0) : major;
113
+ }
114
+
115
+ /** The Gradle root at or above a file. */
116
+ export function resolveKotlinRoot(file, explicitRoot) {
117
+ if (explicitRoot) return { root: explicitRoot, source: "explicit" };
118
+ let dir = dirname(file);
119
+ for (let i = 0; i < 40 && dir && dir !== "/"; i++) {
120
+ if (existsSync(join(dir, "settings.gradle.kts")) || existsSync(join(dir, "settings.gradle"))) {
121
+ return { root: dir, source: "gradle" };
122
+ }
123
+ dir = dirname(dir);
124
+ }
125
+ dir = dirname(file);
126
+ for (let i = 0; i < 40 && dir && dir !== "/"; i++) {
127
+ if (existsSync(join(dir, "build.gradle.kts")) || existsSync(join(dir, "build.gradle"))) {
128
+ return { root: dir, source: "gradle-module" };
129
+ }
130
+ dir = dirname(dir);
131
+ }
132
+ return { root: dirname(file), source: "fallback" };
133
+ }
134
+
135
+ /** Kotlin's half of `code_index_status`, whether or not anything is installed. */
136
+ export function kotlinReport(root) {
137
+ const probe = probeKotlin();
138
+ if (!probe.available) {
139
+ return {
140
+ available: false,
141
+ root,
142
+ reason: probe.reason,
143
+ install: probe.install,
144
+ note: "Kotlin support here is written against JetBrains kotlin-lsp (Alpha) and is not exercised by any gate in this repository.",
145
+ };
146
+ }
147
+ return {
148
+ available: true,
149
+ root,
150
+ bin: probe.bin,
151
+ javaVersion: probe.javaVersion,
152
+ confidence: "alpha",
153
+ note: "kotlin-lsp is Alpha and its Android Gradle Plugin support is experimental. The first request against a multi-module Gradle build triggers an import that can take minutes.",
154
+ };
155
+ }
156
+
157
+ export const KOTLIN_INIT_TIMEOUT_MS = 300000;
158
+ export const KOTLIN_ARGV = ["--stdio"];
159
+ export { INSTALL as KOTLIN_INSTALL };
@@ -0,0 +1,422 @@
1
+ /**
2
+ * lsp-client.js - a JSON-RPC client for one language server, over its stdio.
3
+ *
4
+ * STDOUT DISCIPLINE, first because it is the one mistake that breaks everything
5
+ * else. This process speaks JSON-RPC to its own MCP host on fd 1. A language
6
+ * server speaks JSON-RPC too, and getting the two streams confused corrupts the
7
+ * host's channel and takes every tool in the server down with it, not just this
8
+ * family. Three independent rules, all applied:
9
+ *
10
+ * 1. The child is spawned with `stdio: ["pipe","pipe","pipe"]`. Never
11
+ * "inherit" for fd 1 or 2. Nothing in this file touches process.stdout.
12
+ * 2. The child's stderr is drained into a small ring buffer and never
13
+ * forwarded. sourcekit-lsp logs there when SOURCEKIT_LSP_LOG_LEVEL is set,
14
+ * so the spawn env deletes that rather than inheriting a developer's shell.
15
+ * 3. Bytes off the child's stdout are parsed as LSP frames and never
16
+ * re-emitted. Anything unparseable goes into the same ring buffer.
17
+ *
18
+ * FRAMING is done over a Buffer, not a string. `Content-Length` counts bytes,
19
+ * and a UTF-8 identifier - a Turkish `ö`, an emoji in a string literal - will
20
+ * straddle a chunk boundary and be miscounted the moment the accumulator is a
21
+ * string.
22
+ *
23
+ * INDEXING is tracked, because it is the difference between two answers that
24
+ * look identical. Measured against sourcekit-lsp 6.3.1: a `references` request
25
+ * answers `[]` while the background index is still building, and `[3]` sixteen
26
+ * seconds later with no other change. Returning the first as "no references"
27
+ * reads as "this symbol is unused". The server announces the work - a
28
+ * `window/workDoneProgress/create` for a token named `indexing.<uuid>`, then
29
+ * `$/progress` begin ("Indexing", "Determining files"), report ("0 / 2"), end -
30
+ * so `indexing()` reports whether any such token is currently open, and
31
+ * `waitForIndex()` lets a caller hold until it closes.
32
+ *
33
+ * @module tools/code-intel/lsp-client
34
+ */
35
+
36
+ import { spawn } from "node:child_process";
37
+
38
+ const STDERR_RING = 8 * 1024;
39
+ const DEFAULT_REQUEST_TIMEOUT_MS = 20000;
40
+
41
+ /** Env a language server should not inherit from whoever started the host. */
42
+ const SCRUBBED_ENV = [
43
+ "SOURCEKIT_LSP_LOG_LEVEL",
44
+ "SOURCEKIT_LSP_LOG_PRIVACY_LEVEL",
45
+ "SOURCEKIT_TOOLCHAIN_PATH",
46
+ ];
47
+
48
+ /**
49
+ * Start a language server and talk to it.
50
+ *
51
+ * Never throws for anything the server does; a failure is a rejected request
52
+ * with a readable reason, and a dead server is reported through `onCrash`.
53
+ *
54
+ * @param {object} opts
55
+ * @param {string} opts.bin executable
56
+ * @param {string[]} [opts.argv] arguments, as an array - no shell
57
+ * @param {string} opts.cwd workspace root
58
+ * @param {object} [opts.env] extra env
59
+ * @param {object} opts.initializeParams LSP initialize params
60
+ * @param {number} [opts.requestTimeoutMs]
61
+ * @param {(m: object) => void} [opts.onNotification]
62
+ * @param {(info: object) => void} [opts.onCrash]
63
+ * @returns {object} the client
64
+ */
65
+ export function createLspClient(opts) {
66
+ const {
67
+ bin,
68
+ argv = [],
69
+ cwd,
70
+ env = {},
71
+ initializeParams,
72
+ requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
73
+ onNotification,
74
+ onCrash,
75
+ } = opts;
76
+
77
+ const childEnv = { ...process.env, ...env };
78
+ for (const k of SCRUBBED_ENV) delete childEnv[k];
79
+
80
+ const child = spawn(bin, argv, { cwd, env: childEnv, stdio: ["pipe", "pipe", "pipe"] });
81
+
82
+ let buf = Buffer.alloc(0);
83
+ let stderrTail = "";
84
+ let nextId = 1;
85
+ let disposing = false;
86
+ let exited = null;
87
+ let serverCapabilities = null;
88
+ const pending = new Map();
89
+ /** Extra notification subscribers, added and removed per request. */
90
+ const subscribers = new Set();
91
+ /** Open work-done tokens, by token -> {title, message, percentage}. */
92
+ const progress = new Map();
93
+ const indexWaiters = [];
94
+
95
+ const note = (s) => {
96
+ stderrTail = (stderrTail + s).slice(-STDERR_RING);
97
+ };
98
+
99
+ child.stderr.on("data", (d) => note(String(d)));
100
+ child.on("error", (e) => finish(127, e.message));
101
+ child.on("exit", (code, signal) => finish(code ?? -1, signal ? `signal ${signal}` : ""));
102
+
103
+ function finish(code, detail) {
104
+ if (exited) return;
105
+ exited = { code, detail };
106
+ const reason =
107
+ `ERROR: ${bin} exited (${code})` + (detail ? ` ${detail}` : "") + tail();
108
+ for (const [, p] of pending) {
109
+ clearTimeout(p.timer);
110
+ p.resolve({ error: { message: reason } });
111
+ }
112
+ pending.clear();
113
+ releaseIndexWaiters();
114
+ if (!disposing && typeof onCrash === "function") {
115
+ onCrash({ code, detail, stderr: stderrTail });
116
+ }
117
+ }
118
+
119
+ function tail() {
120
+ const t = stderrTail.trim();
121
+ return t ? `: ${t.slice(-400)}` : "";
122
+ }
123
+
124
+ function write(obj) {
125
+ if (exited || !child.stdin.writable) return false;
126
+ const s = JSON.stringify(obj);
127
+ child.stdin.write(`Content-Length: ${Buffer.byteLength(s, "utf8")}\r\n\r\n${s}`);
128
+ return true;
129
+ }
130
+
131
+ child.stdout.on("data", (chunk) => {
132
+ buf = Buffer.concat([buf, chunk]);
133
+ for (;;) {
134
+ const sep = buf.indexOf("\r\n\r\n");
135
+ if (sep === -1) {
136
+ // A header this long is not a header. Drop it rather than growing
137
+ // without bound on a server that prints a banner and nothing else.
138
+ if (buf.length > 64 * 1024) {
139
+ note(buf.toString("utf8"));
140
+ buf = Buffer.alloc(0);
141
+ }
142
+ return;
143
+ }
144
+ const header = buf.slice(0, sep).toString("ascii");
145
+ const m = /content-length:\s*(\d+)/i.exec(header);
146
+ if (!m) {
147
+ note(header);
148
+ buf = buf.slice(sep + 4);
149
+ continue;
150
+ }
151
+ const len = Number(m[1]);
152
+ if (buf.length < sep + 4 + len) return;
153
+ const body = buf.slice(sep + 4, sep + 4 + len).toString("utf8");
154
+ buf = buf.slice(sep + 4 + len);
155
+ let msg;
156
+ try {
157
+ msg = JSON.parse(body);
158
+ } catch {
159
+ note(body);
160
+ continue;
161
+ }
162
+ dispatch(msg);
163
+ }
164
+ });
165
+
166
+ function dispatch(msg) {
167
+ // A server-to-client REQUEST has both an id and a method. Ignoring these
168
+ // hangs the server: sourcekit-lsp waits on workDoneProgress/create before
169
+ // it starts indexing.
170
+ if (msg.id !== undefined && msg.method) {
171
+ if (msg.method === "window/workDoneProgress/create") {
172
+ const token = msg.params?.token;
173
+ if (token !== undefined) progress.set(tokenKey(token), { title: null });
174
+ }
175
+ write({ jsonrpc: "2.0", id: msg.id, result: null });
176
+ return;
177
+ }
178
+ if (msg.id !== undefined && pending.has(msg.id)) {
179
+ const p = pending.get(msg.id);
180
+ pending.delete(msg.id);
181
+ clearTimeout(p.timer);
182
+ p.resolve(msg);
183
+ return;
184
+ }
185
+ if (msg.method === "$/progress") {
186
+ onProgress(msg.params);
187
+ fanout(msg);
188
+ return;
189
+ }
190
+ if (msg.method) {
191
+ fanout(msg);
192
+ return;
193
+ }
194
+ note(JSON.stringify(msg).slice(0, 500));
195
+ }
196
+
197
+ function fanout(msg) {
198
+ if (typeof onNotification === "function") onNotification(msg);
199
+ for (const s of subscribers) {
200
+ try {
201
+ s(msg);
202
+ } catch {
203
+ // A subscriber that throws must not take the read loop with it.
204
+ }
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Listen to notifications for the length of one request.
210
+ *
211
+ * Diagnostics are the reason this exists: sourcekit-lsp publishes them
212
+ * unsolicited after didOpen rather than answering a request, so a caller has
213
+ * to be listening before it asks.
214
+ *
215
+ * @returns {() => void} unsubscribe
216
+ */
217
+ function subscribe(handler) {
218
+ subscribers.add(handler);
219
+ return () => subscribers.delete(handler);
220
+ }
221
+
222
+ function tokenKey(t) {
223
+ return typeof t === "string" ? t : JSON.stringify(t);
224
+ }
225
+
226
+ function onProgress(params) {
227
+ if (!params) return;
228
+ const key = tokenKey(params.token);
229
+ const v = params.value || {};
230
+ if (v.kind === "end") {
231
+ progress.delete(key);
232
+ releaseIndexWaiters();
233
+ return;
234
+ }
235
+ const prev = progress.get(key) || {};
236
+ progress.set(key, {
237
+ title: v.title ?? prev.title ?? null,
238
+ message: v.message ?? prev.message ?? null,
239
+ percentage: v.percentage ?? prev.percentage ?? null,
240
+ });
241
+ }
242
+
243
+ /** An open token that is the index build, by either of the two signals. */
244
+ function indexingEntry() {
245
+ for (const [key, v] of progress) {
246
+ if (key.startsWith("indexing.")) return { key, ...v };
247
+ if (typeof v.title === "string" && /index/i.test(v.title)) return { key, ...v };
248
+ }
249
+ return null;
250
+ }
251
+
252
+ function releaseIndexWaiters() {
253
+ if (indexingEntry()) return;
254
+ while (indexWaiters.length) {
255
+ const w = indexWaiters.shift();
256
+ clearTimeout(w.timer);
257
+ w.resolve(true);
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Send a request. Resolves with the raw JSON-RPC message, or a synthetic
263
+ * `{error}` - it never rejects, so no caller needs a try/catch to stay alive.
264
+ */
265
+ function request(method, params, { timeoutMs = requestTimeoutMs, signal } = {}) {
266
+ if (exited) {
267
+ return Promise.resolve({
268
+ error: { message: `ERROR: ${bin} is not running (${exited.code})${tail()}` },
269
+ });
270
+ }
271
+ const id = nextId++;
272
+ return new Promise((resolve) => {
273
+ let onAbort;
274
+ const settle = (v) => {
275
+ if (signal && onAbort) signal.removeEventListener("abort", onAbort);
276
+ resolve(v);
277
+ };
278
+ const timer = setTimeout(() => {
279
+ pending.delete(id);
280
+ write({ jsonrpc: "2.0", method: "$/cancelRequest", params: { id } });
281
+ // The server is NOT killed. One slow query is not a reason to throw
282
+ // away a warm index that took minutes to build.
283
+ settle({ error: { message: `ERROR: ${method} timed out after ${timeoutMs}ms` } });
284
+ }, timeoutMs);
285
+ pending.set(id, { resolve: settle, timer, method });
286
+ if (signal) {
287
+ onAbort = () => {
288
+ const p = pending.get(id);
289
+ if (!p) return;
290
+ pending.delete(id);
291
+ clearTimeout(p.timer);
292
+ write({ jsonrpc: "2.0", method: "$/cancelRequest", params: { id } });
293
+ settle({ error: { message: `ERROR: ${method} aborted` } });
294
+ };
295
+ signal.addEventListener("abort", onAbort, { once: true });
296
+ }
297
+ if (!write({ jsonrpc: "2.0", id, method, params })) {
298
+ pending.delete(id);
299
+ clearTimeout(timer);
300
+ settle({ error: { message: `ERROR: cannot write to ${bin}` } });
301
+ }
302
+ });
303
+ }
304
+
305
+ function notify(method, params) {
306
+ write({ jsonrpc: "2.0", method, params });
307
+ }
308
+
309
+ async function initialize({ timeoutMs } = {}) {
310
+ const res = await request("initialize", initializeParams, { timeoutMs });
311
+ if (res.error) return res;
312
+ serverCapabilities = res.result?.capabilities || {};
313
+ notify("initialized", {});
314
+ return res;
315
+ }
316
+
317
+ /**
318
+ * Hold until the index build closes, or the budget runs out.
319
+ *
320
+ * `settleMs` is not a nicety. Measured: `initialize` answers at ~680ms and the
321
+ * `indexing.<uuid>` token does not open until ~1750ms, so a wait that only
322
+ * asked "is a token open right now" returned true 900ms too early and the
323
+ * caller went on to read an empty `references` as "no references". The wait
324
+ * therefore has to survive a quiet gap: no open token has to STAY true for
325
+ * `settleMs` before it counts as done.
326
+ *
327
+ * @returns {Promise<boolean>} true when idle, false when the budget ran out
328
+ */
329
+ async function waitForIndex(timeoutMs, { settleMs = 2500 } = {}) {
330
+ const deadline = Date.now() + timeoutMs;
331
+ for (;;) {
332
+ if (indexingEntry()) {
333
+ const left = deadline - Date.now();
334
+ if (left <= 0) return false;
335
+ const closed = await awaitIndexClose(left);
336
+ if (!closed) return false;
337
+ continue;
338
+ }
339
+ const quiet = Math.min(settleMs, Math.max(0, deadline - Date.now()));
340
+ if (quiet === 0) return !indexingEntry();
341
+ await delay(quiet, { keepAlive: true });
342
+ if (!indexingEntry()) return true;
343
+ }
344
+ }
345
+
346
+ function awaitIndexClose(timeoutMs) {
347
+ return new Promise((resolve) => {
348
+ const timer = setTimeout(() => {
349
+ const i = indexWaiters.findIndex((w) => w.timer === timer);
350
+ if (i >= 0) indexWaiters.splice(i, 1);
351
+ resolve(false);
352
+ }, timeoutMs);
353
+ indexWaiters.push({ resolve, timer });
354
+ });
355
+ }
356
+
357
+ async function dispose() {
358
+ disposing = true;
359
+ if (!exited) {
360
+ await Promise.race([
361
+ request("shutdown", null, { timeoutMs: 2000 }),
362
+ delay(2000, { keepAlive: true }),
363
+ ]);
364
+ notify("exit");
365
+ await delay(150, { keepAlive: true });
366
+ if (!exited) child.kill("SIGTERM");
367
+ const hard = setTimeout(() => {
368
+ if (!exited) child.kill("SIGKILL");
369
+ }, 1000);
370
+ hard.unref?.();
371
+ }
372
+ // SETTLE, not just clear. Clearing a pending entry drops its `resolve`
373
+ // with nobody left to call it: the exit event that would have run finish()
374
+ // arrives to an empty map, and whoever was awaiting that request waits for
375
+ // the rest of the process's life. Measured against a server that ignores
376
+ // shutdown - the request never settled and the probe had to be timed out.
377
+ for (const [, p] of pending) {
378
+ clearTimeout(p.timer);
379
+ p.resolve({ error: { message: `ERROR: ${bin} was shut down while ${p.method} was in flight` } });
380
+ }
381
+ pending.clear();
382
+ while (indexWaiters.length) {
383
+ const w = indexWaiters.shift();
384
+ clearTimeout(w.timer);
385
+ w.resolve(false);
386
+ }
387
+ }
388
+
389
+ return {
390
+ request,
391
+ notify,
392
+ subscribe,
393
+ initialize,
394
+ dispose,
395
+ waitForIndex,
396
+ get pid() {
397
+ return child.pid;
398
+ },
399
+ get alive() {
400
+ return !exited;
401
+ },
402
+ get capabilities() {
403
+ return serverCapabilities;
404
+ },
405
+ /** `{title, message, percentage}` while the index is building, else null. */
406
+ indexing: () => indexingEntry(),
407
+ stderrTail: () => stderrTail,
408
+ };
409
+ }
410
+
411
+ /**
412
+ * `keepAlive` exists because an unref'd timer that is the only thing left in the
413
+ * loop never fires, and an `await` on it hangs the process instead of ending it.
414
+ * Measured on the first dispose(): "Detected unsettled top-level await". Waits
415
+ * that something is awaiting must hold the loop; fire-and-forget ones must not.
416
+ */
417
+ function delay(ms, { keepAlive = false } = {}) {
418
+ return new Promise((r) => {
419
+ const t = setTimeout(r, ms);
420
+ if (!keepAlive) t.unref?.();
421
+ });
422
+ }