@theupsider/lsp-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +173 -0
- package/README.md +188 -0
- package/dist/__tests__/index.test.js +45 -0
- package/dist/detection/__tests__/language-detector.test.js +121 -0
- package/dist/detection/__tests__/lsp-mapping.test.js +78 -0
- package/dist/detection/language-detector.js +107 -0
- package/dist/detection/lsp-mapping.js +86 -0
- package/dist/index.js +60 -0
- package/dist/lsp/__tests__/installer.test.js +113 -0
- package/dist/lsp/__tests__/lifecycle-manager.test.js +288 -0
- package/dist/lsp/__tests__/lsp-client.test.js +238 -0
- package/dist/lsp/installer.js +65 -0
- package/dist/lsp/lifecycle-manager.js +272 -0
- package/dist/lsp/lsp-client.js +226 -0
- package/dist/mcp/__tests__/formatters.test.js +153 -0
- package/dist/mcp/__tests__/read-tools.test.js +281 -0
- package/dist/mcp/__tests__/server.test.js +140 -0
- package/dist/mcp/__tests__/write-tools.test.js +257 -0
- package/dist/mcp/formatters.js +202 -0
- package/dist/mcp/server.js +117 -0
- package/dist/mcp/tools/read-tools.js +208 -0
- package/dist/mcp/tools/shared.js +106 -0
- package/dist/mcp/tools/write-tools.js +252 -0
- package/dist/utils/__tests__/uri.test.js +21 -0
- package/dist/utils/uri.js +43 -0
- package/package.json +32 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const node_events_1 = require("node:events");
|
|
4
|
+
const node_stream_1 = require("node:stream");
|
|
5
|
+
const lsp_client_1 = require("../lsp-client");
|
|
6
|
+
jest.mock('node:child_process', () => ({
|
|
7
|
+
spawn: jest.fn()
|
|
8
|
+
}));
|
|
9
|
+
class MockChildProcess extends node_events_1.EventEmitter {
|
|
10
|
+
stdinWrites = [];
|
|
11
|
+
stdout = new node_stream_1.PassThrough();
|
|
12
|
+
stderr = new node_stream_1.PassThrough();
|
|
13
|
+
stdin = new node_stream_1.Writable({
|
|
14
|
+
write: (chunk, _encoding, callback) => {
|
|
15
|
+
this.stdinWrites.push(chunk.toString());
|
|
16
|
+
callback();
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
kill = jest.fn();
|
|
20
|
+
}
|
|
21
|
+
const SERVER = {
|
|
22
|
+
cmd: 'typescript-language-server',
|
|
23
|
+
args: ['--stdio'],
|
|
24
|
+
pkg: 'typescript-language-server',
|
|
25
|
+
mgr: 'npm'
|
|
26
|
+
};
|
|
27
|
+
describe('LspClient', () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
jest.useRealTimers();
|
|
30
|
+
jest.clearAllMocks();
|
|
31
|
+
});
|
|
32
|
+
it('starts the server, sends initialize, and becomes ready after initialize response', async () => {
|
|
33
|
+
const child = new MockChildProcess();
|
|
34
|
+
const spawn = jest.requireMock('node:child_process').spawn;
|
|
35
|
+
spawn.mockReturnValue(child);
|
|
36
|
+
const client = new lsp_client_1.LspClient(SERVER, '/workspace/project', 'debug');
|
|
37
|
+
const startPromise = client.start();
|
|
38
|
+
const initializeMessage = extractMessage(child.stdinWrites[0]);
|
|
39
|
+
expect(initializeMessage.method).toBe('initialize');
|
|
40
|
+
expect(getObject(initializeMessage.params).workspaceFolders).toEqual([
|
|
41
|
+
{ uri: 'file:///workspace/project', name: 'project' }
|
|
42
|
+
]);
|
|
43
|
+
child.stdout.write(encodeMessage({
|
|
44
|
+
jsonrpc: '2.0',
|
|
45
|
+
id: initializeMessage.id,
|
|
46
|
+
result: { capabilities: { hoverProvider: true } }
|
|
47
|
+
}));
|
|
48
|
+
await startPromise;
|
|
49
|
+
expect(client.isReady()).toBe(true);
|
|
50
|
+
const initializedMessage = extractMessage(child.stdinWrites[1]);
|
|
51
|
+
expect(initializedMessage.method).toBe('initialized');
|
|
52
|
+
expect(initializedMessage.params).toEqual({});
|
|
53
|
+
});
|
|
54
|
+
it('sends framed JSON-RPC requests and resolves matching responses', async () => {
|
|
55
|
+
const child = new MockChildProcess();
|
|
56
|
+
const spawn = jest.requireMock('node:child_process').spawn;
|
|
57
|
+
spawn.mockReturnValue(child);
|
|
58
|
+
const client = new lsp_client_1.LspClient(SERVER, '/workspace/project', 'debug');
|
|
59
|
+
await startClient(client, child);
|
|
60
|
+
const hoverPromise = client.request('textDocument/hover', {
|
|
61
|
+
textDocument: { uri: 'file:///workspace/project/src/index.ts' },
|
|
62
|
+
position: { line: 0, character: 1 }
|
|
63
|
+
}, 1000);
|
|
64
|
+
const hoverRequest = extractMessage(child.stdinWrites[2]);
|
|
65
|
+
expect(child.stdinWrites[2]).toMatch(/^Content-Length: \d+\r\n\r\n\{/);
|
|
66
|
+
expect(hoverRequest.method).toBe('textDocument/hover');
|
|
67
|
+
child.stdout.write(encodeMessage({
|
|
68
|
+
jsonrpc: '2.0',
|
|
69
|
+
id: hoverRequest.id,
|
|
70
|
+
result: { contents: 'hover text' }
|
|
71
|
+
}));
|
|
72
|
+
await expect(hoverPromise).resolves.toEqual({ contents: 'hover text' });
|
|
73
|
+
expect(client.getCapabilities()).toEqual({ hoverProvider: true });
|
|
74
|
+
});
|
|
75
|
+
it('rejects requests that exceed their timeout', async () => {
|
|
76
|
+
jest.useFakeTimers();
|
|
77
|
+
const child = new MockChildProcess();
|
|
78
|
+
const spawn = jest.requireMock('node:child_process').spawn;
|
|
79
|
+
spawn.mockReturnValue(child);
|
|
80
|
+
const client = new lsp_client_1.LspClient(SERVER, '/workspace/project', 'debug');
|
|
81
|
+
await startClient(client, child);
|
|
82
|
+
const hoverPromise = client.request('textDocument/hover', {}, 250);
|
|
83
|
+
const expectation = expect(hoverPromise).rejects.toThrow('LSP request timed out: textDocument/hover');
|
|
84
|
+
await jest.advanceTimersByTimeAsync(250);
|
|
85
|
+
await expectation;
|
|
86
|
+
});
|
|
87
|
+
it('parses multibyte JSON-RPC payloads using byte-length framing', async () => {
|
|
88
|
+
const child = new MockChildProcess();
|
|
89
|
+
const spawn = jest.requireMock('node:child_process').spawn;
|
|
90
|
+
spawn.mockReturnValue(child);
|
|
91
|
+
const client = new lsp_client_1.LspClient(SERVER, '/workspace/project', 'debug');
|
|
92
|
+
await startClient(client, child);
|
|
93
|
+
const hoverPromise = client.request('textDocument/hover', {
|
|
94
|
+
textDocument: { uri: 'file:///workspace/project/src/index.ts' },
|
|
95
|
+
position: { line: 0, character: 1 }
|
|
96
|
+
}, 1000);
|
|
97
|
+
const hoverRequest = extractMessage(child.stdinWrites[2]);
|
|
98
|
+
child.stdout.emit('data', encodeMessage({
|
|
99
|
+
jsonrpc: '2.0',
|
|
100
|
+
id: hoverRequest.id,
|
|
101
|
+
result: { contents: 'Grüße 👋' }
|
|
102
|
+
}));
|
|
103
|
+
await expect(hoverPromise).resolves.toEqual({ contents: 'Grüße 👋' });
|
|
104
|
+
});
|
|
105
|
+
it('emits crash when the server exits unexpectedly', async () => {
|
|
106
|
+
const child = new MockChildProcess();
|
|
107
|
+
const spawn = jest.requireMock('node:child_process').spawn;
|
|
108
|
+
spawn.mockReturnValue(child);
|
|
109
|
+
const client = new lsp_client_1.LspClient(SERVER, '/workspace/project', 'debug');
|
|
110
|
+
await startClient(client, child);
|
|
111
|
+
const crashPromise = onceErrorEvent(client, 'crash');
|
|
112
|
+
child.emit('exit', 9, null);
|
|
113
|
+
await expect(crashPromise).resolves.toThrow('LSP server exited unexpectedly');
|
|
114
|
+
expect(client.isReady()).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
it('rejects requests when the server returns a JSON-RPC error response', async () => {
|
|
117
|
+
const child = new MockChildProcess();
|
|
118
|
+
const spawn = jest.requireMock('node:child_process').spawn;
|
|
119
|
+
spawn.mockReturnValue(child);
|
|
120
|
+
const client = new lsp_client_1.LspClient(SERVER, '/workspace/project', 'debug');
|
|
121
|
+
await startClient(client, child);
|
|
122
|
+
const hoverPromise = client.request('textDocument/hover', {}, 1000);
|
|
123
|
+
const hoverRequest = extractMessage(child.stdinWrites[2]);
|
|
124
|
+
child.stdout.write(encodeMessage({
|
|
125
|
+
jsonrpc: '2.0',
|
|
126
|
+
id: hoverRequest.id,
|
|
127
|
+
error: { code: -32603, message: 'hover failed' }
|
|
128
|
+
}));
|
|
129
|
+
await expect(hoverPromise).rejects.toThrow('hover failed');
|
|
130
|
+
});
|
|
131
|
+
it('emits error when the child process reports an error', async () => {
|
|
132
|
+
const child = new MockChildProcess();
|
|
133
|
+
const spawn = jest.requireMock('node:child_process').spawn;
|
|
134
|
+
spawn.mockReturnValue(child);
|
|
135
|
+
const client = new lsp_client_1.LspClient(SERVER, '/workspace/project', 'debug');
|
|
136
|
+
await startClient(client, child);
|
|
137
|
+
const errorPromise = onceErrorEvent(client, 'error');
|
|
138
|
+
child.emit('error', new Error('spawn failed'));
|
|
139
|
+
await expect(errorPromise).resolves.toThrow('spawn failed');
|
|
140
|
+
});
|
|
141
|
+
it('emits error when a message arrives without a content length header', async () => {
|
|
142
|
+
const child = new MockChildProcess();
|
|
143
|
+
const spawn = jest.requireMock('node:child_process').spawn;
|
|
144
|
+
spawn.mockReturnValue(child);
|
|
145
|
+
const client = new lsp_client_1.LspClient(SERVER, '/workspace/project', 'debug');
|
|
146
|
+
await startClient(client, child);
|
|
147
|
+
const errorPromise = onceErrorEvent(client, 'error');
|
|
148
|
+
child.stdout.write('X-Test: 1\r\n\r\n{}');
|
|
149
|
+
await expect(errorPromise).resolves.toThrow('Missing Content-Length header');
|
|
150
|
+
});
|
|
151
|
+
it('shuts down gracefully and kills the process if it does not exit in time', async () => {
|
|
152
|
+
jest.useFakeTimers();
|
|
153
|
+
const child = new MockChildProcess();
|
|
154
|
+
const spawn = jest.requireMock('node:child_process').spawn;
|
|
155
|
+
spawn.mockReturnValue(child);
|
|
156
|
+
const client = new lsp_client_1.LspClient(SERVER, '/workspace/project', 'debug');
|
|
157
|
+
await startClient(client, child);
|
|
158
|
+
const shutdownPromise = client.shutdown();
|
|
159
|
+
const shutdownRequest = extractMessage(child.stdinWrites[2]);
|
|
160
|
+
expect(shutdownRequest.method).toBe('shutdown');
|
|
161
|
+
child.stdout.write(encodeMessage({
|
|
162
|
+
jsonrpc: '2.0',
|
|
163
|
+
id: shutdownRequest.id,
|
|
164
|
+
result: null
|
|
165
|
+
}));
|
|
166
|
+
await shutdownPromise;
|
|
167
|
+
expect(extractMessage(child.stdinWrites[3]).method).toBe('exit');
|
|
168
|
+
await jest.advanceTimersByTimeAsync(5000);
|
|
169
|
+
expect(child.kill).toHaveBeenCalledWith('SIGKILL');
|
|
170
|
+
});
|
|
171
|
+
it('does not emit crash after an expected shutdown exit', async () => {
|
|
172
|
+
const child = new MockChildProcess();
|
|
173
|
+
const spawn = jest.requireMock('node:child_process').spawn;
|
|
174
|
+
spawn.mockReturnValue(child);
|
|
175
|
+
const client = new lsp_client_1.LspClient(SERVER, '/workspace/project', 'debug');
|
|
176
|
+
await startClient(client, child);
|
|
177
|
+
const crashListener = jest.fn();
|
|
178
|
+
client.on('crash', crashListener);
|
|
179
|
+
const shutdownPromise = client.shutdown();
|
|
180
|
+
const shutdownRequest = extractMessage(child.stdinWrites[2]);
|
|
181
|
+
child.stdout.write(encodeMessage({ jsonrpc: '2.0', id: shutdownRequest.id, result: null }));
|
|
182
|
+
await shutdownPromise;
|
|
183
|
+
child.emit('exit', 0, null);
|
|
184
|
+
expect(crashListener).not.toHaveBeenCalled();
|
|
185
|
+
});
|
|
186
|
+
it('does not spawn a second process when start is called twice', async () => {
|
|
187
|
+
const child = new MockChildProcess();
|
|
188
|
+
const spawn = jest.requireMock('node:child_process').spawn;
|
|
189
|
+
spawn.mockReturnValue(child);
|
|
190
|
+
const client = new lsp_client_1.LspClient(SERVER, '/workspace/project', 'debug');
|
|
191
|
+
await startClient(client, child);
|
|
192
|
+
await client.start();
|
|
193
|
+
expect(spawn).toHaveBeenCalledTimes(1);
|
|
194
|
+
});
|
|
195
|
+
it('handles notifications, idle shutdown, and missing process writes safely', async () => {
|
|
196
|
+
const child = new MockChildProcess();
|
|
197
|
+
const spawn = jest.requireMock('node:child_process').spawn;
|
|
198
|
+
spawn.mockReturnValue(child);
|
|
199
|
+
const client = new lsp_client_1.LspClient(SERVER, '/workspace/project', 'debug');
|
|
200
|
+
const freshClient = new lsp_client_1.LspClient(SERVER, '/workspace/project', 'debug');
|
|
201
|
+
await expect(freshClient.shutdown()).resolves.toBeUndefined();
|
|
202
|
+
expect(() => freshClient.notify('initialized', {})).toThrow('LSP process is not running');
|
|
203
|
+
await expect(freshClient.request('textDocument/hover', {}, 1000)).rejects.toThrow('LSP process is not running');
|
|
204
|
+
await startClient(client, child);
|
|
205
|
+
const notificationPromise = new Promise((resolve) => {
|
|
206
|
+
client.once('notification', (method, params) => resolve({ method, params }));
|
|
207
|
+
});
|
|
208
|
+
child.stdout.write(encodeMessage({ jsonrpc: '2.0', method: 'textDocument/publishDiagnostics', params: { uri: 'file:///x', diagnostics: [] } }));
|
|
209
|
+
await expect(notificationPromise).resolves.toEqual({ method: 'textDocument/publishDiagnostics', params: { uri: 'file:///x', diagnostics: [] } });
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
function encodeMessage(payload) {
|
|
213
|
+
const body = JSON.stringify(payload);
|
|
214
|
+
return `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`;
|
|
215
|
+
}
|
|
216
|
+
function extractMessage(raw) {
|
|
217
|
+
const [, body = ''] = raw.split('\r\n\r\n');
|
|
218
|
+
return JSON.parse(body);
|
|
219
|
+
}
|
|
220
|
+
function getObject(value) {
|
|
221
|
+
expect(value).toBeDefined();
|
|
222
|
+
return value;
|
|
223
|
+
}
|
|
224
|
+
async function startClient(client, child) {
|
|
225
|
+
const startPromise = client.start();
|
|
226
|
+
const initializeMessage = extractMessage(child.stdinWrites[0]);
|
|
227
|
+
child.stdout.write(encodeMessage({
|
|
228
|
+
jsonrpc: '2.0',
|
|
229
|
+
id: initializeMessage.id,
|
|
230
|
+
result: { capabilities: { hoverProvider: true } }
|
|
231
|
+
}));
|
|
232
|
+
await startPromise;
|
|
233
|
+
}
|
|
234
|
+
function onceErrorEvent(emitter, eventName) {
|
|
235
|
+
return new Promise((resolve) => {
|
|
236
|
+
emitter.once(eventName, (error) => resolve(error));
|
|
237
|
+
});
|
|
238
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.installLsp = installLsp;
|
|
4
|
+
const node_child_process_1 = require("node:child_process");
|
|
5
|
+
async function installLsp(candidate) {
|
|
6
|
+
const command = getInstallCommand(candidate);
|
|
7
|
+
if (!command) {
|
|
8
|
+
return {
|
|
9
|
+
success: false,
|
|
10
|
+
error: `Automatic user-local install is not supported for ${candidate.mgr}`,
|
|
11
|
+
instructions: getManualInstructions(candidate)
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
return await new Promise((resolve) => {
|
|
15
|
+
(0, node_child_process_1.exec)(command, { env: process.env }, (error) => {
|
|
16
|
+
if (error) {
|
|
17
|
+
resolve({
|
|
18
|
+
success: false,
|
|
19
|
+
error: error.message,
|
|
20
|
+
instructions: getManualInstructions(candidate)
|
|
21
|
+
});
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
resolve({ success: true });
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
function getInstallCommand(candidate) {
|
|
29
|
+
switch (candidate.mgr) {
|
|
30
|
+
case 'npm':
|
|
31
|
+
return `npm install --global --prefix "$HOME/.local" ${candidate.pkg}`;
|
|
32
|
+
case 'pip':
|
|
33
|
+
return `pip install --user ${candidate.pkg}`;
|
|
34
|
+
case 'go':
|
|
35
|
+
return `go install ${candidate.pkg}@latest`;
|
|
36
|
+
case 'gem':
|
|
37
|
+
return `gem install --user-install ${candidate.pkg}`;
|
|
38
|
+
case 'cargo':
|
|
39
|
+
return `cargo install ${candidate.pkg}`;
|
|
40
|
+
default:
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function getManualInstructions(candidate) {
|
|
45
|
+
switch (candidate.mgr) {
|
|
46
|
+
case 'npm':
|
|
47
|
+
return `npm install -g ${candidate.pkg}`;
|
|
48
|
+
case 'pip':
|
|
49
|
+
return `pip install ${candidate.pkg}`;
|
|
50
|
+
case 'go':
|
|
51
|
+
return `go install ${candidate.pkg}@latest`;
|
|
52
|
+
case 'gem':
|
|
53
|
+
return `gem install ${candidate.pkg}`;
|
|
54
|
+
case 'cargo':
|
|
55
|
+
return `cargo install ${candidate.pkg}`;
|
|
56
|
+
case 'dotnet':
|
|
57
|
+
return `dotnet tool install -g ${candidate.pkg}`;
|
|
58
|
+
case 'apt':
|
|
59
|
+
return `sudo apt install ${candidate.pkg}`;
|
|
60
|
+
case 'brew':
|
|
61
|
+
return `brew install ${candidate.pkg}`;
|
|
62
|
+
default:
|
|
63
|
+
return `${candidate.mgr} install ${candidate.pkg}`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
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.LifecycleManager = void 0;
|
|
7
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
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
|
+
};
|
|
33
|
+
class LifecycleManager {
|
|
34
|
+
projectRoot;
|
|
35
|
+
logLevel;
|
|
36
|
+
states = new Map();
|
|
37
|
+
diagnostics = new Map();
|
|
38
|
+
constructor(projectRoot, logLevel) {
|
|
39
|
+
this.projectRoot = projectRoot;
|
|
40
|
+
this.logLevel = normalizeLogLevel(logLevel);
|
|
41
|
+
}
|
|
42
|
+
async start(languages) {
|
|
43
|
+
const startPromise = this.startInternal(languages);
|
|
44
|
+
await promiseWithTimeout(startPromise, 30000, 'Lifecycle start timed out');
|
|
45
|
+
}
|
|
46
|
+
async ensureLanguage(language) {
|
|
47
|
+
if (this.states.has(language)) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
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);
|
|
60
|
+
}
|
|
61
|
+
async ensureLanguageForFile(filePath) {
|
|
62
|
+
const language = EXTENSION_LANGUAGE_MAP[node_path_1.default.extname(filePath).toLowerCase()];
|
|
63
|
+
if (language) {
|
|
64
|
+
await this.ensureLanguage(language);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
getClient(language) {
|
|
68
|
+
return this.states.get(language)?.client ?? null;
|
|
69
|
+
}
|
|
70
|
+
getClientForFile(filePath) {
|
|
71
|
+
const language = EXTENSION_LANGUAGE_MAP[node_path_1.default.extname(filePath).toLowerCase()];
|
|
72
|
+
if (language) {
|
|
73
|
+
return this.getClient(language);
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
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
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
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] : []);
|
|
89
|
+
}
|
|
90
|
+
getFileDiagnostics(filePath) {
|
|
91
|
+
const uri = (0, uri_1.pathToUri)(filePath);
|
|
92
|
+
return (this.diagnostics.get(uri) ?? []).map((diagnostic) => ({ ...diagnostic, uri }));
|
|
93
|
+
}
|
|
94
|
+
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));
|
|
109
|
+
}
|
|
110
|
+
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')));
|
|
119
|
+
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`);
|
|
121
|
+
}
|
|
122
|
+
async startInternal(languages) {
|
|
123
|
+
const detected = languages
|
|
124
|
+
? languages.map((language) => ({ language }))
|
|
125
|
+
: await (0, language_detector_1.detectLanguages)(this.projectRoot);
|
|
126
|
+
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);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
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) => {
|
|
157
|
+
if (method === 'textDocument/publishDiagnostics') {
|
|
158
|
+
this.storeDiagnostics(params);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
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
|
+
}
|
|
239
|
+
}
|
|
240
|
+
exports.LifecycleManager = LifecycleManager;
|
|
241
|
+
function normalizeLogLevel(level) {
|
|
242
|
+
if (level === 'error' || level === 'debug') {
|
|
243
|
+
return level;
|
|
244
|
+
}
|
|
245
|
+
return 'info';
|
|
246
|
+
}
|
|
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
|
+
async function promiseWithTimeout(promise, timeoutMs, message) {
|
|
262
|
+
return await new Promise((resolve, reject) => {
|
|
263
|
+
const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
|
264
|
+
promise.then((value) => {
|
|
265
|
+
clearTimeout(timer);
|
|
266
|
+
resolve(value);
|
|
267
|
+
}, (error) => {
|
|
268
|
+
clearTimeout(timer);
|
|
269
|
+
reject(error);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
}
|