@theupsider/lsp-mcp 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,8 +26,9 @@ describe('index entrypoint', () => {
26
26
  it('prints version and exits for --version', async () => {
27
27
  const stdout = jest.fn();
28
28
  const exit = jest.fn();
29
+ const { version } = jest.requireActual('../../package.json');
29
30
  await (0, index_1.main)(['--version'], {}, { stdout, exit });
30
- expect(stdout).toHaveBeenCalledWith('0.1.0\n');
31
+ expect(stdout).toHaveBeenCalledWith(`${version}\n`);
31
32
  expect(exit).toHaveBeenCalledWith(0);
32
33
  });
33
34
  it('boots lifecycle, logs startup report, and starts the MCP server', async () => {
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.detectLanguages = detectLanguages;
7
7
  const promises_1 = require("node:fs/promises");
8
8
  const node_path_1 = __importDefault(require("node:path"));
9
+ const language_registry_1 = require("./language-registry");
9
10
  const MARKER_DEFINITIONS = [
10
11
  { language: 'javascript', markers: ['package.json'] },
11
12
  { language: 'typescript', markers: ['tsconfig.json'] },
@@ -19,10 +20,7 @@ const MARKER_DEFINITIONS = [
19
20
  { language: 'kotlin', markers: ['build.gradle.kts'] },
20
21
  { language: 'swift', markers: ['Package.swift'] }
21
22
  ];
22
- const EXTENSION_DEFINITIONS = [
23
- { language: 'c', extensions: ['.c', '.h'] },
24
- { language: 'cpp', extensions: ['.cpp', '.hpp', '.cc'] }
25
- ];
23
+ const EXTENSION_LANGUAGES = ['c', 'cpp'];
26
24
  async function detectLanguages(projectRoot) {
27
25
  const entries = await collectEntries(projectRoot, projectRoot);
28
26
  const markerMatches = new Map();
@@ -64,14 +62,15 @@ async function detectLanguages(projectRoot) {
64
62
  });
65
63
  }
66
64
  }
67
- for (const definition of EXTENSION_DEFINITIONS) {
65
+ for (const language of EXTENSION_LANGUAGES) {
66
+ const extensions = (0, language_registry_1.extensionsForLanguage)(language);
68
67
  const matches = entries
69
- .filter((entry) => definition.extensions.some((extension) => entry.relativePath.endsWith(extension)))
68
+ .filter((entry) => extensions.some((extension) => entry.relativePath.endsWith(extension)))
70
69
  .map((entry) => entry.relativePath)
71
- .sort((left, right) => compareByExtensionPriority(left, right, definition.extensions));
70
+ .sort((left, right) => compareByExtensionPriority(left, right, extensions));
72
71
  if (matches.length > 0) {
73
72
  detected.push({
74
- language: definition.language,
73
+ language,
75
74
  confidence: 'extension',
76
75
  markers: matches
77
76
  });
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.KNOWN_EXTENSIONS = void 0;
4
+ exports.extensionToLanguage = extensionToLanguage;
5
+ exports.extensionToLanguageId = extensionToLanguageId;
6
+ exports.extensionsForLanguage = extensionsForLanguage;
7
+ const EXTENSION_MAP = {
8
+ '.ts': { language: 'typescript', languageId: 'typescript' },
9
+ '.tsx': { language: 'typescript', languageId: 'typescriptreact' },
10
+ '.js': { language: 'javascript', languageId: 'javascript' },
11
+ '.jsx': { language: 'javascript', languageId: 'javascriptreact' },
12
+ '.py': { language: 'python', languageId: 'python' },
13
+ '.cs': { language: 'csharp', languageId: 'csharp' },
14
+ '.java': { language: 'java', languageId: 'java' },
15
+ '.go': { language: 'go', languageId: 'go' },
16
+ '.rs': { language: 'rust', languageId: 'rust' },
17
+ '.c': { language: 'c', languageId: 'c' },
18
+ '.h': { language: 'c', languageId: 'c' },
19
+ '.cpp': { language: 'cpp', languageId: 'cpp' },
20
+ '.hpp': { language: 'cpp', languageId: 'cpp' },
21
+ '.cc': { language: 'cpp', languageId: 'cpp' },
22
+ '.rb': { language: 'ruby', languageId: 'ruby' },
23
+ '.php': { language: 'php', languageId: 'php' },
24
+ '.kt': { language: 'kotlin', languageId: 'kotlin' },
25
+ '.swift': { language: 'swift', languageId: 'swift' }
26
+ };
27
+ function extensionToLanguage(ext) {
28
+ return EXTENSION_MAP[ext.toLowerCase()]?.language;
29
+ }
30
+ function extensionToLanguageId(ext) {
31
+ return EXTENSION_MAP[ext.toLowerCase()]?.languageId ?? 'plaintext';
32
+ }
33
+ function extensionsForLanguage(language) {
34
+ return Object.entries(EXTENSION_MAP)
35
+ .filter(([, entry]) => entry.language === language)
36
+ .map(([ext]) => ext);
37
+ }
38
+ exports.KNOWN_EXTENSIONS = new Set(Object.keys(EXTENSION_MAP));
@@ -21,6 +21,8 @@ class MockLspClient extends node_events_1.EventEmitter {
21
21
  request = jest.fn().mockResolvedValue({ items: [] });
22
22
  isReady = jest.fn().mockReturnValue(true);
23
23
  getCapabilities = jest.fn().mockReturnValue({ hoverProvider: true });
24
+ ensureDidOpen = jest.fn().mockResolvedValue(undefined);
25
+ waitForDiagnosticsPublish = jest.fn().mockResolvedValue(undefined);
24
26
  }
25
27
  describe('LifecycleManager', () => {
26
28
  beforeEach(() => {
@@ -116,7 +118,7 @@ describe('LifecycleManager', () => {
116
118
  const getLspCandidates = jest.requireMock('../../detection/lsp-mapping').getLspCandidates;
117
119
  const LspClient = jest.requireMock('../lsp-client').LspClient;
118
120
  const firstClient = new MockLspClient();
119
- firstClient.request.mockRejectedValueOnce(new Error('ping timeout'));
121
+ firstClient.isReady.mockReturnValue(false);
120
122
  const secondClient = new MockLspClient();
121
123
  detectLanguages.mockResolvedValue([{ language: 'typescript', confidence: 'marker', markers: ['tsconfig.json'] }]);
122
124
  findAvailableLsp.mockResolvedValue({ cmd: 'typescript-language-server', args: ['--stdio'], pkg: 'typescript-language-server', mgr: 'npm' });
@@ -126,7 +128,7 @@ describe('LifecycleManager', () => {
126
128
  const manager = new lifecycle_manager_1.LifecycleManager('/workspace/project', 'debug');
127
129
  await manager.start();
128
130
  await jest.advanceTimersByTimeAsync(30000);
129
- expect(firstClient.request).toHaveBeenCalledWith('workspace/symbol', { query: '__lsp_mcp_healthcheck__' }, 5000);
131
+ expect(firstClient.isReady).toHaveBeenCalled();
130
132
  expect(secondClient.start).toHaveBeenCalled();
131
133
  expect(manager.getClient('typescript')).toBe(secondClient);
132
134
  await manager.shutdown();
@@ -222,13 +224,13 @@ describe('LifecycleManager', () => {
222
224
  const manager = new lifecycle_manager_1.LifecycleManager('/workspace/project', 'debug');
223
225
  await manager.start();
224
226
  const managerAccess = manager;
225
- const state = managerAccess.states.get('typescript');
226
- expect(state).toBeDefined();
227
- if (!state) {
228
- throw new Error('Expected typescript state');
227
+ const supervisor = managerAccess.supervisors.get('typescript');
228
+ expect(supervisor).toBeDefined();
229
+ if (!supervisor) {
230
+ throw new Error('Expected typescript supervisor');
229
231
  }
230
- state.restartCount = 3;
231
- await managerAccess.restartLanguage(state, 'too many restarts');
232
+ supervisor.restartCount = 3;
233
+ await supervisor.restart('too many restarts');
232
234
  expect(manager.getClient('typescript')).toBeNull();
233
235
  expect(manager.getHealth()).toEqual([
234
236
  {
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.DiagnosticStore = void 0;
7
+ const node_path_1 = __importDefault(require("node:path"));
8
+ const language_registry_1 = require("../detection/language-registry");
9
+ const uri_1 = require("../utils/uri");
10
+ class DiagnosticStore {
11
+ diagnostics = new Map();
12
+ store(params) {
13
+ if (!isPublishDiagnosticsParams(params)) {
14
+ return;
15
+ }
16
+ this.diagnostics.set(params.uri, cloneDiagnostics(params.diagnostics));
17
+ }
18
+ getForFile(filePath) {
19
+ const uri = (0, uri_1.pathToUri)(filePath);
20
+ return (this.diagnostics.get(uri) ?? []).map((diagnostic) => ({ ...diagnostic, uri }));
21
+ }
22
+ getForWorkspace(language) {
23
+ const allowedExtensions = language ? (0, language_registry_1.extensionsForLanguage)(language) : null;
24
+ return Array.from(this.diagnostics.entries())
25
+ .filter(([uri]) => {
26
+ if (!allowedExtensions) {
27
+ return true;
28
+ }
29
+ return allowedExtensions.includes(node_path_1.default.extname((0, uri_1.uriToPath)(uri)).toLowerCase());
30
+ })
31
+ .flatMap(([uri, diagnostics]) => diagnostics.map((diagnostic) => ({ ...diagnostic, uri })))
32
+ .sort((left, right) => (left.severity ?? 4) - (right.severity ?? 4));
33
+ }
34
+ }
35
+ exports.DiagnosticStore = DiagnosticStore;
36
+ function isPublishDiagnosticsParams(value) {
37
+ return typeof value === 'object' && value !== null
38
+ && 'uri' in value && typeof value.uri === 'string'
39
+ && 'diagnostics' in value && Array.isArray(value.diagnostics);
40
+ }
41
+ function cloneDiagnostics(diagnostics) {
42
+ return diagnostics.map((diagnostic) => ({
43
+ ...diagnostic,
44
+ range: {
45
+ start: { ...diagnostic.range.start },
46
+ end: { ...diagnostic.range.end }
47
+ }
48
+ }));
49
+ }
@@ -6,35 +6,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.LifecycleManager = void 0;
7
7
  const node_path_1 = __importDefault(require("node:path"));
8
8
  const language_detector_1 = require("../detection/language-detector");
9
- const lsp_mapping_1 = require("../detection/lsp-mapping");
10
- const uri_1 = require("../utils/uri");
11
- const installer_1 = require("./installer");
12
- const lsp_client_1 = require("./lsp-client");
13
- const EXTENSION_LANGUAGE_MAP = {
14
- '.ts': 'typescript',
15
- '.tsx': 'typescript',
16
- '.js': 'javascript',
17
- '.jsx': 'javascript',
18
- '.py': 'python',
19
- '.cs': 'csharp',
20
- '.java': 'java',
21
- '.go': 'go',
22
- '.rs': 'rust',
23
- '.c': 'c',
24
- '.h': 'c',
25
- '.cpp': 'cpp',
26
- '.hpp': 'cpp',
27
- '.cc': 'cpp',
28
- '.rb': 'ruby',
29
- '.php': 'php',
30
- '.kt': 'kotlin',
31
- '.swift': 'swift'
32
- };
9
+ const language_registry_1 = require("../detection/language-registry");
10
+ const diagnostic_store_1 = require("./diagnostic-store");
11
+ const server_supervisor_1 = require("./server-supervisor");
33
12
  class LifecycleManager {
34
13
  projectRoot;
35
14
  logLevel;
36
- states = new Map();
37
- diagnostics = new Map();
15
+ supervisors = new Map();
16
+ store = new diagnostic_store_1.DiagnosticStore();
38
17
  constructor(projectRoot, logLevel) {
39
18
  this.projectRoot = projectRoot;
40
19
  this.logLevel = normalizeLogLevel(logLevel);
@@ -44,197 +23,80 @@ class LifecycleManager {
44
23
  await promiseWithTimeout(startPromise, 30000, 'Lifecycle start timed out');
45
24
  }
46
25
  async ensureLanguage(language) {
47
- if (this.states.has(language)) {
26
+ if (this.supervisors.has(language)) {
48
27
  return;
49
28
  }
50
- const state = {
51
- language,
52
- client: null,
53
- status: 'starting',
54
- serverDef: null,
55
- restartCount: 0,
56
- healthInterval: null
57
- };
58
- this.states.set(language, state);
59
- await this.startLanguage(state);
29
+ const supervisor = this.createSupervisor(language);
30
+ this.supervisors.set(language, supervisor);
31
+ await supervisor.start();
60
32
  }
61
33
  async ensureLanguageForFile(filePath) {
62
- const language = EXTENSION_LANGUAGE_MAP[node_path_1.default.extname(filePath).toLowerCase()];
34
+ const language = (0, language_registry_1.extensionToLanguage)(node_path_1.default.extname(filePath));
63
35
  if (language) {
64
36
  await this.ensureLanguage(language);
65
37
  }
66
38
  }
67
39
  getClient(language) {
68
- return this.states.get(language)?.client ?? null;
40
+ return this.supervisors.get(language)?.getClient() ?? null;
69
41
  }
70
42
  getClientForFile(filePath) {
71
- const language = EXTENSION_LANGUAGE_MAP[node_path_1.default.extname(filePath).toLowerCase()];
43
+ const language = (0, language_registry_1.extensionToLanguage)(node_path_1.default.extname(filePath));
72
44
  if (language) {
73
45
  return this.getClient(language);
74
46
  }
75
47
  return null;
76
48
  }
77
49
  getHealth() {
78
- return Array.from(this.states.values()).map((state) => ({
79
- language: state.language,
80
- status: state.status,
81
- error: state.error,
82
- capabilities: state.capabilities
83
- }));
50
+ return Array.from(this.supervisors.values()).map((supervisor) => supervisor.getHealth());
84
51
  }
85
52
  getReadyClients(language) {
86
- return Array.from(this.states.values())
87
- .filter((state) => state.status === 'ready' && state.client && (!language || state.language === language))
88
- .flatMap((state) => state.client ? [state.client] : []);
53
+ return Array.from(this.supervisors.values())
54
+ .filter((supervisor) => {
55
+ const health = supervisor.getHealth();
56
+ return health.status === 'ready' && (!language || supervisor.language === language);
57
+ })
58
+ .flatMap((supervisor) => {
59
+ const client = supervisor.getClient();
60
+ return client ? [client] : [];
61
+ });
89
62
  }
90
63
  getFileDiagnostics(filePath) {
91
- const uri = (0, uri_1.pathToUri)(filePath);
92
- return (this.diagnostics.get(uri) ?? []).map((diagnostic) => ({ ...diagnostic, uri }));
64
+ return this.store.getForFile(filePath);
93
65
  }
94
66
  getWorkspaceDiagnostics(language) {
95
- const allowedExtensions = language
96
- ? Object.entries(EXTENSION_LANGUAGE_MAP)
97
- .filter(([, mappedLanguage]) => mappedLanguage === language)
98
- .map(([extension]) => extension)
99
- : null;
100
- return Array.from(this.diagnostics.entries())
101
- .filter(([uri]) => {
102
- if (!allowedExtensions) {
103
- return true;
104
- }
105
- return allowedExtensions.includes(node_path_1.default.extname((0, uri_1.uriToPath)(uri)).toLowerCase());
106
- })
107
- .flatMap(([uri, diagnostics]) => diagnostics.map((diagnostic) => ({ ...diagnostic, uri })))
108
- .sort((left, right) => (left.severity ?? 4) - (right.severity ?? 4));
67
+ return this.store.getForWorkspace(language);
68
+ }
69
+ async ensureSeedFilesOpen() {
70
+ await Promise.all(Array.from(this.supervisors.entries()).map(async ([language, supervisor]) => {
71
+ const client = supervisor.getClient();
72
+ if (!client)
73
+ return;
74
+ const extensions = (0, language_registry_1.extensionsForLanguage)(language);
75
+ await client.ensureSeedFileOpen(extensions);
76
+ }));
109
77
  }
110
78
  async shutdown() {
111
- const clients = Array.from(this.states.values()).flatMap((state) => {
112
- if (state.healthInterval) {
113
- clearInterval(state.healthInterval);
114
- state.healthInterval = null;
115
- }
116
- return state.client ? [state.client] : [];
117
- });
118
- const results = await Promise.allSettled(clients.map(async (client) => await promiseWithTimeout(client.shutdown(), 5000, 'LSP shutdown timed out')));
79
+ const supervisors = Array.from(this.supervisors.values());
80
+ const results = await Promise.allSettled(supervisors.map(async (supervisor) => await promiseWithTimeout(supervisor.shutdown(), 5000, 'LSP shutdown timed out')));
119
81
  const errors = results.filter((result) => result.status === 'rejected').length;
120
- process.stderr.write(`{"timestamp":"${new Date().toISOString()}","level":"info","event":"Shutdown: ${clients.length - errors} LSP-Server beendet, ${errors} Fehler"}\n`);
82
+ process.stderr.write(`{"timestamp":"${new Date().toISOString()}","level":"info","event":"Shutdown: ${supervisors.length - errors} LSP-Server beendet, ${errors} Fehler"}\n`);
121
83
  }
122
84
  async startInternal(languages) {
123
85
  const detected = languages
124
86
  ? languages.map((language) => ({ language }))
125
87
  : await (0, language_detector_1.detectLanguages)(this.projectRoot);
126
88
  for (const entry of detected) {
127
- const state = {
128
- language: entry.language,
129
- client: null,
130
- status: 'starting',
131
- serverDef: null,
132
- restartCount: 0,
133
- healthInterval: null
134
- };
135
- this.states.set(entry.language, state);
136
- await this.startLanguage(state);
89
+ const supervisor = this.createSupervisor(entry.language);
90
+ this.supervisors.set(entry.language, supervisor);
91
+ await supervisor.start();
137
92
  }
138
93
  }
139
- async startLanguage(state) {
140
- const serverDef = await this.resolveServer(state.language);
141
- if (!serverDef) {
142
- state.status = 'error';
143
- state.error = `No LSP server available for ${state.language}`;
144
- return;
145
- }
146
- state.serverDef = serverDef;
147
- const client = new lsp_client_1.LspClient(serverDef, this.projectRoot, this.logLevel);
148
- state.client = client;
149
- client.on('crash', async () => {
150
- await this.restartLanguage(state, `LSP server crashed for ${state.language}`);
151
- });
152
- client.on('error', (error) => {
153
- state.status = 'error';
154
- state.error = error instanceof Error ? error.message : 'Unknown LSP error';
155
- });
156
- client.on('notification', (method, params) => {
94
+ createSupervisor(language) {
95
+ return new server_supervisor_1.ServerSupervisor(language, this.projectRoot, this.logLevel, (method, params) => {
157
96
  if (method === 'textDocument/publishDiagnostics') {
158
- this.storeDiagnostics(params);
97
+ this.store.store(params);
159
98
  }
160
99
  });
161
- try {
162
- await client.start();
163
- state.status = 'ready';
164
- state.error = undefined;
165
- state.capabilities = client.getCapabilities() ?? undefined;
166
- this.startHealthChecks(state);
167
- }
168
- catch (error) {
169
- state.status = 'error';
170
- state.error = error instanceof Error ? error.message : 'Unknown LSP startup error';
171
- }
172
- }
173
- startHealthChecks(state) {
174
- if (state.healthInterval) {
175
- clearInterval(state.healthInterval);
176
- }
177
- state.healthInterval = setInterval(() => {
178
- void this.runHealthCheck(state);
179
- }, 30000);
180
- }
181
- async runHealthCheck(state) {
182
- if (!state.client || state.status !== 'ready') {
183
- return;
184
- }
185
- try {
186
- await state.client.request('workspace/symbol', { query: '__lsp_mcp_healthcheck__' }, 5000);
187
- }
188
- catch (error) {
189
- const message = error instanceof Error ? error.message : 'LSP health check failed';
190
- await this.restartLanguage(state, message);
191
- }
192
- }
193
- async restartLanguage(state, reason) {
194
- if (state.healthInterval) {
195
- clearInterval(state.healthInterval);
196
- state.healthInterval = null;
197
- }
198
- if (state.restartCount >= 3) {
199
- state.status = 'error';
200
- state.error = reason;
201
- state.client = null;
202
- return;
203
- }
204
- state.restartCount += 1;
205
- state.status = 'starting';
206
- state.error = reason;
207
- if (state.client) {
208
- try {
209
- await promiseWithTimeout(state.client.shutdown(), 5000, 'LSP shutdown timed out');
210
- }
211
- catch {
212
- // Ignore shutdown failures during restart.
213
- }
214
- }
215
- state.client = null;
216
- await this.startLanguage(state);
217
- }
218
- async resolveServer(language) {
219
- const available = await (0, lsp_mapping_1.findAvailableLsp)(language);
220
- if (available) {
221
- return available;
222
- }
223
- const candidate = (0, lsp_mapping_1.getLspCandidates)(language)[0] ?? null;
224
- if (!candidate) {
225
- return null;
226
- }
227
- const installation = await (0, installer_1.installLsp)(candidate);
228
- if (!installation.success) {
229
- return null;
230
- }
231
- return candidate;
232
- }
233
- storeDiagnostics(params) {
234
- if (!isPublishDiagnosticsParams(params)) {
235
- return;
236
- }
237
- this.diagnostics.set(params.uri, cloneDiagnostics(params.diagnostics));
238
100
  }
239
101
  }
240
102
  exports.LifecycleManager = LifecycleManager;
@@ -244,20 +106,6 @@ function normalizeLogLevel(level) {
244
106
  }
245
107
  return 'info';
246
108
  }
247
- function isPublishDiagnosticsParams(value) {
248
- return typeof value === 'object' && value !== null
249
- && 'uri' in value && typeof value.uri === 'string'
250
- && 'diagnostics' in value && Array.isArray(value.diagnostics);
251
- }
252
- function cloneDiagnostics(diagnostics) {
253
- return diagnostics.map((diagnostic) => ({
254
- ...diagnostic,
255
- range: {
256
- start: { ...diagnostic.range.start },
257
- end: { ...diagnostic.range.end }
258
- }
259
- }));
260
- }
261
109
  async function promiseWithTimeout(promise, timeoutMs, message) {
262
110
  return await new Promise((resolve, reject) => {
263
111
  const timer = setTimeout(() => reject(new Error(message)), timeoutMs);