@theupsider/lsp-mcp 1.1.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const diagnostic_store_1 = require("../diagnostic-store");
4
+ describe('DiagnosticStore', () => {
5
+ it('replaces diagnostics wholesale per uri on each publish', () => {
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')] });
9
+ expect(store.getForFile('/a.ts')).toEqual([
10
+ { ...makeDiagnostic(2, 'second'), uri: 'file:///a.ts' },
11
+ ]);
12
+ });
13
+ it('ignores malformed publishDiagnostics payloads', () => {
14
+ const store = new diagnostic_store_1.DiagnosticStore();
15
+ store.store(null);
16
+ store.store({ uri: 'file:///a.ts' });
17
+ store.store({ diagnostics: [] });
18
+ expect(store.getForFile('/a.ts')).toEqual([]);
19
+ });
20
+ it('sorts workspace diagnostics by severity ascending (errors before warnings)', () => {
21
+ const store = new diagnostic_store_1.DiagnosticStore();
22
+ store.store({
23
+ uri: 'file:///warn.ts',
24
+ diagnostics: [makeDiagnostic(2, 'a warning')],
25
+ });
26
+ store.store({
27
+ uri: 'file:///error.ts',
28
+ diagnostics: [makeDiagnostic(1, 'an error')],
29
+ });
30
+ store.store({
31
+ uri: 'file:///hint.ts',
32
+ diagnostics: [makeDiagnostic(4, 'a hint')],
33
+ });
34
+ const result = store.getForWorkspace();
35
+ expect(result.map((d) => d.message)).toEqual([
36
+ 'an error',
37
+ 'a warning',
38
+ 'a hint',
39
+ ]);
40
+ });
41
+ it('filters workspace diagnostics by language extension', () => {
42
+ 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')] });
45
+ const result = store.getForWorkspace('python');
46
+ expect(result).toEqual([
47
+ { ...makeDiagnostic(1, 'py issue'), uri: 'file:///b.py' },
48
+ ]);
49
+ });
50
+ });
51
+ function makeDiagnostic(severity, message) {
52
+ return {
53
+ severity,
54
+ message,
55
+ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
56
+ };
57
+ }
@@ -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);
@@ -4,6 +4,7 @@ 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");
@@ -68,18 +69,44 @@ class LifecycleManager {
68
69
  return this.store.getForWorkspace(language);
69
70
  }
70
71
  async analyzeWorkspace(language) {
71
- await Promise.all(Array.from(this.supervisors.entries())
72
+ const results = await Promise.allSettled(Array.from(this.supervisors.entries())
72
73
  .filter(([lang, supervisor]) => {
73
74
  const health = supervisor.getHealth();
74
75
  return health.status === "ready" && (!language || lang === language);
75
76
  })
76
77
  .map(async ([lang, supervisor]) => {
77
78
  const client = supervisor.getClient();
78
- if (!client)
79
- return;
79
+ if (!client) {
80
+ return { language: lang, opened: 0, failed: 0, truncated: false };
81
+ }
80
82
  const extensions = (0, language_registry_1.extensionsForLanguage)(lang);
81
- await client.openAllFilesForDiagnostics(extensions);
83
+ try {
84
+ const scan = await client.openAllFilesForDiagnostics(extensions);
85
+ return { language: lang, ...scan };
86
+ }
87
+ catch (error) {
88
+ return {
89
+ language: lang,
90
+ opened: 0,
91
+ failed: 0,
92
+ truncated: false,
93
+ error: error instanceof Error ? error.message : String(error),
94
+ };
95
+ }
82
96
  }));
97
+ return {
98
+ perLanguage: results.map((result) => result.status === "fulfilled"
99
+ ? result.value
100
+ : {
101
+ language: "unknown",
102
+ opened: 0,
103
+ failed: 0,
104
+ truncated: false,
105
+ error: result.reason instanceof Error
106
+ ? result.reason.message
107
+ : String(result.reason),
108
+ }),
109
+ };
83
110
  }
84
111
  async ensureSeedFilesOpen() {
85
112
  await Promise.all(Array.from(this.supervisors.entries()).map(async ([language, supervisor]) => {
@@ -176,13 +176,16 @@ class LspClient extends node_events_1.EventEmitter {
176
176
  const MAX_FILES = 100;
177
177
  const allFiles = await findAllFilesWithExtension(this.projectRoot, extensions, MAX_FILES);
178
178
  const newFiles = allFiles.filter((f) => !this.openedFiles.has(f));
179
+ const truncated = allFiles.length >= MAX_FILES;
179
180
  if (newFiles.length === 0) {
180
- return;
181
+ return { opened: 0, failed: 0, truncated };
181
182
  }
182
183
  // Set up wait promises BEFORE opening files to avoid missing notifications
183
184
  const waitPromises = newFiles.map((f) => this.waitForDiagnosticsPublish(f, 15000));
184
- await Promise.all(newFiles.map((f) => this.ensureDidOpen(f)));
185
- await Promise.all(waitPromises);
185
+ const openResults = await Promise.allSettled(newFiles.map((f) => this.ensureDidOpen(f)));
186
+ await Promise.allSettled(waitPromises);
187
+ const failed = openResults.filter((result) => result.status === "rejected").length;
188
+ return { opened: newFiles.length - failed, failed, truncated };
186
189
  }
187
190
  sendMessage(message) {
188
191
  if (!this.process) {
@@ -311,6 +314,13 @@ const SKIP_DIRS = new Set([
311
314
  "target",
312
315
  ".cache",
313
316
  "vendor",
317
+ ".venv",
318
+ "venv",
319
+ ".tox",
320
+ ".mypy_cache",
321
+ ".pytest_cache",
322
+ ".idea",
323
+ ".gradle",
314
324
  ]);
315
325
  async function findFirstFileWithExtension(dir, extensions) {
316
326
  const extensionSet = new Set(extensions.map((e) => e.toLowerCase()));
@@ -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({
@@ -461,7 +529,7 @@ function createLifecycle(options) {
461
529
  getHealth: jest.fn(() => options.health ?? []),
462
530
  ensureLanguageForFile: jest.fn().mockResolvedValue(undefined),
463
531
  ensureSeedFilesOpen: jest.fn().mockResolvedValue(undefined),
464
- analyzeWorkspace: jest.fn().mockResolvedValue(undefined),
532
+ analyzeWorkspace: jest.fn().mockResolvedValue({ perLanguage: [] }),
465
533
  };
466
534
  }
467
535
  function createClient(result) {
@@ -219,7 +219,7 @@ describe("registerWriteTools", () => {
219
219
  getHealth: jest.fn(() => []),
220
220
  ensureLanguageForFile: jest.fn().mockResolvedValue(undefined),
221
221
  ensureSeedFilesOpen: jest.fn().mockResolvedValue(undefined),
222
- analyzeWorkspace: jest.fn().mockResolvedValue(undefined),
222
+ analyzeWorkspace: jest.fn().mockResolvedValue({ perLanguage: [] }),
223
223
  };
224
224
  (0, write_tools_1.registerWriteTools)(registrar, noClientLifecycle);
225
225
  await expect(getHandler(registrar, "lsp_rename")({ file: "/workspace/README.md", line: 0, character: 0, newName: "x" })).resolves.toEqual({
@@ -302,7 +302,7 @@ function createLifecycle(client) {
302
302
  getHealth: jest.fn(() => []),
303
303
  ensureLanguageForFile: jest.fn().mockResolvedValue(undefined),
304
304
  ensureSeedFilesOpen: jest.fn().mockResolvedValue(undefined),
305
- analyzeWorkspace: jest.fn().mockResolvedValue(undefined),
305
+ analyzeWorkspace: jest.fn().mockResolvedValue({ perLanguage: [] }),
306
306
  };
307
307
  }
308
308
  function createClient(result, capabilities = { renameProvider: true }) {
@@ -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,47 @@ 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
+ const client = lifecycleManager.getClientForFile(filePath);
95
+ if (client) {
96
+ const waitPromise = client.waitForDiagnosticsPublish(filePath, 10000);
97
+ await client.ensureDidOpen(filePath);
98
+ client.notify("textDocument/didSave", {
99
+ textDocument: { uri: (0, uri_1.pathToUri)(filePath) },
100
+ });
101
+ await waitPromise;
102
+ }
99
103
  }
104
+ if (scope === "workspace") {
105
+ const language = typeof args.language === "string" ? args.language : undefined;
106
+ const summary = await (0, lifecycle_manager_1.promiseWithTimeout)(lifecycleManager.analyzeWorkspace(language), 30000, "Workspace diagnostics timed out");
107
+ const diagnostics = lifecycleManager
108
+ .getWorkspaceDiagnostics(language)
109
+ .slice(0, 200);
110
+ let text = (0, formatters_1.formatDiagnostics)(diagnostics, "workspace");
111
+ const truncated = summary.perLanguage
112
+ .filter((entry) => entry.truncated)
113
+ .map((entry) => entry.language);
114
+ const errored = summary.perLanguage
115
+ .filter((entry) => entry.error)
116
+ .map((entry) => `${entry.language}: ${entry.error}`);
117
+ if (truncated.length > 0) {
118
+ text += `\n\nNote: hit the 100-file scan limit for: ${truncated.join(", ")}. Results may be incomplete — narrow scope with { language }.`;
119
+ }
120
+ if (errored.length > 0) {
121
+ text += `\n\nNote: workspace scan failed for: ${errored.join("; ")}. Other languages were unaffected.`;
122
+ }
123
+ return (0, shared_1.success)(text, diagnostics);
124
+ }
125
+ const diagnostics = lifecycleManager.getFileDiagnostics(filePath);
126
+ return (0, shared_1.success)((0, formatters_1.formatDiagnostics)(diagnostics, "file"), diagnostics);
100
127
  }
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);
128
+ catch (error) {
129
+ return (0, shared_1.mapToolError)(error, timeoutSeconds);
108
130
  }
109
- const diagnostics = lifecycleManager.getFileDiagnostics(filePath);
110
- return (0, shared_1.success)((0, formatters_1.formatDiagnostics)(diagnostics, "file"), diagnostics);
111
131
  });
112
132
  registrar.registerTool("lsp_type_definition", { description: "Find type definitions", inputSchema: positionSchema }, async (args) => {
113
133
  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.2.0",
4
4
  "description": "Universal LSP MCP server",
5
5
  "license": "Apache-2.0",
6
6
  "type": "commonjs",