@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.
@@ -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
- const candidates = getLspCandidates(language);
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.set(params.uri, cloneDiagnostics(params.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 (this.diagnostics.get(uri) ?? []).map((diagnostic) => ({ ...diagnostic, uri }));
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.entries())
25
- .filter(([uri]) => {
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(([uri, diagnostics]) => diagnostics.map((diagnostic) => ({ ...diagnostic, uri })))
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
- return;
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
- const supervisor = this.createSupervisor(language);
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 Array.from(this.supervisors.values()).map((supervisor) => supervisor.getHealth());
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.all(Array.from(this.supervisors.entries())
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
- await client.openAllFilesForDiagnostics(extensions);
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(Array.from(this.supervisors.entries()).map(async ([language, supervisor]) => {
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 = Array.from(this.supervisors.values());
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
- createSupervisor(language) {
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;
@@ -117,7 +117,9 @@ class LspClient extends node_events_1.EventEmitter {
117
117
  this.ready = false;
118
118
  return;
119
119
  }
120
- await this.request("shutdown", {}, 5000);
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.all(newFiles.map((f) => this.ensureDidOpen(f)));
185
- await Promise.all(waitPromises);
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 = await (0, lsp_mapping_1.findAvailableLsp)(this.language);
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 = (0, lsp_mapping_1.getLspCandidates)(this.language)[0] ?? null;
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(undefined),
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(undefined),
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(undefined),
307
+ analyzeWorkspace: jest.fn().mockResolvedValue({ perLanguage: [] }),
306
308
  };
307
309
  }
308
310
  function createClient(result, capabilities = { renameProvider: true }) {
@@ -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
- if (filePath) {
90
- await lifecycleManager.ensureLanguageForFile(filePath);
91
- const client = lifecycleManager.getClientForFile(filePath);
92
- if (client) {
93
- const waitPromise = client.waitForDiagnosticsPublish(filePath, 10000);
94
- await client.ensureDidOpen(filePath);
95
- client.notify("textDocument/didSave", {
96
- textDocument: { uri: (0, uri_1.pathToUri)(filePath) },
97
- });
98
- await waitPromise;
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
- if (scope === "workspace") {
102
- const language = typeof args.language === "string" ? args.language : undefined;
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({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theupsider/lsp-mcp",
3
- "version": "1.1.2",
3
+ "version": "1.3.0",
4
4
  "description": "Universal LSP MCP server",
5
5
  "license": "Apache-2.0",
6
6
  "type": "commonjs",