@theupsider/lsp-mcp 0.1.1 → 0.1.3
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/dist/__tests__/index.test.js +2 -1
- package/dist/detection/language-detector.js +7 -8
- package/dist/detection/language-registry.js +38 -0
- package/dist/lsp/__tests__/lifecycle-manager.test.js +10 -8
- package/dist/lsp/diagnostic-store.js +49 -0
- package/dist/lsp/lifecycle-manager.js +42 -194
- package/dist/lsp/lsp-client.js +131 -44
- package/dist/lsp/server-supervisor.js +152 -0
- package/dist/mcp/__tests__/formatters.test.js +1 -1
- package/dist/mcp/__tests__/read-tools.test.js +11 -9
- package/dist/mcp/__tests__/server.test.js +4 -1
- package/dist/mcp/__tests__/write-tools.test.js +10 -6
- package/dist/mcp/formatters.js +1 -1
- package/dist/mcp/server.js +4 -3
- package/dist/mcp/tools/read-tools.js +14 -3
- package/dist/mcp/tools/shared.js +0 -44
- package/dist/mcp/tools/write-tools.js +5 -5
- package/package.json +1 -1
package/dist/lsp/lsp-client.js
CHANGED
|
@@ -5,8 +5,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.LspClient = void 0;
|
|
7
7
|
const node_events_1 = require("node:events");
|
|
8
|
+
const promises_1 = require("node:fs/promises");
|
|
8
9
|
const node_child_process_1 = require("node:child_process");
|
|
9
10
|
const node_path_1 = __importDefault(require("node:path"));
|
|
11
|
+
const language_registry_1 = require("../detection/language-registry");
|
|
10
12
|
const uri_1 = require("../utils/uri");
|
|
11
13
|
class LspClient extends node_events_1.EventEmitter {
|
|
12
14
|
serverDef;
|
|
@@ -21,6 +23,7 @@ class LspClient extends node_events_1.EventEmitter {
|
|
|
21
23
|
exitExpected = false;
|
|
22
24
|
initializeResult = null;
|
|
23
25
|
forcedKillTimer = null;
|
|
26
|
+
openedFiles = new Set();
|
|
24
27
|
constructor(serverDef, projectRoot, logLevel) {
|
|
25
28
|
super();
|
|
26
29
|
this.serverDef = serverDef;
|
|
@@ -34,35 +37,46 @@ class LspClient extends node_events_1.EventEmitter {
|
|
|
34
37
|
this.process = (0, node_child_process_1.spawn)(this.serverDef.cmd, this.serverDef.args, {
|
|
35
38
|
cwd: this.projectRoot,
|
|
36
39
|
env: { ...process.env },
|
|
37
|
-
stdio:
|
|
40
|
+
stdio: "pipe",
|
|
38
41
|
});
|
|
39
|
-
this.process.stdout.on(
|
|
42
|
+
this.process.stdout.on("data", (chunk) => {
|
|
40
43
|
this.handleData(chunk);
|
|
41
44
|
});
|
|
42
|
-
this.process.on(
|
|
43
|
-
this.log(
|
|
44
|
-
this.emit(
|
|
45
|
+
this.process.on("error", (error) => {
|
|
46
|
+
this.log("error", "lsp_process_error", { error: error.message });
|
|
47
|
+
this.emit("error", error);
|
|
45
48
|
});
|
|
46
|
-
this.process.on(
|
|
49
|
+
this.process.on("exit", (code, signal) => {
|
|
47
50
|
this.handleExit(code, signal);
|
|
48
51
|
});
|
|
49
|
-
this.log(
|
|
50
|
-
const initializeResult = await this.request(
|
|
52
|
+
this.log("info", "lsp_starting", { language: this.serverDef.cmd });
|
|
53
|
+
const initializeResult = await this.request("initialize", {
|
|
51
54
|
processId: process.pid,
|
|
52
|
-
clientInfo: { name:
|
|
55
|
+
clientInfo: { name: "lsp-mcp", version: "0.1.0" },
|
|
53
56
|
rootUri: (0, uri_1.pathToUri)(this.projectRoot),
|
|
54
57
|
workspaceFolders: [
|
|
55
58
|
{
|
|
56
59
|
uri: (0, uri_1.pathToUri)(this.projectRoot),
|
|
57
|
-
name: node_path_1.default.basename(this.projectRoot)
|
|
58
|
-
}
|
|
60
|
+
name: node_path_1.default.basename(this.projectRoot),
|
|
61
|
+
},
|
|
59
62
|
],
|
|
60
|
-
capabilities: {
|
|
63
|
+
capabilities: {
|
|
64
|
+
textDocument: {
|
|
65
|
+
publishDiagnostics: {
|
|
66
|
+
relatedInformation: true,
|
|
67
|
+
versionSupport: false,
|
|
68
|
+
tagSupport: { valueSet: [1, 2] },
|
|
69
|
+
},
|
|
70
|
+
synchronization: {
|
|
71
|
+
didSave: true,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
61
75
|
}, 30000);
|
|
62
76
|
this.initializeResult = initializeResult;
|
|
63
|
-
this.notify(
|
|
77
|
+
this.notify("initialized", {});
|
|
64
78
|
this.ready = true;
|
|
65
|
-
this.log(
|
|
79
|
+
this.log("info", "lsp_ready", { language: this.serverDef.cmd });
|
|
66
80
|
}
|
|
67
81
|
isReady() {
|
|
68
82
|
return this.ready;
|
|
@@ -71,10 +85,10 @@ class LspClient extends node_events_1.EventEmitter {
|
|
|
71
85
|
const id = this.nextRequestId;
|
|
72
86
|
this.nextRequestId += 1;
|
|
73
87
|
const message = {
|
|
74
|
-
jsonrpc:
|
|
88
|
+
jsonrpc: "2.0",
|
|
75
89
|
id,
|
|
76
90
|
method,
|
|
77
|
-
params
|
|
91
|
+
params,
|
|
78
92
|
};
|
|
79
93
|
return await new Promise((resolve, reject) => {
|
|
80
94
|
const timeoutHandle = setTimeout(() => {
|
|
@@ -84,16 +98,16 @@ class LspClient extends node_events_1.EventEmitter {
|
|
|
84
98
|
this.pendingRequests.set(id, {
|
|
85
99
|
resolve: (value) => resolve(value),
|
|
86
100
|
reject,
|
|
87
|
-
timeoutHandle
|
|
101
|
+
timeoutHandle,
|
|
88
102
|
});
|
|
89
103
|
this.sendMessage(message);
|
|
90
104
|
});
|
|
91
105
|
}
|
|
92
106
|
notify(method, params) {
|
|
93
107
|
const message = {
|
|
94
|
-
jsonrpc:
|
|
108
|
+
jsonrpc: "2.0",
|
|
95
109
|
method,
|
|
96
|
-
params
|
|
110
|
+
params,
|
|
97
111
|
};
|
|
98
112
|
this.sendMessage(message);
|
|
99
113
|
}
|
|
@@ -103,65 +117,108 @@ class LspClient extends node_events_1.EventEmitter {
|
|
|
103
117
|
this.ready = false;
|
|
104
118
|
return;
|
|
105
119
|
}
|
|
106
|
-
await this.request(
|
|
107
|
-
this.notify(
|
|
120
|
+
await this.request("shutdown", {}, 5000);
|
|
121
|
+
this.notify("exit", {});
|
|
108
122
|
this.ready = false;
|
|
109
123
|
const currentProcess = this.process;
|
|
110
124
|
this.forcedKillTimer = setTimeout(() => {
|
|
111
|
-
currentProcess.kill(
|
|
125
|
+
currentProcess.kill("SIGKILL");
|
|
112
126
|
}, 5000);
|
|
113
127
|
}
|
|
114
128
|
getCapabilities() {
|
|
115
129
|
return this.initializeResult?.capabilities ?? null;
|
|
116
130
|
}
|
|
131
|
+
waitForDiagnosticsPublish(filePath, timeoutMs) {
|
|
132
|
+
const uri = (0, uri_1.pathToUri)(filePath);
|
|
133
|
+
return new Promise((resolve) => {
|
|
134
|
+
const timer = setTimeout(resolve, timeoutMs);
|
|
135
|
+
const handler = (method, params) => {
|
|
136
|
+
if (method === "textDocument/publishDiagnostics") {
|
|
137
|
+
const p = params;
|
|
138
|
+
if (p?.uri === uri) {
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
this.off("notification", handler);
|
|
141
|
+
resolve();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
this.on("notification", handler);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
async ensureDidOpen(filePath) {
|
|
149
|
+
if (this.openedFiles.has(filePath)) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const text = await (0, promises_1.readFile)(filePath, "utf8");
|
|
153
|
+
this.notify("textDocument/didOpen", {
|
|
154
|
+
textDocument: {
|
|
155
|
+
uri: (0, uri_1.pathToUri)(filePath),
|
|
156
|
+
languageId: (0, language_registry_1.extensionToLanguageId)(node_path_1.default.extname(filePath)),
|
|
157
|
+
version: 1,
|
|
158
|
+
text,
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
this.openedFiles.add(filePath);
|
|
162
|
+
}
|
|
163
|
+
async ensureSeedFileOpen(extensions) {
|
|
164
|
+
if (this.openedFiles.size > 0) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const file = await findFirstFileWithExtension(this.projectRoot, extensions);
|
|
168
|
+
if (file) {
|
|
169
|
+
await this.ensureDidOpen(file);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
117
172
|
sendMessage(message) {
|
|
118
173
|
if (!this.process) {
|
|
119
|
-
throw new Error(
|
|
174
|
+
throw new Error("LSP process is not running");
|
|
120
175
|
}
|
|
121
176
|
const body = JSON.stringify(message);
|
|
122
|
-
const content = `Content-Length: ${Buffer.byteLength(body,
|
|
177
|
+
const content = `Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`;
|
|
123
178
|
this.process.stdin.write(content);
|
|
124
179
|
}
|
|
125
180
|
handleData(chunk) {
|
|
126
|
-
const incoming = typeof chunk ===
|
|
181
|
+
const incoming = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
|
|
127
182
|
this.buffer = Buffer.concat([this.buffer, incoming]);
|
|
128
183
|
while (true) {
|
|
129
184
|
if (this.contentLength === null) {
|
|
130
|
-
const headerEnd = this.buffer.indexOf(
|
|
185
|
+
const headerEnd = this.buffer.indexOf("\r\n\r\n");
|
|
131
186
|
if (headerEnd === -1) {
|
|
132
187
|
return;
|
|
133
188
|
}
|
|
134
|
-
const header = this.buffer.subarray(0, headerEnd).toString(
|
|
189
|
+
const header = this.buffer.subarray(0, headerEnd).toString("utf8");
|
|
135
190
|
this.buffer = this.buffer.subarray(headerEnd + 4);
|
|
136
191
|
const contentLengthLine = header
|
|
137
|
-
.split(
|
|
138
|
-
.find((line) => line.toLowerCase().startsWith(
|
|
192
|
+
.split("\r\n")
|
|
193
|
+
.find((line) => line.toLowerCase().startsWith("content-length:"));
|
|
139
194
|
if (!contentLengthLine) {
|
|
140
|
-
const error = new Error(
|
|
141
|
-
this.emit(
|
|
195
|
+
const error = new Error("Missing Content-Length header");
|
|
196
|
+
this.emit("error", error);
|
|
142
197
|
return;
|
|
143
198
|
}
|
|
144
|
-
const value = Number.parseInt(contentLengthLine.slice(
|
|
199
|
+
const value = Number.parseInt(contentLengthLine.slice("Content-Length:".length).trim(), 10);
|
|
145
200
|
this.contentLength = value;
|
|
146
201
|
}
|
|
147
202
|
if (this.buffer.byteLength < this.contentLength) {
|
|
148
203
|
return;
|
|
149
204
|
}
|
|
150
|
-
const messageBody = this.buffer
|
|
205
|
+
const messageBody = this.buffer
|
|
206
|
+
.subarray(0, this.contentLength)
|
|
207
|
+
.toString("utf8");
|
|
151
208
|
this.buffer = this.buffer.subarray(this.contentLength);
|
|
152
209
|
this.contentLength = null;
|
|
153
210
|
this.handleMessage(JSON.parse(messageBody));
|
|
154
211
|
}
|
|
155
212
|
}
|
|
156
213
|
handleMessage(message) {
|
|
157
|
-
if (!message || typeof message !==
|
|
214
|
+
if (!message || typeof message !== "object") {
|
|
158
215
|
return;
|
|
159
216
|
}
|
|
160
|
-
if (
|
|
161
|
-
this.emit(
|
|
217
|
+
if ("method" in message) {
|
|
218
|
+
this.emit("notification", message.method, message.params);
|
|
162
219
|
return;
|
|
163
220
|
}
|
|
164
|
-
if (
|
|
221
|
+
if ("id" in message && "error" in message && message.id !== null) {
|
|
165
222
|
const pending = this.pendingRequests.get(message.id);
|
|
166
223
|
if (!pending) {
|
|
167
224
|
return;
|
|
@@ -171,7 +228,7 @@ class LspClient extends node_events_1.EventEmitter {
|
|
|
171
228
|
pending.reject(new Error(message.error.message));
|
|
172
229
|
return;
|
|
173
230
|
}
|
|
174
|
-
if (
|
|
231
|
+
if ("id" in message && "result" in message) {
|
|
175
232
|
const pending = this.pendingRequests.get(message.id);
|
|
176
233
|
if (!pending) {
|
|
177
234
|
return;
|
|
@@ -186,13 +243,16 @@ class LspClient extends node_events_1.EventEmitter {
|
|
|
186
243
|
clearTimeout(this.forcedKillTimer);
|
|
187
244
|
this.forcedKillTimer = null;
|
|
188
245
|
}
|
|
189
|
-
this.rejectAllPending(new Error(`LSP server exited (code: ${code ??
|
|
246
|
+
this.rejectAllPending(new Error(`LSP server exited (code: ${code ?? "null"}, signal: ${signal ?? "null"})`));
|
|
190
247
|
this.ready = false;
|
|
191
248
|
this.process = null;
|
|
192
249
|
if (!this.exitExpected) {
|
|
193
|
-
const error = new Error(`LSP server exited unexpectedly (code: ${code ??
|
|
194
|
-
this.log(
|
|
195
|
-
|
|
250
|
+
const error = new Error(`LSP server exited unexpectedly (code: ${code ?? "null"}, signal: ${signal ?? "null"})`);
|
|
251
|
+
this.log("error", "lsp_crash", {
|
|
252
|
+
error: error.message,
|
|
253
|
+
language: this.serverDef.cmd,
|
|
254
|
+
});
|
|
255
|
+
this.emit("crash", error);
|
|
196
256
|
}
|
|
197
257
|
}
|
|
198
258
|
rejectAllPending(error) {
|
|
@@ -210,7 +270,7 @@ class LspClient extends node_events_1.EventEmitter {
|
|
|
210
270
|
timestamp: new Date().toISOString(),
|
|
211
271
|
level,
|
|
212
272
|
event,
|
|
213
|
-
...extra
|
|
273
|
+
...extra,
|
|
214
274
|
};
|
|
215
275
|
process.stderr.write(`${JSON.stringify(payload)}\n`);
|
|
216
276
|
}
|
|
@@ -220,7 +280,34 @@ function shouldLog(configuredLevel, messageLevel) {
|
|
|
220
280
|
const order = {
|
|
221
281
|
error: 0,
|
|
222
282
|
info: 1,
|
|
223
|
-
debug: 2
|
|
283
|
+
debug: 2,
|
|
224
284
|
};
|
|
225
285
|
return order[messageLevel] <= order[configuredLevel];
|
|
226
286
|
}
|
|
287
|
+
const SKIP_DIRS = new Set([
|
|
288
|
+
'node_modules', '.git', 'dist', 'build', 'out', '.next',
|
|
289
|
+
'coverage', '__pycache__', 'target', '.cache', 'vendor',
|
|
290
|
+
]);
|
|
291
|
+
async function findFirstFileWithExtension(dir, extensions) {
|
|
292
|
+
const extensionSet = new Set(extensions.map((e) => e.toLowerCase()));
|
|
293
|
+
let entries;
|
|
294
|
+
try {
|
|
295
|
+
entries = await (0, promises_1.readdir)(dir, { withFileTypes: true });
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
for (const entry of entries) {
|
|
301
|
+
if (entry.isFile() && extensionSet.has(node_path_1.default.extname(entry.name).toLowerCase())) {
|
|
302
|
+
return node_path_1.default.join(dir, entry.name);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
for (const entry of entries) {
|
|
306
|
+
if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) {
|
|
307
|
+
const result = await findFirstFileWithExtension(node_path_1.default.join(dir, entry.name), extensions);
|
|
308
|
+
if (result)
|
|
309
|
+
return result;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ServerSupervisor = void 0;
|
|
4
|
+
const lsp_mapping_1 = require("../detection/lsp-mapping");
|
|
5
|
+
const installer_1 = require("./installer");
|
|
6
|
+
const lsp_client_1 = require("./lsp-client");
|
|
7
|
+
class ServerSupervisor {
|
|
8
|
+
language;
|
|
9
|
+
restartCount = 0;
|
|
10
|
+
client = null;
|
|
11
|
+
status = 'starting';
|
|
12
|
+
error = undefined;
|
|
13
|
+
capabilities = undefined;
|
|
14
|
+
serverDef = null;
|
|
15
|
+
healthInterval = null;
|
|
16
|
+
projectRoot;
|
|
17
|
+
logLevel;
|
|
18
|
+
onDiagnostics;
|
|
19
|
+
shuttingDown = false;
|
|
20
|
+
constructor(language, projectRoot, logLevel, onDiagnostics) {
|
|
21
|
+
this.language = language;
|
|
22
|
+
this.projectRoot = projectRoot;
|
|
23
|
+
this.logLevel = logLevel;
|
|
24
|
+
this.onDiagnostics = onDiagnostics;
|
|
25
|
+
}
|
|
26
|
+
async start() {
|
|
27
|
+
const serverDef = await this.resolveServer();
|
|
28
|
+
if (!serverDef) {
|
|
29
|
+
this.status = 'error';
|
|
30
|
+
this.error = `No LSP server available for ${this.language}`;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
this.serverDef = serverDef;
|
|
34
|
+
const client = new lsp_client_1.LspClient(serverDef, this.projectRoot, this.logLevel);
|
|
35
|
+
this.client = client;
|
|
36
|
+
client.on('crash', async () => {
|
|
37
|
+
if (this.shuttingDown) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
await this.restart(`LSP server crashed for ${this.language}`);
|
|
41
|
+
});
|
|
42
|
+
client.on('error', (error) => {
|
|
43
|
+
this.status = 'error';
|
|
44
|
+
this.error = error instanceof Error ? error.message : 'Unknown LSP error';
|
|
45
|
+
});
|
|
46
|
+
client.on('notification', (method, params) => {
|
|
47
|
+
this.onDiagnostics(method, params);
|
|
48
|
+
});
|
|
49
|
+
try {
|
|
50
|
+
await client.start();
|
|
51
|
+
this.status = 'ready';
|
|
52
|
+
this.error = undefined;
|
|
53
|
+
this.capabilities = client.getCapabilities() ?? undefined;
|
|
54
|
+
this.startHealthChecks();
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
this.status = 'error';
|
|
58
|
+
this.error = error instanceof Error ? error.message : 'Unknown LSP startup error';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async restart(reason) {
|
|
62
|
+
if (this.healthInterval) {
|
|
63
|
+
clearInterval(this.healthInterval);
|
|
64
|
+
this.healthInterval = null;
|
|
65
|
+
}
|
|
66
|
+
if (this.restartCount >= 3) {
|
|
67
|
+
this.status = 'error';
|
|
68
|
+
this.error = reason;
|
|
69
|
+
this.client = null;
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
this.restartCount += 1;
|
|
73
|
+
this.status = 'starting';
|
|
74
|
+
this.error = reason;
|
|
75
|
+
if (this.client) {
|
|
76
|
+
try {
|
|
77
|
+
await promiseWithTimeout(this.client.shutdown(), 5000, 'LSP shutdown timed out');
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// Ignore shutdown failures during restart.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
this.client = null;
|
|
84
|
+
await this.start();
|
|
85
|
+
}
|
|
86
|
+
async shutdown() {
|
|
87
|
+
this.shuttingDown = true;
|
|
88
|
+
if (this.healthInterval) {
|
|
89
|
+
clearInterval(this.healthInterval);
|
|
90
|
+
this.healthInterval = null;
|
|
91
|
+
}
|
|
92
|
+
if (this.client) {
|
|
93
|
+
await this.client.shutdown();
|
|
94
|
+
this.client = null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
getClient() {
|
|
98
|
+
return this.client;
|
|
99
|
+
}
|
|
100
|
+
getHealth() {
|
|
101
|
+
return {
|
|
102
|
+
language: this.language,
|
|
103
|
+
status: this.status,
|
|
104
|
+
error: this.error,
|
|
105
|
+
capabilities: this.capabilities
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
startHealthChecks() {
|
|
109
|
+
if (this.healthInterval) {
|
|
110
|
+
clearInterval(this.healthInterval);
|
|
111
|
+
}
|
|
112
|
+
this.healthInterval = setInterval(() => {
|
|
113
|
+
void this.runHealthCheck();
|
|
114
|
+
}, 30000);
|
|
115
|
+
}
|
|
116
|
+
async runHealthCheck() {
|
|
117
|
+
if (!this.client || this.status !== 'ready') {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (!this.client.isReady()) {
|
|
121
|
+
await this.restart('LSP client lost ready state');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async resolveServer() {
|
|
125
|
+
const available = await (0, lsp_mapping_1.findAvailableLsp)(this.language);
|
|
126
|
+
if (available) {
|
|
127
|
+
return available;
|
|
128
|
+
}
|
|
129
|
+
const candidate = (0, lsp_mapping_1.getLspCandidates)(this.language)[0] ?? null;
|
|
130
|
+
if (!candidate) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
const installation = await (0, installer_1.installLsp)(candidate);
|
|
134
|
+
if (!installation.success) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
return candidate;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
exports.ServerSupervisor = ServerSupervisor;
|
|
141
|
+
async function promiseWithTimeout(promise, timeoutMs, message) {
|
|
142
|
+
return await new Promise((resolve, reject) => {
|
|
143
|
+
const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
|
144
|
+
promise.then((value) => {
|
|
145
|
+
clearTimeout(timer);
|
|
146
|
+
resolve(value);
|
|
147
|
+
}, (error) => {
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
reject(error);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
}
|
|
@@ -133,7 +133,7 @@ describe('mcp formatters', () => {
|
|
|
133
133
|
expect((0, formatters_1.formatDefinition)(null)).toBe('No result');
|
|
134
134
|
expect((0, formatters_1.formatReferences)([])).toBe('No result');
|
|
135
135
|
expect((0, formatters_1.formatSymbols)(null)).toBe('No result');
|
|
136
|
-
expect((0, formatters_1.formatDiagnostics)([], 'file')).toBe('No
|
|
136
|
+
expect((0, formatters_1.formatDiagnostics)([], 'file')).toBe('No diagnostics found');
|
|
137
137
|
expect((0, formatters_1.formatCompletion)(null)).toBe('No result');
|
|
138
138
|
expect((0, formatters_1.formatHealth)([])).toBe('No result');
|
|
139
139
|
});
|
|
@@ -4,7 +4,6 @@ const promises_1 = require("node:fs/promises");
|
|
|
4
4
|
const read_tools_1 = require("../tools/read-tools");
|
|
5
5
|
const vscode_languageserver_protocol_1 = require("vscode-languageserver-protocol");
|
|
6
6
|
jest.mock('node:fs/promises', () => ({
|
|
7
|
-
readFile: jest.fn(),
|
|
8
7
|
stat: jest.fn()
|
|
9
8
|
}));
|
|
10
9
|
class FakeRegistrar {
|
|
@@ -16,7 +15,6 @@ class FakeRegistrar {
|
|
|
16
15
|
describe('registerReadTools', () => {
|
|
17
16
|
beforeEach(() => {
|
|
18
17
|
jest.clearAllMocks();
|
|
19
|
-
promises_1.readFile.mockResolvedValue('const foo = 1;');
|
|
20
18
|
promises_1.stat.mockResolvedValue({ isDirectory: () => true });
|
|
21
19
|
});
|
|
22
20
|
it('initializes LSP with a valid root and reports health', async () => {
|
|
@@ -94,10 +92,8 @@ describe('registerReadTools', () => {
|
|
|
94
92
|
(0, read_tools_1.registerReadTools)(registrar, lifecycle, { initializeManager: jest.fn() });
|
|
95
93
|
const hover = await getHandler(registrar, 'lsp_hover')({ file: '/workspace/src/index.ts', line: 2, character: 4 });
|
|
96
94
|
await getHandler(registrar, 'lsp_hover')({ file: '/workspace/src/index.ts', line: 2, character: 4 });
|
|
97
|
-
expect(
|
|
98
|
-
expect(client.
|
|
99
|
-
textDocument: expect.objectContaining({ uri: 'file:///workspace/src/index.ts', text: 'const foo = 1;' })
|
|
100
|
-
}));
|
|
95
|
+
expect(client.ensureDidOpen).toHaveBeenCalledTimes(2);
|
|
96
|
+
expect(client.ensureDidOpen).toHaveBeenCalledWith('/workspace/src/index.ts');
|
|
101
97
|
expect(client.request).toHaveBeenCalledWith('textDocument/hover', {
|
|
102
98
|
textDocument: { uri: 'file:///workspace/src/index.ts' },
|
|
103
99
|
position: { line: 2, character: 4 }
|
|
@@ -143,8 +139,10 @@ describe('registerReadTools', () => {
|
|
|
143
139
|
const registrar = new FakeRegistrar();
|
|
144
140
|
const firstClient = createClient([{ name: 'UserService', kind: 5, location: { uri: 'file:///workspace/src/user.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 4 } } } }]);
|
|
145
141
|
const secondClient = createClient([{ name: 'login', kind: 12, location: { uri: 'file:///workspace/src/auth.ts', range: { start: { line: 1, character: 0 }, end: { line: 1, character: 3 } } } }]);
|
|
146
|
-
|
|
142
|
+
const lifecycle = createLifecycle({ workspaceClients: [firstClient, secondClient] });
|
|
143
|
+
(0, read_tools_1.registerReadTools)(registrar, lifecycle, { initializeManager: jest.fn() });
|
|
147
144
|
const result = await getHandler(registrar, 'lsp_workspace_symbols')({ query: 'log' });
|
|
145
|
+
expect(lifecycle.ensureSeedFilesOpen).toHaveBeenCalledTimes(1);
|
|
148
146
|
expect(firstClient.request).toHaveBeenCalledWith('workspace/symbol', { query: 'log' }, 30000);
|
|
149
147
|
expect(secondClient.request).toHaveBeenCalledWith('workspace/symbol', { query: 'log' }, 30000);
|
|
150
148
|
expect(result).toEqual({
|
|
@@ -267,7 +265,8 @@ function createLifecycle(options) {
|
|
|
267
265
|
getFileDiagnostics: jest.fn((_) => (options.diagnostics ?? []).filter((diagnostic) => diagnostic.uri === 'file:///workspace/src/index.ts')),
|
|
268
266
|
getWorkspaceDiagnostics: jest.fn((_) => options.diagnostics ?? []),
|
|
269
267
|
getHealth: jest.fn(() => options.health ?? []),
|
|
270
|
-
ensureLanguageForFile: jest.fn().mockResolvedValue(undefined)
|
|
268
|
+
ensureLanguageForFile: jest.fn().mockResolvedValue(undefined),
|
|
269
|
+
ensureSeedFilesOpen: jest.fn().mockResolvedValue(undefined)
|
|
271
270
|
};
|
|
272
271
|
}
|
|
273
272
|
function createClient(result) {
|
|
@@ -276,6 +275,9 @@ function createClient(result) {
|
|
|
276
275
|
? jest.fn().mockRejectedValue(result)
|
|
277
276
|
: jest.fn().mockResolvedValue(result),
|
|
278
277
|
notify: jest.fn(),
|
|
279
|
-
getCapabilities: jest.fn(() => ({ renameProvider: true }))
|
|
278
|
+
getCapabilities: jest.fn(() => ({ renameProvider: true })),
|
|
279
|
+
ensureDidOpen: jest.fn().mockResolvedValue(undefined),
|
|
280
|
+
waitForDiagnosticsPublish: jest.fn().mockResolvedValue(undefined),
|
|
281
|
+
ensureSeedFileOpen: jest.fn().mockResolvedValue(undefined)
|
|
280
282
|
};
|
|
281
283
|
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const mockRegisterCapabilities = jest.fn();
|
|
3
4
|
const mockSetRequestHandler = jest.fn();
|
|
4
5
|
const mockConnect = jest.fn().mockResolvedValue(undefined);
|
|
5
6
|
jest.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({
|
|
6
7
|
McpServer: jest.fn().mockImplementation(() => ({
|
|
7
8
|
connect: mockConnect,
|
|
8
9
|
server: {
|
|
10
|
+
registerCapabilities: mockRegisterCapabilities,
|
|
9
11
|
setRequestHandler: mockSetRequestHandler
|
|
10
12
|
}
|
|
11
13
|
}))
|
|
@@ -32,9 +34,10 @@ describe('McpServer', () => {
|
|
|
32
34
|
beforeEach(() => {
|
|
33
35
|
jest.clearAllMocks();
|
|
34
36
|
});
|
|
35
|
-
it('registers request handlers and connects on start', async () => {
|
|
37
|
+
it('registers tool capabilities, request handlers, and connects on start', async () => {
|
|
36
38
|
const server = new server_1.McpServer('info');
|
|
37
39
|
await server.start();
|
|
40
|
+
expect(mockRegisterCapabilities).toHaveBeenCalledWith({ tools: {} });
|
|
38
41
|
expect(mockSetRequestHandler).toHaveBeenCalledTimes(2);
|
|
39
42
|
expect(mockConnect).toHaveBeenCalledWith({ kind: 'stdio' });
|
|
40
43
|
});
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
const promises_1 = require("node:fs/promises");
|
|
4
|
-
const shared_1 = require("../tools/shared");
|
|
5
4
|
const write_tools_1 = require("../tools/write-tools");
|
|
6
5
|
jest.mock('node:fs/promises', () => ({
|
|
7
6
|
access: jest.fn(),
|
|
@@ -17,7 +16,6 @@ class FakeRegistrar {
|
|
|
17
16
|
describe('registerWriteTools', () => {
|
|
18
17
|
beforeEach(() => {
|
|
19
18
|
jest.clearAllMocks();
|
|
20
|
-
(0, shared_1.clearOpenedFiles)();
|
|
21
19
|
promises_1.access.mockResolvedValue(undefined);
|
|
22
20
|
promises_1.readFile.mockImplementation(async (filePath) => {
|
|
23
21
|
const pathText = String(filePath);
|
|
@@ -165,7 +163,8 @@ describe('registerWriteTools', () => {
|
|
|
165
163
|
getFileDiagnostics: jest.fn((_) => []),
|
|
166
164
|
getWorkspaceDiagnostics: jest.fn(() => []),
|
|
167
165
|
getHealth: jest.fn(() => []),
|
|
168
|
-
ensureLanguageForFile: jest.fn().mockResolvedValue(undefined)
|
|
166
|
+
ensureLanguageForFile: jest.fn().mockResolvedValue(undefined),
|
|
167
|
+
ensureSeedFilesOpen: jest.fn().mockResolvedValue(undefined)
|
|
169
168
|
});
|
|
170
169
|
await expect(getHandler(registrar, 'lsp_apply_workspace_edit')({ edit: {} })).resolves.toEqual({
|
|
171
170
|
content: [{ type: 'text', text: 'No language servers are ready. Run lsp_health for details.' }],
|
|
@@ -181,7 +180,8 @@ describe('registerWriteTools', () => {
|
|
|
181
180
|
getFileDiagnostics: jest.fn((_) => []),
|
|
182
181
|
getWorkspaceDiagnostics: jest.fn(() => []),
|
|
183
182
|
getHealth: jest.fn(() => []),
|
|
184
|
-
ensureLanguageForFile: jest.fn().mockResolvedValue(undefined)
|
|
183
|
+
ensureLanguageForFile: jest.fn().mockResolvedValue(undefined),
|
|
184
|
+
ensureSeedFilesOpen: jest.fn().mockResolvedValue(undefined)
|
|
185
185
|
};
|
|
186
186
|
(0, write_tools_1.registerWriteTools)(registrar, noClientLifecycle);
|
|
187
187
|
await expect(getHandler(registrar, 'lsp_rename')({ file: '/workspace/README.md', line: 0, character: 0, newName: 'x' })).resolves.toEqual({
|
|
@@ -237,7 +237,8 @@ function createLifecycle(client) {
|
|
|
237
237
|
getFileDiagnostics: jest.fn((_) => []),
|
|
238
238
|
getWorkspaceDiagnostics: jest.fn(() => []),
|
|
239
239
|
getHealth: jest.fn(() => []),
|
|
240
|
-
ensureLanguageForFile: jest.fn().mockResolvedValue(undefined)
|
|
240
|
+
ensureLanguageForFile: jest.fn().mockResolvedValue(undefined),
|
|
241
|
+
ensureSeedFilesOpen: jest.fn().mockResolvedValue(undefined)
|
|
241
242
|
};
|
|
242
243
|
}
|
|
243
244
|
function createClient(result, capabilities = { renameProvider: true }) {
|
|
@@ -252,6 +253,9 @@ function createClient(result, capabilities = { renameProvider: true }) {
|
|
|
252
253
|
return result;
|
|
253
254
|
}),
|
|
254
255
|
notify: jest.fn(),
|
|
255
|
-
getCapabilities: jest.fn(() => capabilities)
|
|
256
|
+
getCapabilities: jest.fn(() => capabilities),
|
|
257
|
+
ensureDidOpen: jest.fn().mockResolvedValue(undefined),
|
|
258
|
+
waitForDiagnosticsPublish: jest.fn().mockResolvedValue(undefined),
|
|
259
|
+
ensureSeedFileOpen: jest.fn().mockResolvedValue(undefined)
|
|
256
260
|
};
|
|
257
261
|
}
|
package/dist/mcp/formatters.js
CHANGED
|
@@ -116,7 +116,7 @@ function formatSymbols(symbols) {
|
|
|
116
116
|
}
|
|
117
117
|
function formatDiagnostics(diagnostics, scope) {
|
|
118
118
|
if (!diagnostics || diagnostics.length === 0) {
|
|
119
|
-
return 'No
|
|
119
|
+
return scope === 'workspace' ? 'No diagnostics found in workspace' : 'No diagnostics found';
|
|
120
120
|
}
|
|
121
121
|
const grouped = new Map();
|
|
122
122
|
const ordered = [...diagnostics].sort((left, right) => (left.severity ?? 4) - (right.severity ?? 4));
|
package/dist/mcp/server.js
CHANGED
|
@@ -28,8 +28,7 @@ class McpServer {
|
|
|
28
28
|
const registrar = {
|
|
29
29
|
registerTool: (name, config, handler) => {
|
|
30
30
|
this.tools.set(name, { name, description: config.description, inputSchema: config.inputSchema, handler });
|
|
31
|
-
}
|
|
32
|
-
fromJsonSchema: (schema) => schema
|
|
31
|
+
}
|
|
33
32
|
};
|
|
34
33
|
(0, read_tools_1.registerReadTools)(registrar, lifecycleProxy, { initializeManager: async (root, languages) => await this.initializeManager(root, languages) });
|
|
35
34
|
(0, write_tools_1.registerWriteTools)(registrar, lifecycleProxy);
|
|
@@ -64,6 +63,7 @@ class McpServer {
|
|
|
64
63
|
}
|
|
65
64
|
}
|
|
66
65
|
configureToolHandlers() {
|
|
66
|
+
this.server.server.registerCapabilities({ tools: {} });
|
|
67
67
|
this.server.server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({ tools: this.listTools() }));
|
|
68
68
|
this.server.server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
69
69
|
const toolName = request.params.name;
|
|
@@ -101,7 +101,8 @@ class McpServer {
|
|
|
101
101
|
getFileDiagnostics: (filePath) => this.requireManager().getFileDiagnostics(filePath),
|
|
102
102
|
getWorkspaceDiagnostics: (language) => this.requireManager().getWorkspaceDiagnostics(language),
|
|
103
103
|
getHealth: () => this.requireManager().getHealth(),
|
|
104
|
-
ensureLanguageForFile: async (filePath) => await this.requireManager().ensureLanguageForFile(filePath)
|
|
104
|
+
ensureLanguageForFile: async (filePath) => await this.requireManager().ensureLanguageForFile(filePath),
|
|
105
|
+
ensureSeedFilesOpen: async () => await this.requireManager().ensureSeedFilesOpen()
|
|
105
106
|
};
|
|
106
107
|
}
|
|
107
108
|
requireManager() {
|