@theupsider/lsp-mcp 1.2.0 → 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;
@@ -2,32 +2,50 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const diagnostic_store_1 = require("../diagnostic-store");
4
4
  describe('DiagnosticStore', () => {
5
- it('replaces diagnostics wholesale per uri on each publish', () => {
5
+ it("replaces a source's diagnostics wholesale per uri on each publish", () => {
6
6
  const store = new diagnostic_store_1.DiagnosticStore();
7
- store.store({ uri: 'file:///a.ts', diagnostics: [makeDiagnostic(1, 'first')] });
8
- store.store({ uri: 'file:///a.ts', diagnostics: [makeDiagnostic(2, 'second')] });
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
9
  expect(store.getForFile('/a.ts')).toEqual([
10
10
  { ...makeDiagnostic(2, 'second'), uri: 'file:///a.ts' },
11
11
  ]);
12
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
+ });
13
31
  it('ignores malformed publishDiagnostics payloads', () => {
14
32
  const store = new diagnostic_store_1.DiagnosticStore();
15
- store.store(null);
16
- store.store({ uri: 'file:///a.ts' });
17
- store.store({ diagnostics: [] });
33
+ store.store('pyright', null);
34
+ store.store('pyright', { uri: 'file:///a.ts' });
35
+ store.store('pyright', { diagnostics: [] });
18
36
  expect(store.getForFile('/a.ts')).toEqual([]);
19
37
  });
20
38
  it('sorts workspace diagnostics by severity ascending (errors before warnings)', () => {
21
39
  const store = new diagnostic_store_1.DiagnosticStore();
22
- store.store({
40
+ store.store('pyright', {
23
41
  uri: 'file:///warn.ts',
24
42
  diagnostics: [makeDiagnostic(2, 'a warning')],
25
43
  });
26
- store.store({
44
+ store.store('pyright', {
27
45
  uri: 'file:///error.ts',
28
46
  diagnostics: [makeDiagnostic(1, 'an error')],
29
47
  });
30
- store.store({
48
+ store.store('pyright', {
31
49
  uri: 'file:///hint.ts',
32
50
  diagnostics: [makeDiagnostic(4, 'a hint')],
33
51
  });
@@ -40,8 +58,8 @@ describe('DiagnosticStore', () => {
40
58
  });
41
59
  it('filters workspace diagnostics by language extension', () => {
42
60
  const store = new diagnostic_store_1.DiagnosticStore();
43
- store.store({ uri: 'file:///a.ts', diagnostics: [makeDiagnostic(1, 'ts issue')] });
44
- store.store({ uri: 'file:///b.py', diagnostics: [makeDiagnostic(1, 'py issue')] });
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')] });
45
63
  const result = store.getForWorkspace('python');
46
64
  expect(result).toEqual([
47
65
  { ...makeDiagnostic(1, 'py issue'), uri: 'file:///b.py' },
@@ -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
  });
@@ -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) {
@@ -8,12 +8,18 @@ exports.promiseWithTimeout = promiseWithTimeout;
8
8
  const node_path_1 = __importDefault(require("node:path"));
9
9
  const language_detector_1 = require("../detection/language-detector");
10
10
  const language_registry_1 = require("../detection/language-registry");
11
+ const lsp_mapping_1 = require("../detection/lsp-mapping");
11
12
  const diagnostic_store_1 = require("./diagnostic-store");
12
13
  const server_supervisor_1 = require("./server-supervisor");
13
14
  class LifecycleManager {
14
15
  projectRoot;
15
16
  logLevel;
16
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();
17
23
  store = new diagnostic_store_1.DiagnosticStore();
18
24
  constructor(projectRoot, logLevel) {
19
25
  this.projectRoot = projectRoot;
@@ -24,12 +30,12 @@ class LifecycleManager {
24
30
  await promiseWithTimeout(startPromise, 30000, "Lifecycle start timed out");
25
31
  }
26
32
  async ensureLanguage(language) {
27
- if (this.supervisors.has(language)) {
28
- return;
33
+ if (!this.supervisors.has(language)) {
34
+ const supervisor = this.createSupervisor(language);
35
+ this.supervisors.set(language, supervisor);
36
+ await supervisor.start();
29
37
  }
30
- const supervisor = this.createSupervisor(language);
31
- this.supervisors.set(language, supervisor);
32
- await supervisor.start();
38
+ await this.ensureLinter(language);
33
39
  }
34
40
  async ensureLanguageForFile(filePath) {
35
41
  const language = (0, language_registry_1.extensionToLanguage)(node_path_1.default.extname(filePath));
@@ -47,8 +53,26 @@ class LifecycleManager {
47
53
  }
48
54
  return null;
49
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
+ }
50
74
  getHealth() {
51
- return Array.from(this.supervisors.values()).map((supervisor) => supervisor.getHealth());
75
+ return this.allSupervisors().map((supervisor) => supervisor.getHealth());
52
76
  }
53
77
  getReadyClients(language) {
54
78
  return Array.from(this.supervisors.values())
@@ -69,7 +93,7 @@ class LifecycleManager {
69
93
  return this.store.getForWorkspace(language);
70
94
  }
71
95
  async analyzeWorkspace(language) {
72
- const results = await Promise.allSettled(Array.from(this.supervisors.entries())
96
+ const results = await Promise.allSettled(this.allSupervisorEntries()
73
97
  .filter(([lang, supervisor]) => {
74
98
  const health = supervisor.getHealth();
75
99
  return health.status === "ready" && (!language || lang === language);
@@ -109,7 +133,7 @@ class LifecycleManager {
109
133
  };
110
134
  }
111
135
  async ensureSeedFilesOpen() {
112
- await Promise.all(Array.from(this.supervisors.entries()).map(async ([language, supervisor]) => {
136
+ await Promise.all(this.allSupervisorEntries().map(async ([language, supervisor]) => {
113
137
  const client = supervisor.getClient();
114
138
  if (!client)
115
139
  return;
@@ -118,7 +142,7 @@ class LifecycleManager {
118
142
  }));
119
143
  }
120
144
  async shutdown() {
121
- const supervisors = Array.from(this.supervisors.values());
145
+ const supervisors = this.allSupervisors();
122
146
  const results = await Promise.allSettled(supervisors.map(async (supervisor) => await promiseWithTimeout(supervisor.shutdown(), 5000, "LSP shutdown timed out")));
123
147
  const errors = results.filter((result) => result.status === "rejected").length;
124
148
  process.stderr.write(`{"timestamp":"${new Date().toISOString()}","level":"info","event":"Shutdown: ${supervisors.length - errors} LSP-Server beendet, ${errors} Fehler"}\n`);
@@ -131,14 +155,36 @@ class LifecycleManager {
131
155
  const supervisor = this.createSupervisor(entry.language);
132
156
  this.supervisors.set(entry.language, supervisor);
133
157
  await supervisor.start();
158
+ await this.ensureLinter(entry.language);
159
+ }
160
+ }
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;
134
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);
135
174
  }
136
- createSupervisor(language) {
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;
137
183
  return new server_supervisor_1.ServerSupervisor(language, this.projectRoot, this.logLevel, (method, params) => {
138
184
  if (method === "textDocument/publishDiagnostics") {
139
- this.store.store(params);
185
+ this.store.store(sourceId, params);
140
186
  }
141
- });
187
+ }, linter);
142
188
  }
143
189
  }
144
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;
@@ -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
  }
@@ -523,6 +523,7 @@ function getHandler(registrar, name) {
523
523
  function createLifecycle(options) {
524
524
  return {
525
525
  getClientForFile: jest.fn((_) => options.fileClient ?? null),
526
+ getDiagnosticClientsForFile: jest.fn((_) => options.fileClient ? [options.fileClient] : []),
526
527
  getReadyClients: jest.fn((_) => options.workspaceClients ?? []),
527
528
  getFileDiagnostics: jest.fn((_) => (options.diagnostics ?? []).filter((diagnostic) => diagnostic.uri === "file:///workspace/src/index.ts")),
528
529
  getWorkspaceDiagnostics: jest.fn((_) => options.diagnostics ?? []),
@@ -213,6 +213,7 @@ 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(() => []),
@@ -296,6 +297,7 @@ 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(() => []),
@@ -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),
@@ -91,15 +91,18 @@ function registerReadTools(registrar, lifecycleManager, options) {
91
91
  try {
92
92
  if (filePath) {
93
93
  await lifecycleManager.ensureLanguageForFile(filePath);
94
- const client = lifecycleManager.getClientForFile(filePath);
95
- if (client) {
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) => {
96
99
  const waitPromise = client.waitForDiagnosticsPublish(filePath, 10000);
97
100
  await client.ensureDidOpen(filePath);
98
101
  client.notify("textDocument/didSave", {
99
102
  textDocument: { uri: (0, uri_1.pathToUri)(filePath) },
100
103
  });
101
104
  await waitPromise;
102
- }
105
+ }));
103
106
  }
104
107
  if (scope === "workspace") {
105
108
  const language = typeof args.language === "string" ? args.language : undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theupsider/lsp-mcp",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Universal LSP MCP server",
5
5
  "license": "Apache-2.0",
6
6
  "type": "commonjs",