@theupsider/lsp-mcp 1.1.2 → 1.3.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/dist/detection/__tests__/lsp-mapping.test.js +7 -0
- package/dist/detection/lsp-mapping.js +19 -1
- package/dist/lsp/__tests__/diagnostic-store.test.js +75 -0
- package/dist/lsp/__tests__/lifecycle-manager.test.js +46 -1
- package/dist/lsp/__tests__/lsp-client.test.js +73 -0
- package/dist/lsp/diagnostic-store.js +21 -6
- package/dist/lsp/lifecycle-manager.js +88 -15
- package/dist/lsp/lsp-client.js +16 -4
- package/dist/lsp/server-supervisor.js +9 -3
- package/dist/mcp/__tests__/read-tools.test.js +70 -1
- package/dist/mcp/__tests__/write-tools.test.js +4 -2
- package/dist/mcp/server.js +1 -0
- package/dist/mcp/tools/read-tools.js +42 -19
- package/package.json +1 -1
|
@@ -14,6 +14,12 @@ describe('lsp-mapping', () => {
|
|
|
14
14
|
{ cmd: 'pylsp', args: [], pkg: 'python-lsp-server', mgr: 'pip' }
|
|
15
15
|
]);
|
|
16
16
|
});
|
|
17
|
+
it('returns ruff as the python linter candidate, run alongside the primary server', () => {
|
|
18
|
+
expect((0, lsp_mapping_1.getLinterCandidates)('python')).toEqual([
|
|
19
|
+
{ cmd: 'ruff', args: ['server', '--quiet'], pkg: 'ruff', mgr: 'pip' }
|
|
20
|
+
]);
|
|
21
|
+
expect((0, lsp_mapping_1.getLinterCandidates)('typescript')).toEqual([]);
|
|
22
|
+
});
|
|
17
23
|
it('returns the full supported language list and shared candidate mappings', () => {
|
|
18
24
|
expect(lsp_mapping_1.SUPPORTED_LANGUAGES).toEqual([
|
|
19
25
|
'python',
|
|
@@ -69,6 +75,7 @@ describe('lsp-mapping', () => {
|
|
|
69
75
|
process.env.PATH = binDir;
|
|
70
76
|
await expect((0, lsp_mapping_1.findAvailableLsp)('swift')).resolves.toBeNull();
|
|
71
77
|
await expect((0, lsp_mapping_1.findAvailableLsp)('unknown')).resolves.toBeNull();
|
|
78
|
+
await expect((0, lsp_mapping_1.findAvailableLinter)('python')).resolves.toBeNull();
|
|
72
79
|
}
|
|
73
80
|
finally {
|
|
74
81
|
process.env.PATH = originalPath;
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.SUPPORTED_LANGUAGES = void 0;
|
|
4
4
|
exports.getLspCandidates = getLspCandidates;
|
|
5
|
+
exports.getLinterCandidates = getLinterCandidates;
|
|
5
6
|
exports.findAvailableLsp = findAvailableLsp;
|
|
7
|
+
exports.findAvailableLinter = findAvailableLinter;
|
|
6
8
|
const node_child_process_1 = require("node:child_process");
|
|
7
9
|
const node_util_1 = require("node:util");
|
|
8
10
|
const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
|
|
@@ -12,6 +14,14 @@ const TYPESCRIPT_CANDIDATES = [
|
|
|
12
14
|
const CLANGD_CANDIDATES = [
|
|
13
15
|
{ cmd: 'clangd', args: [], pkg: 'clangd', mgr: 'apt' }
|
|
14
16
|
];
|
|
17
|
+
// Linters that run alongside (not instead of) the primary language server for a
|
|
18
|
+
// language, so their diagnostics (e.g. ruff lint rules) show up next to the
|
|
19
|
+
// primary server's diagnostics (e.g. pyright type errors) instead of being lost.
|
|
20
|
+
const LANGUAGE_TO_LINTERS = {
|
|
21
|
+
python: [
|
|
22
|
+
{ cmd: 'ruff', args: ['server', '--quiet'], pkg: 'ruff', mgr: 'pip' }
|
|
23
|
+
]
|
|
24
|
+
};
|
|
15
25
|
const LANGUAGE_TO_CANDIDATES = {
|
|
16
26
|
python: [
|
|
17
27
|
{ cmd: 'pyright-langserver', args: ['--stdio'], pkg: 'pyright', mgr: 'npm' },
|
|
@@ -65,8 +75,16 @@ exports.SUPPORTED_LANGUAGES = Object.freeze([
|
|
|
65
75
|
function getLspCandidates(language) {
|
|
66
76
|
return LANGUAGE_TO_CANDIDATES[language]?.map((candidate) => ({ ...candidate, args: [...candidate.args] })) ?? [];
|
|
67
77
|
}
|
|
78
|
+
function getLinterCandidates(language) {
|
|
79
|
+
return LANGUAGE_TO_LINTERS[language]?.map((candidate) => ({ ...candidate, args: [...candidate.args] })) ?? [];
|
|
80
|
+
}
|
|
68
81
|
async function findAvailableLsp(language) {
|
|
69
|
-
|
|
82
|
+
return await findFirstAvailable(getLspCandidates(language));
|
|
83
|
+
}
|
|
84
|
+
async function findAvailableLinter(language) {
|
|
85
|
+
return await findFirstAvailable(getLinterCandidates(language));
|
|
86
|
+
}
|
|
87
|
+
async function findFirstAvailable(candidates) {
|
|
70
88
|
for (const candidate of candidates) {
|
|
71
89
|
if (await commandExists(candidate.cmd)) {
|
|
72
90
|
return candidate;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const diagnostic_store_1 = require("../diagnostic-store");
|
|
4
|
+
describe('DiagnosticStore', () => {
|
|
5
|
+
it("replaces a source's diagnostics wholesale per uri on each publish", () => {
|
|
6
|
+
const store = new diagnostic_store_1.DiagnosticStore();
|
|
7
|
+
store.store('pyright', { uri: 'file:///a.ts', diagnostics: [makeDiagnostic(1, 'first')] });
|
|
8
|
+
store.store('pyright', { uri: 'file:///a.ts', diagnostics: [makeDiagnostic(2, 'second')] });
|
|
9
|
+
expect(store.getForFile('/a.ts')).toEqual([
|
|
10
|
+
{ ...makeDiagnostic(2, 'second'), uri: 'file:///a.ts' },
|
|
11
|
+
]);
|
|
12
|
+
});
|
|
13
|
+
it('merges diagnostics from multiple sources for the same uri instead of overwriting', () => {
|
|
14
|
+
const store = new diagnostic_store_1.DiagnosticStore();
|
|
15
|
+
store.store('pyright', { uri: 'file:///a.py', diagnostics: [makeDiagnostic(1, 'type error')] });
|
|
16
|
+
store.store('ruff', { uri: 'file:///a.py', diagnostics: [makeDiagnostic(2, 'unused import')] });
|
|
17
|
+
expect(store.getForFile('/a.py')).toEqual([
|
|
18
|
+
{ ...makeDiagnostic(1, 'type error'), uri: 'file:///a.py' },
|
|
19
|
+
{ ...makeDiagnostic(2, 'unused import'), uri: 'file:///a.py' },
|
|
20
|
+
]);
|
|
21
|
+
});
|
|
22
|
+
it("only replaces the publishing source's diagnostics, leaving other sources intact", () => {
|
|
23
|
+
const store = new diagnostic_store_1.DiagnosticStore();
|
|
24
|
+
store.store('pyright', { uri: 'file:///a.py', diagnostics: [makeDiagnostic(1, 'type error')] });
|
|
25
|
+
store.store('ruff', { uri: 'file:///a.py', diagnostics: [makeDiagnostic(2, 'unused import')] });
|
|
26
|
+
store.store('ruff', { uri: 'file:///a.py', diagnostics: [] });
|
|
27
|
+
expect(store.getForFile('/a.py')).toEqual([
|
|
28
|
+
{ ...makeDiagnostic(1, 'type error'), uri: 'file:///a.py' },
|
|
29
|
+
]);
|
|
30
|
+
});
|
|
31
|
+
it('ignores malformed publishDiagnostics payloads', () => {
|
|
32
|
+
const store = new diagnostic_store_1.DiagnosticStore();
|
|
33
|
+
store.store('pyright', null);
|
|
34
|
+
store.store('pyright', { uri: 'file:///a.ts' });
|
|
35
|
+
store.store('pyright', { diagnostics: [] });
|
|
36
|
+
expect(store.getForFile('/a.ts')).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
it('sorts workspace diagnostics by severity ascending (errors before warnings)', () => {
|
|
39
|
+
const store = new diagnostic_store_1.DiagnosticStore();
|
|
40
|
+
store.store('pyright', {
|
|
41
|
+
uri: 'file:///warn.ts',
|
|
42
|
+
diagnostics: [makeDiagnostic(2, 'a warning')],
|
|
43
|
+
});
|
|
44
|
+
store.store('pyright', {
|
|
45
|
+
uri: 'file:///error.ts',
|
|
46
|
+
diagnostics: [makeDiagnostic(1, 'an error')],
|
|
47
|
+
});
|
|
48
|
+
store.store('pyright', {
|
|
49
|
+
uri: 'file:///hint.ts',
|
|
50
|
+
diagnostics: [makeDiagnostic(4, 'a hint')],
|
|
51
|
+
});
|
|
52
|
+
const result = store.getForWorkspace();
|
|
53
|
+
expect(result.map((d) => d.message)).toEqual([
|
|
54
|
+
'an error',
|
|
55
|
+
'a warning',
|
|
56
|
+
'a hint',
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
it('filters workspace diagnostics by language extension', () => {
|
|
60
|
+
const store = new diagnostic_store_1.DiagnosticStore();
|
|
61
|
+
store.store('pyright', { uri: 'file:///a.ts', diagnostics: [makeDiagnostic(1, 'ts issue')] });
|
|
62
|
+
store.store('pyright', { uri: 'file:///b.py', diagnostics: [makeDiagnostic(1, 'py issue')] });
|
|
63
|
+
const result = store.getForWorkspace('python');
|
|
64
|
+
expect(result).toEqual([
|
|
65
|
+
{ ...makeDiagnostic(1, 'py issue'), uri: 'file:///b.py' },
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
function makeDiagnostic(severity, message) {
|
|
70
|
+
return {
|
|
71
|
+
severity,
|
|
72
|
+
message,
|
|
73
|
+
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -7,7 +7,9 @@ jest.mock('../../detection/language-detector', () => ({
|
|
|
7
7
|
}));
|
|
8
8
|
jest.mock('../../detection/lsp-mapping', () => ({
|
|
9
9
|
findAvailableLsp: jest.fn(),
|
|
10
|
-
getLspCandidates: jest.fn()
|
|
10
|
+
getLspCandidates: jest.fn(),
|
|
11
|
+
findAvailableLinter: jest.fn(),
|
|
12
|
+
getLinterCandidates: jest.fn().mockReturnValue([])
|
|
11
13
|
}));
|
|
12
14
|
jest.mock('../installer', () => ({
|
|
13
15
|
installLsp: jest.fn()
|
|
@@ -287,4 +289,47 @@ describe('LifecycleManager', () => {
|
|
|
287
289
|
expect(manager.getWorkspaceDiagnostics()).toEqual([]);
|
|
288
290
|
await manager.shutdown();
|
|
289
291
|
});
|
|
292
|
+
it('merges diagnostics from a linter server alongside the primary server for the same language', async () => {
|
|
293
|
+
const detectLanguages = jest.requireMock('../../detection/language-detector').detectLanguages;
|
|
294
|
+
const findAvailableLsp = jest.requireMock('../../detection/lsp-mapping').findAvailableLsp;
|
|
295
|
+
const getLspCandidates = jest.requireMock('../../detection/lsp-mapping').getLspCandidates;
|
|
296
|
+
const findAvailableLinter = jest.requireMock('../../detection/lsp-mapping').findAvailableLinter;
|
|
297
|
+
const getLinterCandidates = jest.requireMock('../../detection/lsp-mapping').getLinterCandidates;
|
|
298
|
+
const LspClient = jest.requireMock('../lsp-client').LspClient;
|
|
299
|
+
const ruffCandidate = { cmd: 'ruff', args: ['server', '--quiet'], pkg: 'ruff', mgr: 'pip' };
|
|
300
|
+
const pyrightClient = new MockLspClient();
|
|
301
|
+
const ruffClient = new MockLspClient();
|
|
302
|
+
detectLanguages.mockResolvedValue([{ language: 'python', confidence: 'marker', markers: ['pyproject.toml'] }]);
|
|
303
|
+
findAvailableLsp.mockResolvedValue({ cmd: 'pyright-langserver', args: ['--stdio'], pkg: 'pyright', mgr: 'npm' });
|
|
304
|
+
getLspCandidates.mockReturnValue([]);
|
|
305
|
+
getLinterCandidates.mockReturnValue([ruffCandidate]);
|
|
306
|
+
findAvailableLinter.mockResolvedValue(ruffCandidate);
|
|
307
|
+
LspClient.mockImplementationOnce(() => pyrightClient);
|
|
308
|
+
LspClient.mockImplementationOnce(() => ruffClient);
|
|
309
|
+
const manager = new lifecycle_manager_1.LifecycleManager('/workspace/project', 'debug');
|
|
310
|
+
await manager.start();
|
|
311
|
+
pyrightClient.emit('notification', 'textDocument/publishDiagnostics', {
|
|
312
|
+
uri: 'file:///workspace/project/app.py',
|
|
313
|
+
diagnostics: [{ message: 'Incompatible type', severity: 1, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }]
|
|
314
|
+
});
|
|
315
|
+
ruffClient.emit('notification', 'textDocument/publishDiagnostics', {
|
|
316
|
+
uri: 'file:///workspace/project/app.py',
|
|
317
|
+
diagnostics: [{ message: 'F401 unused import', severity: 2, range: { start: { line: 1, character: 0 }, end: { line: 1, character: 1 } } }]
|
|
318
|
+
});
|
|
319
|
+
expect(manager.getFileDiagnostics('/workspace/project/app.py').map((d) => d.message)).toEqual([
|
|
320
|
+
'Incompatible type',
|
|
321
|
+
'F401 unused import'
|
|
322
|
+
]);
|
|
323
|
+
// Navigation/rename/format stay pinned to the primary server, not the linter.
|
|
324
|
+
expect(manager.getClient('python')).toBe(pyrightClient);
|
|
325
|
+
expect(manager.getReadyClients('python')).toEqual([pyrightClient]);
|
|
326
|
+
// Both servers are started and both surface in health.
|
|
327
|
+
expect(manager.getHealth()).toEqual([
|
|
328
|
+
{ language: 'python', status: 'ready', capabilities: { hoverProvider: true } },
|
|
329
|
+
{ language: 'python', status: 'ready', capabilities: { hoverProvider: true } }
|
|
330
|
+
]);
|
|
331
|
+
// File-scope diagnostics touch every diagnostic-contributing server.
|
|
332
|
+
expect(manager.getDiagnosticClientsForFile('/workspace/project/app.py')).toEqual([pyrightClient, ruffClient]);
|
|
333
|
+
await manager.shutdown();
|
|
334
|
+
});
|
|
290
335
|
});
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
6
|
const node_events_1 = require("node:events");
|
|
7
|
+
const promises_1 = require("node:fs/promises");
|
|
8
|
+
const node_os_1 = require("node:os");
|
|
9
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
4
10
|
const node_stream_1 = require("node:stream");
|
|
5
11
|
const lsp_client_1 = require("../lsp-client");
|
|
6
12
|
jest.mock('node:child_process', () => ({
|
|
@@ -208,6 +214,73 @@ describe('LspClient', () => {
|
|
|
208
214
|
child.stdout.write(encodeMessage({ jsonrpc: '2.0', method: 'textDocument/publishDiagnostics', params: { uri: 'file:///x', diagnostics: [] } }));
|
|
209
215
|
await expect(notificationPromise).resolves.toEqual({ method: 'textDocument/publishDiagnostics', params: { uri: 'file:///x', diagnostics: [] } });
|
|
210
216
|
});
|
|
217
|
+
describe('openAllFilesForDiagnostics (workspace scan regressions)', () => {
|
|
218
|
+
let tmpDir;
|
|
219
|
+
beforeEach(async () => {
|
|
220
|
+
tmpDir = await (0, promises_1.mkdtemp)(node_path_1.default.join((0, node_os_1.tmpdir)(), 'lsp-client-scan-'));
|
|
221
|
+
});
|
|
222
|
+
afterEach(async () => {
|
|
223
|
+
await (0, promises_1.rm)(tmpDir, { recursive: true, force: true });
|
|
224
|
+
});
|
|
225
|
+
// Publish notifications never arrive in this test (no real server), so
|
|
226
|
+
// shorten the wait so these regression tests run fast under real timers
|
|
227
|
+
// instead of racing fake timers against real fs I/O.
|
|
228
|
+
function withShortDiagnosticsWait(client) {
|
|
229
|
+
const original = client.waitForDiagnosticsPublish.bind(client);
|
|
230
|
+
client.waitForDiagnosticsPublish = (filePath) => original(filePath, 50);
|
|
231
|
+
}
|
|
232
|
+
it('does not let one unreadable file abort the whole scan (regression)', async () => {
|
|
233
|
+
const child = new MockChildProcess();
|
|
234
|
+
const spawn = jest.requireMock('node:child_process').spawn;
|
|
235
|
+
spawn.mockReturnValue(child);
|
|
236
|
+
const client = new lsp_client_1.LspClient(SERVER, tmpDir, 'error');
|
|
237
|
+
await startClient(client, child);
|
|
238
|
+
withShortDiagnosticsWait(client);
|
|
239
|
+
await (0, promises_1.writeFile)(node_path_1.default.join(tmpDir, 'a.ts'), 'const a = 1;\n');
|
|
240
|
+
await (0, promises_1.writeFile)(node_path_1.default.join(tmpDir, 'b.ts'), 'const b = 1;\n');
|
|
241
|
+
const brokenPath = node_path_1.default.join(tmpDir, 'broken.ts');
|
|
242
|
+
await (0, promises_1.writeFile)(brokenPath, 'const c = 1;\n');
|
|
243
|
+
await (0, promises_1.chmod)(brokenPath, 0o000);
|
|
244
|
+
if (process.getuid?.() === 0) {
|
|
245
|
+
// Running as root: chmod 0o000 does not block reads, so this test cannot apply.
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const summary = await client.openAllFilesForDiagnostics(['.ts']);
|
|
249
|
+
expect(summary).toEqual({ opened: 2, failed: 1, truncated: false });
|
|
250
|
+
}, 10000);
|
|
251
|
+
it('skips virtualenv-style directories during the workspace scan (regression)', async () => {
|
|
252
|
+
const child = new MockChildProcess();
|
|
253
|
+
const spawn = jest.requireMock('node:child_process').spawn;
|
|
254
|
+
spawn.mockReturnValue(child);
|
|
255
|
+
const client = new lsp_client_1.LspClient(SERVER, tmpDir, 'error');
|
|
256
|
+
await startClient(client, child);
|
|
257
|
+
withShortDiagnosticsWait(client);
|
|
258
|
+
await (0, promises_1.writeFile)(node_path_1.default.join(tmpDir, 'top1.py'), 'x = 1\n');
|
|
259
|
+
await (0, promises_1.writeFile)(node_path_1.default.join(tmpDir, 'top2.py'), 'y = 2\n');
|
|
260
|
+
const venvPkgDir = node_path_1.default.join(tmpDir, '.venv', 'lib', 'site-packages');
|
|
261
|
+
await (0, promises_1.mkdir)(venvPkgDir, { recursive: true });
|
|
262
|
+
await Promise.all(Array.from({ length: 5 }, (_, i) => (0, promises_1.writeFile)(node_path_1.default.join(venvPkgDir, `dep${i}.py`), `dep = ${i}\n`)));
|
|
263
|
+
const summary = await client.openAllFilesForDiagnostics(['.py']);
|
|
264
|
+
expect(summary).toEqual({ opened: 2, failed: 0, truncated: false });
|
|
265
|
+
const openedUris = child.stdinWrites
|
|
266
|
+
.map((raw) => extractMessage(raw))
|
|
267
|
+
.filter((message) => message.method === 'textDocument/didOpen')
|
|
268
|
+
.map((message) => getObject(message.params).textDocument)
|
|
269
|
+
.map((textDocument) => textDocument.uri);
|
|
270
|
+
expect(openedUris.some((uri) => uri.includes('.venv'))).toBe(false);
|
|
271
|
+
}, 10000);
|
|
272
|
+
it('caps the workspace scan at 100 files per language and reports truncation', async () => {
|
|
273
|
+
const child = new MockChildProcess();
|
|
274
|
+
const spawn = jest.requireMock('node:child_process').spawn;
|
|
275
|
+
spawn.mockReturnValue(child);
|
|
276
|
+
const client = new lsp_client_1.LspClient(SERVER, tmpDir, 'error');
|
|
277
|
+
await startClient(client, child);
|
|
278
|
+
withShortDiagnosticsWait(client);
|
|
279
|
+
await Promise.all(Array.from({ length: 150 }, (_, i) => (0, promises_1.writeFile)(node_path_1.default.join(tmpDir, `file${i}.ts`), `const v${i} = ${i};\n`)));
|
|
280
|
+
const summary = await client.openAllFilesForDiagnostics(['.ts']);
|
|
281
|
+
expect(summary).toEqual({ opened: 100, failed: 0, truncated: true });
|
|
282
|
+
}, 10000);
|
|
283
|
+
});
|
|
211
284
|
});
|
|
212
285
|
function encodeMessage(payload) {
|
|
213
286
|
const body = JSON.stringify(payload);
|
|
@@ -8,29 +8,44 @@ const node_path_1 = __importDefault(require("node:path"));
|
|
|
8
8
|
const language_registry_1 = require("../detection/language-registry");
|
|
9
9
|
const uri_1 = require("../utils/uri");
|
|
10
10
|
class DiagnosticStore {
|
|
11
|
+
// uri -> sourceId -> diagnostics. Keying by source lets multiple servers for the
|
|
12
|
+
// same language (e.g. pyright's type checks and ruff's lint rules) publish
|
|
13
|
+
// diagnostics for the same file without one server's publish overwriting another's.
|
|
11
14
|
diagnostics = new Map();
|
|
12
|
-
store(params) {
|
|
15
|
+
store(sourceId, params) {
|
|
13
16
|
if (!isPublishDiagnosticsParams(params)) {
|
|
14
17
|
return;
|
|
15
18
|
}
|
|
16
|
-
this.diagnostics.
|
|
19
|
+
let bySource = this.diagnostics.get(params.uri);
|
|
20
|
+
if (!bySource) {
|
|
21
|
+
bySource = new Map();
|
|
22
|
+
this.diagnostics.set(params.uri, bySource);
|
|
23
|
+
}
|
|
24
|
+
bySource.set(sourceId, cloneDiagnostics(params.diagnostics));
|
|
17
25
|
}
|
|
18
26
|
getForFile(filePath) {
|
|
19
27
|
const uri = (0, uri_1.pathToUri)(filePath);
|
|
20
|
-
return
|
|
28
|
+
return this.flatten(uri);
|
|
21
29
|
}
|
|
22
30
|
getForWorkspace(language) {
|
|
23
31
|
const allowedExtensions = language ? (0, language_registry_1.extensionsForLanguage)(language) : null;
|
|
24
|
-
return Array.from(this.diagnostics.
|
|
25
|
-
.filter((
|
|
32
|
+
return Array.from(this.diagnostics.keys())
|
|
33
|
+
.filter((uri) => {
|
|
26
34
|
if (!allowedExtensions) {
|
|
27
35
|
return true;
|
|
28
36
|
}
|
|
29
37
|
return allowedExtensions.includes(node_path_1.default.extname((0, uri_1.uriToPath)(uri)).toLowerCase());
|
|
30
38
|
})
|
|
31
|
-
.flatMap((
|
|
39
|
+
.flatMap((uri) => this.flatten(uri))
|
|
32
40
|
.sort((left, right) => (left.severity ?? 4) - (right.severity ?? 4));
|
|
33
41
|
}
|
|
42
|
+
flatten(uri) {
|
|
43
|
+
const bySource = this.diagnostics.get(uri);
|
|
44
|
+
if (!bySource) {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
return Array.from(bySource.values()).flatMap((diagnostics) => diagnostics.map((diagnostic) => ({ ...diagnostic, uri })));
|
|
48
|
+
}
|
|
34
49
|
}
|
|
35
50
|
exports.DiagnosticStore = DiagnosticStore;
|
|
36
51
|
function isPublishDiagnosticsParams(value) {
|
|
@@ -4,15 +4,22 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.LifecycleManager = void 0;
|
|
7
|
+
exports.promiseWithTimeout = promiseWithTimeout;
|
|
7
8
|
const node_path_1 = __importDefault(require("node:path"));
|
|
8
9
|
const language_detector_1 = require("../detection/language-detector");
|
|
9
10
|
const language_registry_1 = require("../detection/language-registry");
|
|
11
|
+
const lsp_mapping_1 = require("../detection/lsp-mapping");
|
|
10
12
|
const diagnostic_store_1 = require("./diagnostic-store");
|
|
11
13
|
const server_supervisor_1 = require("./server-supervisor");
|
|
12
14
|
class LifecycleManager {
|
|
13
15
|
projectRoot;
|
|
14
16
|
logLevel;
|
|
15
17
|
supervisors = new Map();
|
|
18
|
+
// Secondary servers that run alongside the primary one for a language purely to
|
|
19
|
+
// contribute diagnostics (e.g. ruff's lint rules alongside pyright's type checks).
|
|
20
|
+
// They are never returned by getClient/getReadyClients, which stay scoped to the
|
|
21
|
+
// primary server so navigation/formatting/rename requests keep going to one place.
|
|
22
|
+
linterSupervisors = new Map();
|
|
16
23
|
store = new diagnostic_store_1.DiagnosticStore();
|
|
17
24
|
constructor(projectRoot, logLevel) {
|
|
18
25
|
this.projectRoot = projectRoot;
|
|
@@ -23,12 +30,12 @@ class LifecycleManager {
|
|
|
23
30
|
await promiseWithTimeout(startPromise, 30000, "Lifecycle start timed out");
|
|
24
31
|
}
|
|
25
32
|
async ensureLanguage(language) {
|
|
26
|
-
if (this.supervisors.has(language)) {
|
|
27
|
-
|
|
33
|
+
if (!this.supervisors.has(language)) {
|
|
34
|
+
const supervisor = this.createSupervisor(language);
|
|
35
|
+
this.supervisors.set(language, supervisor);
|
|
36
|
+
await supervisor.start();
|
|
28
37
|
}
|
|
29
|
-
|
|
30
|
-
this.supervisors.set(language, supervisor);
|
|
31
|
-
await supervisor.start();
|
|
38
|
+
await this.ensureLinter(language);
|
|
32
39
|
}
|
|
33
40
|
async ensureLanguageForFile(filePath) {
|
|
34
41
|
const language = (0, language_registry_1.extensionToLanguage)(node_path_1.default.extname(filePath));
|
|
@@ -46,8 +53,26 @@ class LifecycleManager {
|
|
|
46
53
|
}
|
|
47
54
|
return null;
|
|
48
55
|
}
|
|
56
|
+
// All ready clients contributing diagnostics for a file's language: the primary
|
|
57
|
+
// language server plus any linter servers (e.g. ruff for python). Used so a single
|
|
58
|
+
// lsp_diagnostics(file) call opens/saves the file on every diagnostic source instead
|
|
59
|
+
// of only the primary one.
|
|
60
|
+
getDiagnosticClientsForFile(filePath) {
|
|
61
|
+
const language = (0, language_registry_1.extensionToLanguage)(node_path_1.default.extname(filePath));
|
|
62
|
+
if (!language) {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
return [this.supervisors.get(language), this.linterSupervisors.get(language)]
|
|
66
|
+
.filter((supervisor) => {
|
|
67
|
+
return !!supervisor && supervisor.getHealth().status === "ready";
|
|
68
|
+
})
|
|
69
|
+
.flatMap((supervisor) => {
|
|
70
|
+
const client = supervisor.getClient();
|
|
71
|
+
return client ? [client] : [];
|
|
72
|
+
});
|
|
73
|
+
}
|
|
49
74
|
getHealth() {
|
|
50
|
-
return
|
|
75
|
+
return this.allSupervisors().map((supervisor) => supervisor.getHealth());
|
|
51
76
|
}
|
|
52
77
|
getReadyClients(language) {
|
|
53
78
|
return Array.from(this.supervisors.values())
|
|
@@ -68,21 +93,47 @@ class LifecycleManager {
|
|
|
68
93
|
return this.store.getForWorkspace(language);
|
|
69
94
|
}
|
|
70
95
|
async analyzeWorkspace(language) {
|
|
71
|
-
await Promise.
|
|
96
|
+
const results = await Promise.allSettled(this.allSupervisorEntries()
|
|
72
97
|
.filter(([lang, supervisor]) => {
|
|
73
98
|
const health = supervisor.getHealth();
|
|
74
99
|
return health.status === "ready" && (!language || lang === language);
|
|
75
100
|
})
|
|
76
101
|
.map(async ([lang, supervisor]) => {
|
|
77
102
|
const client = supervisor.getClient();
|
|
78
|
-
if (!client)
|
|
79
|
-
return;
|
|
103
|
+
if (!client) {
|
|
104
|
+
return { language: lang, opened: 0, failed: 0, truncated: false };
|
|
105
|
+
}
|
|
80
106
|
const extensions = (0, language_registry_1.extensionsForLanguage)(lang);
|
|
81
|
-
|
|
107
|
+
try {
|
|
108
|
+
const scan = await client.openAllFilesForDiagnostics(extensions);
|
|
109
|
+
return { language: lang, ...scan };
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
return {
|
|
113
|
+
language: lang,
|
|
114
|
+
opened: 0,
|
|
115
|
+
failed: 0,
|
|
116
|
+
truncated: false,
|
|
117
|
+
error: error instanceof Error ? error.message : String(error),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
82
120
|
}));
|
|
121
|
+
return {
|
|
122
|
+
perLanguage: results.map((result) => result.status === "fulfilled"
|
|
123
|
+
? result.value
|
|
124
|
+
: {
|
|
125
|
+
language: "unknown",
|
|
126
|
+
opened: 0,
|
|
127
|
+
failed: 0,
|
|
128
|
+
truncated: false,
|
|
129
|
+
error: result.reason instanceof Error
|
|
130
|
+
? result.reason.message
|
|
131
|
+
: String(result.reason),
|
|
132
|
+
}),
|
|
133
|
+
};
|
|
83
134
|
}
|
|
84
135
|
async ensureSeedFilesOpen() {
|
|
85
|
-
await Promise.all(
|
|
136
|
+
await Promise.all(this.allSupervisorEntries().map(async ([language, supervisor]) => {
|
|
86
137
|
const client = supervisor.getClient();
|
|
87
138
|
if (!client)
|
|
88
139
|
return;
|
|
@@ -91,7 +142,7 @@ class LifecycleManager {
|
|
|
91
142
|
}));
|
|
92
143
|
}
|
|
93
144
|
async shutdown() {
|
|
94
|
-
const supervisors =
|
|
145
|
+
const supervisors = this.allSupervisors();
|
|
95
146
|
const results = await Promise.allSettled(supervisors.map(async (supervisor) => await promiseWithTimeout(supervisor.shutdown(), 5000, "LSP shutdown timed out")));
|
|
96
147
|
const errors = results.filter((result) => result.status === "rejected").length;
|
|
97
148
|
process.stderr.write(`{"timestamp":"${new Date().toISOString()}","level":"info","event":"Shutdown: ${supervisors.length - errors} LSP-Server beendet, ${errors} Fehler"}\n`);
|
|
@@ -104,14 +155,36 @@ class LifecycleManager {
|
|
|
104
155
|
const supervisor = this.createSupervisor(entry.language);
|
|
105
156
|
this.supervisors.set(entry.language, supervisor);
|
|
106
157
|
await supervisor.start();
|
|
158
|
+
await this.ensureLinter(entry.language);
|
|
107
159
|
}
|
|
108
160
|
}
|
|
109
|
-
|
|
161
|
+
async ensureLinter(language) {
|
|
162
|
+
if (this.linterSupervisors.has(language)) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if ((0, lsp_mapping_1.getLinterCandidates)(language).length === 0) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const supervisor = this.createSupervisor(language, true);
|
|
169
|
+
this.linterSupervisors.set(language, supervisor);
|
|
170
|
+
await supervisor.start();
|
|
171
|
+
}
|
|
172
|
+
allSupervisors() {
|
|
173
|
+
return this.allSupervisorEntries().map(([, supervisor]) => supervisor);
|
|
174
|
+
}
|
|
175
|
+
allSupervisorEntries() {
|
|
176
|
+
return [
|
|
177
|
+
...Array.from(this.supervisors.entries()),
|
|
178
|
+
...Array.from(this.linterSupervisors.entries()),
|
|
179
|
+
];
|
|
180
|
+
}
|
|
181
|
+
createSupervisor(language, linter = false) {
|
|
182
|
+
const sourceId = linter ? `${language}:lint` : language;
|
|
110
183
|
return new server_supervisor_1.ServerSupervisor(language, this.projectRoot, this.logLevel, (method, params) => {
|
|
111
184
|
if (method === "textDocument/publishDiagnostics") {
|
|
112
|
-
this.store.store(params);
|
|
185
|
+
this.store.store(sourceId, params);
|
|
113
186
|
}
|
|
114
|
-
});
|
|
187
|
+
}, linter);
|
|
115
188
|
}
|
|
116
189
|
}
|
|
117
190
|
exports.LifecycleManager = LifecycleManager;
|
package/dist/lsp/lsp-client.js
CHANGED
|
@@ -117,7 +117,9 @@ class LspClient extends node_events_1.EventEmitter {
|
|
|
117
117
|
this.ready = false;
|
|
118
118
|
return;
|
|
119
119
|
}
|
|
120
|
-
|
|
120
|
+
// The LSP spec defines shutdown as taking no params; strict servers (e.g. ruff,
|
|
121
|
+
// written in Rust with serde) reject an empty object `{}` as "expected unit".
|
|
122
|
+
await this.request("shutdown", null, 5000);
|
|
121
123
|
this.notify("exit", {});
|
|
122
124
|
this.ready = false;
|
|
123
125
|
const currentProcess = this.process;
|
|
@@ -176,13 +178,16 @@ class LspClient extends node_events_1.EventEmitter {
|
|
|
176
178
|
const MAX_FILES = 100;
|
|
177
179
|
const allFiles = await findAllFilesWithExtension(this.projectRoot, extensions, MAX_FILES);
|
|
178
180
|
const newFiles = allFiles.filter((f) => !this.openedFiles.has(f));
|
|
181
|
+
const truncated = allFiles.length >= MAX_FILES;
|
|
179
182
|
if (newFiles.length === 0) {
|
|
180
|
-
return;
|
|
183
|
+
return { opened: 0, failed: 0, truncated };
|
|
181
184
|
}
|
|
182
185
|
// Set up wait promises BEFORE opening files to avoid missing notifications
|
|
183
186
|
const waitPromises = newFiles.map((f) => this.waitForDiagnosticsPublish(f, 15000));
|
|
184
|
-
await Promise.
|
|
185
|
-
await Promise.
|
|
187
|
+
const openResults = await Promise.allSettled(newFiles.map((f) => this.ensureDidOpen(f)));
|
|
188
|
+
await Promise.allSettled(waitPromises);
|
|
189
|
+
const failed = openResults.filter((result) => result.status === "rejected").length;
|
|
190
|
+
return { opened: newFiles.length - failed, failed, truncated };
|
|
186
191
|
}
|
|
187
192
|
sendMessage(message) {
|
|
188
193
|
if (!this.process) {
|
|
@@ -311,6 +316,13 @@ const SKIP_DIRS = new Set([
|
|
|
311
316
|
"target",
|
|
312
317
|
".cache",
|
|
313
318
|
"vendor",
|
|
319
|
+
".venv",
|
|
320
|
+
"venv",
|
|
321
|
+
".tox",
|
|
322
|
+
".mypy_cache",
|
|
323
|
+
".pytest_cache",
|
|
324
|
+
".idea",
|
|
325
|
+
".gradle",
|
|
314
326
|
]);
|
|
315
327
|
async function findFirstFileWithExtension(dir, extensions) {
|
|
316
328
|
const extensionSet = new Set(extensions.map((e) => e.toLowerCase()));
|
|
@@ -15,12 +15,14 @@ class ServerSupervisor {
|
|
|
15
15
|
projectRoot;
|
|
16
16
|
logLevel;
|
|
17
17
|
onDiagnostics;
|
|
18
|
+
linter;
|
|
18
19
|
shuttingDown = false;
|
|
19
|
-
constructor(language, projectRoot, logLevel, onDiagnostics) {
|
|
20
|
+
constructor(language, projectRoot, logLevel, onDiagnostics, linter = false) {
|
|
20
21
|
this.language = language;
|
|
21
22
|
this.projectRoot = projectRoot;
|
|
22
23
|
this.logLevel = logLevel;
|
|
23
24
|
this.onDiagnostics = onDiagnostics;
|
|
25
|
+
this.linter = linter;
|
|
24
26
|
}
|
|
25
27
|
async start() {
|
|
26
28
|
const serverDef = await this.resolveServer();
|
|
@@ -121,11 +123,15 @@ class ServerSupervisor {
|
|
|
121
123
|
}
|
|
122
124
|
}
|
|
123
125
|
async resolveServer() {
|
|
124
|
-
const available =
|
|
126
|
+
const available = this.linter
|
|
127
|
+
? await (0, lsp_mapping_1.findAvailableLinter)(this.language)
|
|
128
|
+
: await (0, lsp_mapping_1.findAvailableLsp)(this.language);
|
|
125
129
|
if (available) {
|
|
126
130
|
return available;
|
|
127
131
|
}
|
|
128
|
-
const candidate = (
|
|
132
|
+
const candidate = (this.linter
|
|
133
|
+
? (0, lsp_mapping_1.getLinterCandidates)(this.language)
|
|
134
|
+
: (0, lsp_mapping_1.getLspCandidates)(this.language))[0] ?? null;
|
|
129
135
|
if (!candidate) {
|
|
130
136
|
return null;
|
|
131
137
|
}
|
|
@@ -306,6 +306,74 @@ describe("registerReadTools", () => {
|
|
|
306
306
|
raw: diagnostics,
|
|
307
307
|
});
|
|
308
308
|
});
|
|
309
|
+
it("returns a structured error instead of throwing when workspace analysis fails (regression)", async () => {
|
|
310
|
+
const registrar = new FakeRegistrar();
|
|
311
|
+
const lifecycle = createLifecycle({ diagnostics: [] });
|
|
312
|
+
lifecycle.analyzeWorkspace.mockRejectedValueOnce(new Error("boom"));
|
|
313
|
+
(0, read_tools_1.registerReadTools)(registrar, lifecycle, { initializeManager: jest.fn() });
|
|
314
|
+
await expect(getHandler(registrar, "lsp_diagnostics")({ scope: "workspace" })).resolves.toMatchObject({
|
|
315
|
+
content: [{ type: "text", text: "boom" }],
|
|
316
|
+
error: true,
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
it("reports the standard timeout message when workspace analysis times out (regression)", async () => {
|
|
320
|
+
const registrar = new FakeRegistrar();
|
|
321
|
+
const lifecycle = createLifecycle({ diagnostics: [] });
|
|
322
|
+
lifecycle.analyzeWorkspace.mockRejectedValueOnce(new Error("Workspace diagnostics timed out"));
|
|
323
|
+
(0, read_tools_1.registerReadTools)(registrar, lifecycle, { initializeManager: jest.fn() });
|
|
324
|
+
await expect(getHandler(registrar, "lsp_diagnostics")({ scope: "workspace" })).resolves.toEqual({
|
|
325
|
+
content: [
|
|
326
|
+
{
|
|
327
|
+
type: "text",
|
|
328
|
+
text: "Operation timed out after 30s — try a more specific query or check the LSP server health",
|
|
329
|
+
},
|
|
330
|
+
],
|
|
331
|
+
error: true,
|
|
332
|
+
raw: null,
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
it("caps workspace diagnostics at 200 results", async () => {
|
|
336
|
+
const registrar = new FakeRegistrar();
|
|
337
|
+
const many = Array.from({ length: 250 }, (_, i) => ({
|
|
338
|
+
uri: `file:///workspace/src/file${i}.ts`,
|
|
339
|
+
message: `issue ${i}`,
|
|
340
|
+
severity: vscode_languageserver_protocol_1.DiagnosticSeverity.Warning,
|
|
341
|
+
range: {
|
|
342
|
+
start: { line: 0, character: 0 },
|
|
343
|
+
end: { line: 0, character: 1 },
|
|
344
|
+
},
|
|
345
|
+
}));
|
|
346
|
+
const lifecycle = createLifecycle({ diagnostics: many });
|
|
347
|
+
(0, read_tools_1.registerReadTools)(registrar, lifecycle, { initializeManager: jest.fn() });
|
|
348
|
+
const result = (await getHandler(registrar, "lsp_diagnostics")({ scope: "workspace" }));
|
|
349
|
+
expect(result.raw).toHaveLength(200);
|
|
350
|
+
expect(result.raw).toEqual(many.slice(0, 200));
|
|
351
|
+
});
|
|
352
|
+
it("notes truncated/errored languages in workspace diagnostics text without altering raw (regression)", async () => {
|
|
353
|
+
const registrar = new FakeRegistrar();
|
|
354
|
+
const diagnostics = [
|
|
355
|
+
{
|
|
356
|
+
uri: "file:///workspace/src/index.ts",
|
|
357
|
+
message: "Boom",
|
|
358
|
+
severity: vscode_languageserver_protocol_1.DiagnosticSeverity.Error,
|
|
359
|
+
range: {
|
|
360
|
+
start: { line: 0, character: 0 },
|
|
361
|
+
end: { line: 0, character: 1 },
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
];
|
|
365
|
+
const lifecycle = createLifecycle({ diagnostics });
|
|
366
|
+
lifecycle.analyzeWorkspace.mockResolvedValueOnce({
|
|
367
|
+
perLanguage: [
|
|
368
|
+
{ language: "python", opened: 90, failed: 3, truncated: true },
|
|
369
|
+
],
|
|
370
|
+
});
|
|
371
|
+
(0, read_tools_1.registerReadTools)(registrar, lifecycle, { initializeManager: jest.fn() });
|
|
372
|
+
const result = (await getHandler(registrar, "lsp_diagnostics")({ scope: "workspace" }));
|
|
373
|
+
expect(result.content[0]?.text).toContain("100-file scan limit");
|
|
374
|
+
expect(result.content[0]?.text).toContain("python");
|
|
375
|
+
expect(result.raw).toEqual(diagnostics);
|
|
376
|
+
});
|
|
309
377
|
it("returns health instantly without LSP requests", async () => {
|
|
310
378
|
const registrar = new FakeRegistrar();
|
|
311
379
|
const lifecycle = createLifecycle({
|
|
@@ -455,13 +523,14 @@ function getHandler(registrar, name) {
|
|
|
455
523
|
function createLifecycle(options) {
|
|
456
524
|
return {
|
|
457
525
|
getClientForFile: jest.fn((_) => options.fileClient ?? null),
|
|
526
|
+
getDiagnosticClientsForFile: jest.fn((_) => options.fileClient ? [options.fileClient] : []),
|
|
458
527
|
getReadyClients: jest.fn((_) => options.workspaceClients ?? []),
|
|
459
528
|
getFileDiagnostics: jest.fn((_) => (options.diagnostics ?? []).filter((diagnostic) => diagnostic.uri === "file:///workspace/src/index.ts")),
|
|
460
529
|
getWorkspaceDiagnostics: jest.fn((_) => options.diagnostics ?? []),
|
|
461
530
|
getHealth: jest.fn(() => options.health ?? []),
|
|
462
531
|
ensureLanguageForFile: jest.fn().mockResolvedValue(undefined),
|
|
463
532
|
ensureSeedFilesOpen: jest.fn().mockResolvedValue(undefined),
|
|
464
|
-
analyzeWorkspace: jest.fn().mockResolvedValue(
|
|
533
|
+
analyzeWorkspace: jest.fn().mockResolvedValue({ perLanguage: [] }),
|
|
465
534
|
};
|
|
466
535
|
}
|
|
467
536
|
function createClient(result) {
|
|
@@ -213,13 +213,14 @@ describe("registerWriteTools", () => {
|
|
|
213
213
|
const registrar = new FakeRegistrar();
|
|
214
214
|
const noClientLifecycle = {
|
|
215
215
|
getClientForFile: jest.fn(() => null),
|
|
216
|
+
getDiagnosticClientsForFile: jest.fn(() => []),
|
|
216
217
|
getReadyClients: jest.fn(() => []),
|
|
217
218
|
getFileDiagnostics: jest.fn((_) => []),
|
|
218
219
|
getWorkspaceDiagnostics: jest.fn(() => []),
|
|
219
220
|
getHealth: jest.fn(() => []),
|
|
220
221
|
ensureLanguageForFile: jest.fn().mockResolvedValue(undefined),
|
|
221
222
|
ensureSeedFilesOpen: jest.fn().mockResolvedValue(undefined),
|
|
222
|
-
analyzeWorkspace: jest.fn().mockResolvedValue(
|
|
223
|
+
analyzeWorkspace: jest.fn().mockResolvedValue({ perLanguage: [] }),
|
|
223
224
|
};
|
|
224
225
|
(0, write_tools_1.registerWriteTools)(registrar, noClientLifecycle);
|
|
225
226
|
await expect(getHandler(registrar, "lsp_rename")({ file: "/workspace/README.md", line: 0, character: 0, newName: "x" })).resolves.toEqual({
|
|
@@ -296,13 +297,14 @@ function getHandler(registrar, name) {
|
|
|
296
297
|
function createLifecycle(client) {
|
|
297
298
|
return {
|
|
298
299
|
getClientForFile: jest.fn((_) => client),
|
|
300
|
+
getDiagnosticClientsForFile: jest.fn((_) => [client]),
|
|
299
301
|
getReadyClients: jest.fn(() => [client]),
|
|
300
302
|
getFileDiagnostics: jest.fn((_) => []),
|
|
301
303
|
getWorkspaceDiagnostics: jest.fn(() => []),
|
|
302
304
|
getHealth: jest.fn(() => []),
|
|
303
305
|
ensureLanguageForFile: jest.fn().mockResolvedValue(undefined),
|
|
304
306
|
ensureSeedFilesOpen: jest.fn().mockResolvedValue(undefined),
|
|
305
|
-
analyzeWorkspace: jest.fn().mockResolvedValue(
|
|
307
|
+
analyzeWorkspace: jest.fn().mockResolvedValue({ perLanguage: [] }),
|
|
306
308
|
};
|
|
307
309
|
}
|
|
308
310
|
function createClient(result, capabilities = { renameProvider: true }) {
|
package/dist/mcp/server.js
CHANGED
|
@@ -190,6 +190,7 @@ class McpServer {
|
|
|
190
190
|
createLifecycleProxy() {
|
|
191
191
|
return {
|
|
192
192
|
getClientForFile: (filePath) => this.requireManager().getClientForFile(filePath),
|
|
193
|
+
getDiagnosticClientsForFile: (filePath) => this.requireManager().getDiagnosticClientsForFile(filePath),
|
|
193
194
|
getReadyClients: (language) => this.requireManager().getReadyClients(language),
|
|
194
195
|
getFileDiagnostics: (filePath) => this.requireManager().getFileDiagnostics(filePath),
|
|
195
196
|
getWorkspaceDiagnostics: (language) => this.requireManager().getWorkspaceDiagnostics(language),
|
|
@@ -10,6 +10,7 @@ const zod_1 = require("zod");
|
|
|
10
10
|
const formatters_1 = require("../formatters");
|
|
11
11
|
const uri_1 = require("../../utils/uri");
|
|
12
12
|
const shared_1 = require("./shared");
|
|
13
|
+
const lifecycle_manager_1 = require("../../lsp/lifecycle-manager");
|
|
13
14
|
function registerReadTools(registrar, lifecycleManager, options) {
|
|
14
15
|
registrar.registerTool("lsp_init", {
|
|
15
16
|
description: "Initialize LSP for a project root. Pass languages to pre-warm specific servers; omit to auto-detect from project files (lazy startup per file if none found).",
|
|
@@ -86,28 +87,50 @@ function registerReadTools(registrar, lifecycleManager, options) {
|
|
|
86
87
|
}, async (args) => {
|
|
87
88
|
const filePath = typeof args.file === "string" ? args.file : "";
|
|
88
89
|
const scope = args.scope === "workspace" ? "workspace" : "file";
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
90
|
+
const timeoutSeconds = scope === "workspace" ? 30 : 10;
|
|
91
|
+
try {
|
|
92
|
+
if (filePath) {
|
|
93
|
+
await lifecycleManager.ensureLanguageForFile(filePath);
|
|
94
|
+
// Trigger analysis on every diagnostic source for this file's language
|
|
95
|
+
// (e.g. both pyright and ruff for python), not just the primary server,
|
|
96
|
+
// so the merged result includes type errors and lint findings alike.
|
|
97
|
+
const clients = lifecycleManager.getDiagnosticClientsForFile(filePath);
|
|
98
|
+
await Promise.all(clients.map(async (client) => {
|
|
99
|
+
const waitPromise = client.waitForDiagnosticsPublish(filePath, 10000);
|
|
100
|
+
await client.ensureDidOpen(filePath);
|
|
101
|
+
client.notify("textDocument/didSave", {
|
|
102
|
+
textDocument: { uri: (0, uri_1.pathToUri)(filePath) },
|
|
103
|
+
});
|
|
104
|
+
await waitPromise;
|
|
105
|
+
}));
|
|
99
106
|
}
|
|
107
|
+
if (scope === "workspace") {
|
|
108
|
+
const language = typeof args.language === "string" ? args.language : undefined;
|
|
109
|
+
const summary = await (0, lifecycle_manager_1.promiseWithTimeout)(lifecycleManager.analyzeWorkspace(language), 30000, "Workspace diagnostics timed out");
|
|
110
|
+
const diagnostics = lifecycleManager
|
|
111
|
+
.getWorkspaceDiagnostics(language)
|
|
112
|
+
.slice(0, 200);
|
|
113
|
+
let text = (0, formatters_1.formatDiagnostics)(diagnostics, "workspace");
|
|
114
|
+
const truncated = summary.perLanguage
|
|
115
|
+
.filter((entry) => entry.truncated)
|
|
116
|
+
.map((entry) => entry.language);
|
|
117
|
+
const errored = summary.perLanguage
|
|
118
|
+
.filter((entry) => entry.error)
|
|
119
|
+
.map((entry) => `${entry.language}: ${entry.error}`);
|
|
120
|
+
if (truncated.length > 0) {
|
|
121
|
+
text += `\n\nNote: hit the 100-file scan limit for: ${truncated.join(", ")}. Results may be incomplete — narrow scope with { language }.`;
|
|
122
|
+
}
|
|
123
|
+
if (errored.length > 0) {
|
|
124
|
+
text += `\n\nNote: workspace scan failed for: ${errored.join("; ")}. Other languages were unaffected.`;
|
|
125
|
+
}
|
|
126
|
+
return (0, shared_1.success)(text, diagnostics);
|
|
127
|
+
}
|
|
128
|
+
const diagnostics = lifecycleManager.getFileDiagnostics(filePath);
|
|
129
|
+
return (0, shared_1.success)((0, formatters_1.formatDiagnostics)(diagnostics, "file"), diagnostics);
|
|
100
130
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
await lifecycleManager.analyzeWorkspace(language);
|
|
104
|
-
const diagnostics = lifecycleManager
|
|
105
|
-
.getWorkspaceDiagnostics(language)
|
|
106
|
-
.slice(0, 200);
|
|
107
|
-
return (0, shared_1.success)((0, formatters_1.formatDiagnostics)(diagnostics, "workspace"), diagnostics);
|
|
131
|
+
catch (error) {
|
|
132
|
+
return (0, shared_1.mapToolError)(error, timeoutSeconds);
|
|
108
133
|
}
|
|
109
|
-
const diagnostics = lifecycleManager.getFileDiagnostics(filePath);
|
|
110
|
-
return (0, shared_1.success)((0, formatters_1.formatDiagnostics)(diagnostics, "file"), diagnostics);
|
|
111
134
|
});
|
|
112
135
|
registrar.registerTool("lsp_type_definition", { description: "Find type definitions", inputSchema: positionSchema }, async (args) => {
|
|
113
136
|
return await runFileRequest({
|