@mmerterden/multi-agent-toolkit-mcp 3.9.0 → 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,273 @@
1
+ /**
2
+ * pool.js - one language server per workspace, kept warm, evicted politely.
3
+ *
4
+ * WHY POOL AT ALL. Measured against sourcekit-lsp 6.3.1 on a two-file package:
5
+ * `initialize` answers in ~600ms, but the background index that `references`
6
+ * and `workspace/symbol` depend on does not finish until ~5.8s. On a real
7
+ * package with dependencies the same sequence had not finished at 70s. Spawning
8
+ * per request pays that every time and throws the result away; the second
9
+ * question about the same repository should be fast, and it is only fast if the
10
+ * first one left something behind.
11
+ *
12
+ * WHY ONLY TWO. sourcekit-lsp with background indexing is a large resident
13
+ * process, and an MCP server quietly holding four of them is a bug report. Two
14
+ * covers the real pattern (one repo, sometimes a second) and the third eviction
15
+ * is the least recently used.
16
+ *
17
+ * WHY SERIALIZE PER ROOT. Every request about a file has to be preceded by that
18
+ * file being open on the server, and two tools interleaving didOpen/request on
19
+ * one server produce answers about the wrong document. Requests to DIFFERENT
20
+ * roots still run concurrently; only same-root work queues, and it queues behind
21
+ * something that is usually already warm.
22
+ *
23
+ * @module tools/code-intel/pool
24
+ */
25
+
26
+ import { readFileSync, statSync } from "node:fs";
27
+ import { pathToFileURL } from "node:url";
28
+ import { createLspClient } from "./lsp-client.js";
29
+
30
+ const IDLE_MS = 10 * 60 * 1000;
31
+ const MAX_SERVERS = 2;
32
+ const SWEEP_MS = 60 * 1000;
33
+ /** Three crashes this close together is a loop, not bad luck. */
34
+ const CRASH_WINDOW_MS = 60 * 1000;
35
+ const CRASH_LIMIT = 3;
36
+ const POISON_MS = 5 * 60 * 1000;
37
+
38
+ const servers = new Map();
39
+ /**
40
+ * One promise chain per key, held OUTSIDE the entry.
41
+ *
42
+ * It used to live on the entry as `entry.lock`, which meant a key with no entry
43
+ * yet had no lock at all: two concurrent calls for a cold root both walked
44
+ * straight past the barrier, both spawned a language server, and the second
45
+ * `servers.set` orphaned the first - never disposed, never swept, because the
46
+ * sweeper only walks this map. A leak of exactly the resident process this file
47
+ * caps at two.
48
+ */
49
+ const locks = new Map();
50
+ /** Crash records survive their entry, so a poison window is not lost on evict. */
51
+ const crashHistory = new Map();
52
+ let sweeper = null;
53
+
54
+ function startSweeper() {
55
+ if (sweeper) return;
56
+ sweeper = setInterval(() => {
57
+ const now = Date.now();
58
+ for (const [key, e] of [...servers]) {
59
+ if (now - e.lastUsed > IDLE_MS) evict(key, "idle");
60
+ }
61
+ if (servers.size === 0) {
62
+ clearInterval(sweeper);
63
+ sweeper = null;
64
+ }
65
+ }, SWEEP_MS);
66
+ sweeper.unref?.();
67
+ }
68
+
69
+ function evict(key, why) {
70
+ const e = servers.get(key);
71
+ if (!e) return;
72
+ servers.delete(key);
73
+ e.evictedBecause = why;
74
+ try {
75
+ e.client.dispose();
76
+ } catch {
77
+ /* disposing a dead server is not an error */
78
+ }
79
+ }
80
+
81
+ /** Every server, gone. Wired into the host's own shutdown. */
82
+ export function shutdownAllLsp() {
83
+ for (const key of [...servers.keys()]) evict(key, "shutdown");
84
+ locks.clear();
85
+ crashHistory.clear();
86
+ if (sweeper) {
87
+ clearInterval(sweeper);
88
+ sweeper = null;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Roots with work queued or in flight right now.
94
+ *
95
+ * Exported because the queue is the one piece of pool state nothing else can
96
+ * see: `poolStats()` lists servers, and a key can hold a queue without ever
97
+ * having started one - a start that failed, or a call still waiting its turn.
98
+ * A number here that only ever grows is the leak this returns to zero.
99
+ */
100
+ export function queuedKeys() {
101
+ return [...locks.keys()];
102
+ }
103
+
104
+ export function poolStats() {
105
+ return [...servers.entries()].map(([key, e]) => ({
106
+ key,
107
+ pid: e.client.pid,
108
+ alive: e.client.alive,
109
+ openDocs: e.openDocs.size,
110
+ idleSec: Math.round((Date.now() - e.lastUsed) / 1000),
111
+ }));
112
+ }
113
+
114
+ /**
115
+ * Stop the servers a caller names, or all of them.
116
+ * @returns {{stopped: string[], remaining: number}}
117
+ */
118
+ export function resetServers({ root, language } = {}) {
119
+ const stopped = [];
120
+ for (const [key, e] of [...servers]) {
121
+ if (root && e.root !== root) continue;
122
+ if (language && e.language !== language) continue;
123
+ stopped.push(key);
124
+ evict(key, "reset");
125
+ }
126
+ return { stopped, remaining: servers.size };
127
+ }
128
+
129
+ function keyFor(language, root) {
130
+ return `${language}:${root}`;
131
+ }
132
+
133
+ /**
134
+ * Run `fn` against a warm server for this root, one caller at a time.
135
+ *
136
+ * `fn` receives `{client, openDoc, root}`. It must not keep the client past its
137
+ * own return: the entry can be evicted the moment the lock is released.
138
+ */
139
+ export async function withServer(spec, fn) {
140
+ const { language, root, bin, argv, initializeParams, initTimeoutMs = 120000 } = spec;
141
+ const key = keyFor(language, root);
142
+ startSweeper();
143
+
144
+ // Queue on this key before looking at anything. Reading the entry first and
145
+ // deciding whether to queue is the race: the decision is made on state that
146
+ // the call ahead is about to change.
147
+ const previous = locks.get(key) || Promise.resolve();
148
+ let release;
149
+ const mine = new Promise((r) => (release = r));
150
+ const chain = previous.then(
151
+ () => mine,
152
+ () => mine,
153
+ );
154
+ locks.set(key, chain);
155
+ await previous.catch(() => {});
156
+
157
+ try {
158
+ let entry = servers.get(key);
159
+ if (entry?.poisonedUntil && Date.now() < entry.poisonedUntil) {
160
+ return {
161
+ error: `ERROR: ${language} language server crashed ${CRASH_LIMIT}x in a minute for ${root}; not restarting until ${new Date(entry.poisonedUntil).toISOString()}. Last stderr: ${entry.lastStderr || "(none)"}`,
162
+ };
163
+ }
164
+ if (entry && !entry.client.alive) {
165
+ evict(key, "dead");
166
+ entry = undefined;
167
+ }
168
+ if (!entry) {
169
+ if (servers.size >= MAX_SERVERS) {
170
+ const oldest = [...servers.entries()]
171
+ .filter(([k]) => k !== key)
172
+ .sort((a, b) => a[1].lastUsed - b[1].lastUsed)[0];
173
+ if (oldest) evict(oldest[0], "lru");
174
+ }
175
+ const started = await start({ key, language, root, bin, argv, initializeParams, initTimeoutMs });
176
+ if (started.error) return started;
177
+ entry = started;
178
+ servers.set(key, entry);
179
+ }
180
+ entry.lastUsed = Date.now();
181
+ const result = await fn({
182
+ client: entry.client,
183
+ root,
184
+ openDoc: (file) => openDoc(entry, file, language),
185
+ });
186
+ entry.lastUsed = Date.now();
187
+ return result;
188
+ } finally {
189
+ release();
190
+ // The queue outlives the server it guards unless the last caller clears it,
191
+ // and a key is added for every distinct root ever asked about - a slow leak
192
+ // in a server that runs for days. A later caller replaces the chain
193
+ // synchronously, before its first await, so a chain that is still ours
194
+ // means nobody is behind us.
195
+ if (locks.get(key) === chain) locks.delete(key);
196
+ }
197
+ }
198
+
199
+ async function start({ key, language, root, bin, argv, initializeParams, initTimeoutMs }) {
200
+ const prior = crashHistory.get(key);
201
+ const entry = {
202
+ language,
203
+ root,
204
+ openDocs: new Map(),
205
+ crashes: prior?.crashes || [],
206
+ poisonedUntil: prior?.poisonedUntil,
207
+ lastUsed: Date.now(),
208
+ lastStderr: prior?.lastStderr || "",
209
+ };
210
+ entry.client = createLspClient({
211
+ bin,
212
+ argv,
213
+ cwd: root,
214
+ initializeParams,
215
+ onCrash: (info) => {
216
+ // Mutate the entry in place. Replacing it with a copy broke identity with
217
+ // the `entry` an in-flight call is holding - its openDoc would go on
218
+ // writing to a record nothing else reads - and re-inserting it into the
219
+ // map resurrected a dead server that evict() had just removed.
220
+ entry.lastStderr = (info.stderr || "").trim().slice(-400);
221
+ const now = Date.now();
222
+ entry.crashes = [...entry.crashes.filter((t) => now - t < CRASH_WINDOW_MS), now];
223
+ if (entry.crashes.length >= CRASH_LIMIT) entry.poisonedUntil = now + POISON_MS;
224
+ // The crash record has to outlive the entry, or the poison window is lost
225
+ // the moment the dead entry is evicted.
226
+ crashHistory.set(key, { crashes: entry.crashes, poisonedUntil: entry.poisonedUntil, lastStderr: entry.lastStderr });
227
+ },
228
+ });
229
+ const init = await entry.client.initialize({ timeoutMs: initTimeoutMs });
230
+ if (init.error) {
231
+ try {
232
+ entry.client.dispose();
233
+ } catch {
234
+ /* ignore */
235
+ }
236
+ return { error: `ERROR: ${bin} failed to initialize: ${init.error.message}` };
237
+ }
238
+ return entry;
239
+ }
240
+
241
+ /**
242
+ * Make sure the server has this file, and has the version on disk.
243
+ *
244
+ * A stateless tool cannot track edits, so a file whose mtime moved is re-sent
245
+ * whole. `textDocumentSync.change: 2` means the server ACCEPTS incremental
246
+ * edits, not that it requires them.
247
+ */
248
+ function openDoc(entry, file, language) {
249
+ let text;
250
+ let mtime;
251
+ try {
252
+ text = readFileSync(file, "utf8");
253
+ mtime = statSync(file).mtimeMs;
254
+ } catch (e) {
255
+ return { error: `ERROR: cannot read ${file}: ${e.message}` };
256
+ }
257
+ const uri = pathToFileURL(file).href;
258
+ const known = entry.openDocs.get(uri);
259
+ if (!known) {
260
+ entry.openDocs.set(uri, { mtime, version: 1 });
261
+ entry.client.notify("textDocument/didOpen", {
262
+ textDocument: { uri, languageId: language, version: 1, text },
263
+ });
264
+ } else if (known.mtime !== mtime) {
265
+ known.version += 1;
266
+ known.mtime = mtime;
267
+ entry.client.notify("textDocument/didChange", {
268
+ textDocument: { uri, version: known.version },
269
+ contentChanges: [{ text }],
270
+ });
271
+ }
272
+ return { uri, text };
273
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * positions.js - the arithmetic, with no I/O and no processes.
3
+ *
4
+ * Everything here is a pure function, because everything here is the kind of
5
+ * mistake that produces a confident wrong answer rather than an error:
6
+ *
7
+ * - LSP counts lines and characters from 0. Editors, ripgrep and every
8
+ * citation in this pipeline count lines from 1. One off, and the tool
9
+ * answers about the line above the one asked for, with no sign anything
10
+ * went wrong.
11
+ * - LSP `character` counts UTF-16 code units, not bytes and not code points.
12
+ * A Turkish identifier or an emoji earlier in the line shifts every column
13
+ * after it.
14
+ * - A caller that has to count columns will get it wrong. `symbol` resolves a
15
+ * name against the file's own symbol tree instead, which needs no build
16
+ * configuration at all.
17
+ *
18
+ * @module tools/code-intel/positions
19
+ */
20
+
21
+ /** Extensions this family can answer for, and which server answers. */
22
+ export const LANGUAGE_BY_EXT = {
23
+ swift: "swift",
24
+ kt: "kotlin",
25
+ kts: "kotlin",
26
+ };
27
+
28
+ /**
29
+ * @param {string} file
30
+ * @returns {"swift"|"kotlin"|null}
31
+ */
32
+ export function languageOf(file) {
33
+ const m = /\.([A-Za-z0-9]+)$/.exec(file || "");
34
+ if (!m) return null;
35
+ return LANGUAGE_BY_EXT[m[1].toLowerCase()] || null;
36
+ }
37
+
38
+ /**
39
+ * A 1-based line/column, as a caller writes it, to an LSP position.
40
+ *
41
+ * @param {string} text the file's contents
42
+ * @param {number} line 1-based
43
+ * @param {number} column 1-based, in characters
44
+ * @returns {{line: number, character: number}|{error: string}}
45
+ */
46
+ export function toLspPosition(text, line, column) {
47
+ if (!Number.isInteger(line) || line < 1) return { error: `line ${line} is not a line number` };
48
+ const lines = text.split("\n");
49
+ if (line > lines.length) {
50
+ return { error: `line ${line} is past the end of the file (${lines.length} lines)` };
51
+ }
52
+ const col = Number.isInteger(column) && column >= 1 ? column : 1;
53
+ const src = lines[line - 1];
54
+ // Column counted in CHARACTERS by the caller, in UTF-16 units by LSP. Walk
55
+ // the code points rather than slicing, so an astral character counts 2.
56
+ let units = 0;
57
+ let seen = 0;
58
+ for (const ch of src) {
59
+ if (seen >= col - 1) break;
60
+ units += ch.length;
61
+ seen += 1;
62
+ }
63
+ return { line: line - 1, character: units };
64
+ }
65
+
66
+ /**
67
+ * An LSP position back to what a human reads.
68
+ *
69
+ * @param {string|null} text the file's contents, when available
70
+ * @param {{line: number, character: number}} pos
71
+ * @returns {{line: number, column: number}} both 1-based
72
+ */
73
+ export function fromLspPosition(text, pos) {
74
+ const line = (pos?.line ?? 0) + 1;
75
+ const units = pos?.character ?? 0;
76
+ if (typeof text !== "string") return { line, column: units + 1 };
77
+ const src = text.split("\n")[line - 1];
78
+ if (typeof src !== "string") return { line, column: units + 1 };
79
+ let consumed = 0;
80
+ let chars = 0;
81
+ for (const ch of src) {
82
+ if (consumed >= units) break;
83
+ consumed += ch.length;
84
+ chars += 1;
85
+ }
86
+ return { line, column: chars + 1 };
87
+ }
88
+
89
+ /**
90
+ * Find a symbol in a `textDocument/documentSymbol` result.
91
+ *
92
+ * Accepts either shape the spec allows: the hierarchical `DocumentSymbol[]`
93
+ * and the flat `SymbolInformation[]`. Matching is on the plain name or on a
94
+ * dotted path (`Greeter.greet`), so a caller can disambiguate without knowing
95
+ * the tree.
96
+ *
97
+ * Ambiguity is returned, never resolved: picking the first of two `reload`
98
+ * methods answers about the wrong one and looks identical to an answer about
99
+ * the right one.
100
+ *
101
+ * @returns {{match: object}|{candidates: object[]}|{error: string}}
102
+ */
103
+ export function findSymbol(symbols, query) {
104
+ const wanted = String(query || "").trim();
105
+ if (!wanted) return { error: "no symbol given" };
106
+ const flat = [];
107
+ const walk = (nodes, prefix) => {
108
+ for (const s of nodes || []) {
109
+ if (!s || typeof s.name !== "string") continue;
110
+ const path = prefix ? `${prefix}.${s.name}` : s.name;
111
+ const range = s.selectionRange || s.range || s.location?.range;
112
+ flat.push({ name: s.name, path, kind: s.kind, range });
113
+ if (Array.isArray(s.children) && s.children.length) walk(s.children, path);
114
+ }
115
+ };
116
+ walk(Array.isArray(symbols) ? symbols : [], "");
117
+
118
+ const exactPath = flat.filter((s) => s.path === wanted);
119
+ const byName = flat.filter((s) => s.name === wanted);
120
+ const hits = exactPath.length ? exactPath : byName;
121
+
122
+ if (hits.length === 1) return { match: hits[0] };
123
+ if (hits.length > 1) return { candidates: hits };
124
+
125
+ const near = flat
126
+ .filter((s) => s.name.toLowerCase().includes(wanted.toLowerCase()))
127
+ .slice(0, 8);
128
+ return {
129
+ error: near.length
130
+ ? `no symbol named "${wanted}" in this file; nearest: ${near.map((n) => n.path).join(", ")}`
131
+ : `no symbol named "${wanted}" in this file`,
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Turn LSP locations into something a caller can read, and cap them.
137
+ *
138
+ * The cap is load-bearing rather than tidy: a tool that declares an
139
+ * `outputSchema` skips this server's automatic large-payload offload, so
140
+ * nothing else stands between a common symbol's four thousand references and
141
+ * the caller's context.
142
+ */
143
+ export function shapeLocations(locations, { root, maxResults = 200, readFile } = {}) {
144
+ const list = Array.isArray(locations) ? locations : locations ? [locations] : [];
145
+ const total = list.length;
146
+ const kept = list.slice(0, maxResults);
147
+ const cache = new Map();
148
+ const results = kept.map((loc) => {
149
+ const uri = loc.uri || loc.targetUri || "";
150
+ const range = loc.range || loc.targetSelectionRange || loc.targetRange || {};
151
+ const abs = uriToPath(uri);
152
+ let text = null;
153
+ if (typeof readFile === "function") {
154
+ if (!cache.has(abs)) cache.set(abs, readFile(abs));
155
+ text = cache.get(abs);
156
+ }
157
+ const start = fromLspPosition(text, range.start);
158
+ const out = {
159
+ file: root && abs.startsWith(root + "/") ? abs.slice(root.length + 1) : abs,
160
+ line: start.line,
161
+ column: start.column,
162
+ };
163
+ if (typeof text === "string") {
164
+ const src = text.split("\n")[start.line - 1];
165
+ if (typeof src === "string") out.preview = src.trim().slice(0, 200);
166
+ }
167
+ return out;
168
+ });
169
+ return { count: total, truncated: total > kept.length, results };
170
+ }
171
+
172
+ /** `file:///a/b` to `/a/b`, with percent-escapes undone. */
173
+ export function uriToPath(uri) {
174
+ if (typeof uri !== "string") return "";
175
+ if (!uri.startsWith("file://")) return uri;
176
+ try {
177
+ return decodeURIComponent(uri.slice("file://".length));
178
+ } catch {
179
+ return uri.slice("file://".length);
180
+ }
181
+ }
182
+
183
+ /** The three legal shapes of `Hover.contents`, as one string. */
184
+ export function markdownFromHover(contents) {
185
+ if (contents == null) return "";
186
+ if (typeof contents === "string") return contents;
187
+ if (Array.isArray(contents)) return contents.map(markdownFromHover).filter(Boolean).join("\n\n");
188
+ if (typeof contents === "object") {
189
+ if (typeof contents.value === "string") return contents.value;
190
+ if (typeof contents.language === "string" && typeof contents.value === "string") {
191
+ return contents.value;
192
+ }
193
+ }
194
+ return "";
195
+ }