@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.
@@ -0,0 +1,226 @@
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.LspClient = void 0;
7
+ const node_events_1 = require("node:events");
8
+ const node_child_process_1 = require("node:child_process");
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ const uri_1 = require("../utils/uri");
11
+ class LspClient extends node_events_1.EventEmitter {
12
+ serverDef;
13
+ projectRoot;
14
+ logLevel;
15
+ process = null;
16
+ nextRequestId = 1;
17
+ ready = false;
18
+ pendingRequests = new Map();
19
+ buffer = Buffer.alloc(0);
20
+ contentLength = null;
21
+ exitExpected = false;
22
+ initializeResult = null;
23
+ forcedKillTimer = null;
24
+ constructor(serverDef, projectRoot, logLevel) {
25
+ super();
26
+ this.serverDef = serverDef;
27
+ this.projectRoot = projectRoot;
28
+ this.logLevel = logLevel;
29
+ }
30
+ async start() {
31
+ if (this.process) {
32
+ return;
33
+ }
34
+ this.process = (0, node_child_process_1.spawn)(this.serverDef.cmd, this.serverDef.args, {
35
+ cwd: this.projectRoot,
36
+ env: { ...process.env },
37
+ stdio: 'pipe'
38
+ });
39
+ this.process.stdout.on('data', (chunk) => {
40
+ this.handleData(chunk);
41
+ });
42
+ this.process.on('error', (error) => {
43
+ this.log('error', 'lsp_process_error', { error: error.message });
44
+ this.emit('error', error);
45
+ });
46
+ this.process.on('exit', (code, signal) => {
47
+ this.handleExit(code, signal);
48
+ });
49
+ this.log('info', 'lsp_starting', { language: this.serverDef.cmd });
50
+ const initializeResult = await this.request('initialize', {
51
+ processId: process.pid,
52
+ clientInfo: { name: 'lsp-mcp', version: '0.1.0' },
53
+ rootUri: (0, uri_1.pathToUri)(this.projectRoot),
54
+ workspaceFolders: [
55
+ {
56
+ uri: (0, uri_1.pathToUri)(this.projectRoot),
57
+ name: node_path_1.default.basename(this.projectRoot)
58
+ }
59
+ ],
60
+ capabilities: {}
61
+ }, 30000);
62
+ this.initializeResult = initializeResult;
63
+ this.notify('initialized', {});
64
+ this.ready = true;
65
+ this.log('info', 'lsp_ready', { language: this.serverDef.cmd });
66
+ }
67
+ isReady() {
68
+ return this.ready;
69
+ }
70
+ async request(method, params, timeout) {
71
+ const id = this.nextRequestId;
72
+ this.nextRequestId += 1;
73
+ const message = {
74
+ jsonrpc: '2.0',
75
+ id,
76
+ method,
77
+ params
78
+ };
79
+ return await new Promise((resolve, reject) => {
80
+ const timeoutHandle = setTimeout(() => {
81
+ this.pendingRequests.delete(id);
82
+ reject(new Error(`LSP request timed out: ${method}`));
83
+ }, timeout);
84
+ this.pendingRequests.set(id, {
85
+ resolve: (value) => resolve(value),
86
+ reject,
87
+ timeoutHandle
88
+ });
89
+ this.sendMessage(message);
90
+ });
91
+ }
92
+ notify(method, params) {
93
+ const message = {
94
+ jsonrpc: '2.0',
95
+ method,
96
+ params
97
+ };
98
+ this.sendMessage(message);
99
+ }
100
+ async shutdown() {
101
+ this.exitExpected = true;
102
+ if (!this.process) {
103
+ this.ready = false;
104
+ return;
105
+ }
106
+ await this.request('shutdown', {}, 5000);
107
+ this.notify('exit', {});
108
+ this.ready = false;
109
+ const currentProcess = this.process;
110
+ this.forcedKillTimer = setTimeout(() => {
111
+ currentProcess.kill('SIGKILL');
112
+ }, 5000);
113
+ }
114
+ getCapabilities() {
115
+ return this.initializeResult?.capabilities ?? null;
116
+ }
117
+ sendMessage(message) {
118
+ if (!this.process) {
119
+ throw new Error('LSP process is not running');
120
+ }
121
+ const body = JSON.stringify(message);
122
+ const content = `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`;
123
+ this.process.stdin.write(content);
124
+ }
125
+ handleData(chunk) {
126
+ const incoming = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk;
127
+ this.buffer = Buffer.concat([this.buffer, incoming]);
128
+ while (true) {
129
+ if (this.contentLength === null) {
130
+ const headerEnd = this.buffer.indexOf('\r\n\r\n');
131
+ if (headerEnd === -1) {
132
+ return;
133
+ }
134
+ const header = this.buffer.subarray(0, headerEnd).toString('utf8');
135
+ this.buffer = this.buffer.subarray(headerEnd + 4);
136
+ const contentLengthLine = header
137
+ .split('\r\n')
138
+ .find((line) => line.toLowerCase().startsWith('content-length:'));
139
+ if (!contentLengthLine) {
140
+ const error = new Error('Missing Content-Length header');
141
+ this.emit('error', error);
142
+ return;
143
+ }
144
+ const value = Number.parseInt(contentLengthLine.slice('Content-Length:'.length).trim(), 10);
145
+ this.contentLength = value;
146
+ }
147
+ if (this.buffer.byteLength < this.contentLength) {
148
+ return;
149
+ }
150
+ const messageBody = this.buffer.subarray(0, this.contentLength).toString('utf8');
151
+ this.buffer = this.buffer.subarray(this.contentLength);
152
+ this.contentLength = null;
153
+ this.handleMessage(JSON.parse(messageBody));
154
+ }
155
+ }
156
+ handleMessage(message) {
157
+ if (!message || typeof message !== 'object') {
158
+ return;
159
+ }
160
+ if ('method' in message) {
161
+ this.emit('notification', message.method, message.params);
162
+ return;
163
+ }
164
+ if ('id' in message && 'error' in message && message.id !== null) {
165
+ const pending = this.pendingRequests.get(message.id);
166
+ if (!pending) {
167
+ return;
168
+ }
169
+ clearTimeout(pending.timeoutHandle);
170
+ this.pendingRequests.delete(message.id);
171
+ pending.reject(new Error(message.error.message));
172
+ return;
173
+ }
174
+ if ('id' in message && 'result' in message) {
175
+ const pending = this.pendingRequests.get(message.id);
176
+ if (!pending) {
177
+ return;
178
+ }
179
+ clearTimeout(pending.timeoutHandle);
180
+ this.pendingRequests.delete(message.id);
181
+ pending.resolve(message.result);
182
+ }
183
+ }
184
+ handleExit(code, signal) {
185
+ if (this.forcedKillTimer) {
186
+ clearTimeout(this.forcedKillTimer);
187
+ this.forcedKillTimer = null;
188
+ }
189
+ this.rejectAllPending(new Error(`LSP server exited (code: ${code ?? 'null'}, signal: ${signal ?? 'null'})`));
190
+ this.ready = false;
191
+ this.process = null;
192
+ if (!this.exitExpected) {
193
+ const error = new Error(`LSP server exited unexpectedly (code: ${code ?? 'null'}, signal: ${signal ?? 'null'})`);
194
+ this.log('error', 'lsp_crash', { error: error.message, language: this.serverDef.cmd });
195
+ this.emit('crash', error);
196
+ }
197
+ }
198
+ rejectAllPending(error) {
199
+ for (const [id, pending] of this.pendingRequests.entries()) {
200
+ clearTimeout(pending.timeoutHandle);
201
+ pending.reject(error);
202
+ this.pendingRequests.delete(id);
203
+ }
204
+ }
205
+ log(level, event, extra) {
206
+ if (!shouldLog(this.logLevel, level)) {
207
+ return;
208
+ }
209
+ const payload = {
210
+ timestamp: new Date().toISOString(),
211
+ level,
212
+ event,
213
+ ...extra
214
+ };
215
+ process.stderr.write(`${JSON.stringify(payload)}\n`);
216
+ }
217
+ }
218
+ exports.LspClient = LspClient;
219
+ function shouldLog(configuredLevel, messageLevel) {
220
+ const order = {
221
+ error: 0,
222
+ info: 1,
223
+ debug: 2
224
+ };
225
+ return order[messageLevel] <= order[configuredLevel];
226
+ }
@@ -0,0 +1,153 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const formatters_1 = require("../formatters");
4
+ describe('mcp formatters', () => {
5
+ it('formats hover results as markdown summary plus code block', () => {
6
+ expect((0, formatters_1.formatHover)({
7
+ contents: {
8
+ kind: 'markdown',
9
+ value: '```ts\ntype Foo = string\n```\n\nFoo description'
10
+ }
11
+ })).toBe('**type Foo = string** — Foo description\n\n```ts\ntype Foo = string\n```');
12
+ });
13
+ it('formats definition locations with file coordinates', () => {
14
+ expect((0, formatters_1.formatDefinition)([
15
+ {
16
+ uri: 'file:///workspace/src/index.ts',
17
+ range: {
18
+ start: { line: 41, character: 4 },
19
+ end: { line: 41, character: 7 }
20
+ }
21
+ }
22
+ ])).toBe('Found 1 definition: `/workspace/src/index.ts:42:5`');
23
+ });
24
+ it('formats references as a bulleted list', () => {
25
+ expect((0, formatters_1.formatReferences)([
26
+ {
27
+ uri: 'file:///workspace/src/index.ts',
28
+ range: {
29
+ start: { line: 0, character: 0 },
30
+ end: { line: 0, character: 1 }
31
+ }
32
+ },
33
+ {
34
+ uri: 'file:///workspace/src/lib.ts',
35
+ range: {
36
+ start: { line: 2, character: 3 },
37
+ end: { line: 2, character: 4 }
38
+ }
39
+ }
40
+ ])).toBe('Found 2 references:\n- `/workspace/src/index.ts:1:1`\n- `/workspace/src/lib.ts:3:4`');
41
+ });
42
+ it('formats symbols with kind icons', () => {
43
+ expect((0, formatters_1.formatSymbols)([
44
+ {
45
+ name: 'UserService',
46
+ kind: 5,
47
+ location: {
48
+ uri: 'file:///workspace/src/user-service.ts',
49
+ range: {
50
+ start: { line: 4, character: 0 },
51
+ end: { line: 10, character: 0 }
52
+ }
53
+ }
54
+ },
55
+ {
56
+ name: 'login',
57
+ kind: 12,
58
+ location: {
59
+ uri: 'file:///workspace/src/user-service.ts',
60
+ range: {
61
+ start: { line: 6, character: 2 },
62
+ end: { line: 8, character: 0 }
63
+ }
64
+ }
65
+ }
66
+ ])).toContain('- 📦 `UserService` — /workspace/src/user-service.ts:5:1');
67
+ });
68
+ it('formats diagnostics grouped by severity with errors first', () => {
69
+ expect((0, formatters_1.formatDiagnostics)([
70
+ {
71
+ message: 'Cannot find name Foo',
72
+ severity: 1,
73
+ source: 'ts',
74
+ range: {
75
+ start: { line: 1, character: 0 },
76
+ end: { line: 1, character: 3 }
77
+ },
78
+ uri: 'file:///workspace/src/index.ts'
79
+ },
80
+ {
81
+ message: 'Unused variable',
82
+ severity: 2,
83
+ source: 'ts',
84
+ range: {
85
+ start: { line: 3, character: 2 },
86
+ end: { line: 3, character: 5 }
87
+ },
88
+ uri: 'file:///workspace/src/index.ts'
89
+ }
90
+ ], 'workspace')).toBe([
91
+ 'Workspace diagnostics: 2 issue(s)',
92
+ '',
93
+ '### Errors',
94
+ '- `/workspace/src/index.ts:2:1` ts: Cannot find name Foo',
95
+ '',
96
+ '### Warnings',
97
+ '- `/workspace/src/index.ts:4:3` ts: Unused variable'
98
+ ].join('\n'));
99
+ });
100
+ it('formats completion items grouped by kind and limited to 50', () => {
101
+ const items = Array.from({ length: 55 }, (_, index) => ({
102
+ label: `item-${index}`,
103
+ kind: index < 30 ? 3 : 2,
104
+ detail: index === 0 ? 'string' : undefined
105
+ }));
106
+ const text = (0, formatters_1.formatCompletion)(items);
107
+ expect(text).toContain('Showing 50 of 55 completion item(s)');
108
+ expect(text).toContain('### Functions');
109
+ expect(text).toContain('- `item-0` — string');
110
+ expect(text).toContain('### Methods');
111
+ expect(text).not.toContain('item-54');
112
+ });
113
+ it('formats health rows as a markdown table', () => {
114
+ expect((0, formatters_1.formatHealth)([
115
+ { language: 'typescript', status: 'ready' },
116
+ { language: 'python', status: 'error', error: 'spawn failed' }
117
+ ])).toBe([
118
+ '| Language | Status | Error |',
119
+ '| --- | --- | --- |',
120
+ '| typescript | ready | |',
121
+ '| python | error | spawn failed |'
122
+ ].join('\n'));
123
+ });
124
+ it('formats errors with raw payload passthrough', () => {
125
+ expect((0, formatters_1.formatError)(new Error('boom'))).toEqual({
126
+ error: true,
127
+ text: 'boom',
128
+ raw: expect.any(Error)
129
+ });
130
+ });
131
+ it('returns no result for empty formatter inputs', () => {
132
+ expect((0, formatters_1.formatHover)(null)).toBe('No result');
133
+ expect((0, formatters_1.formatDefinition)(null)).toBe('No result');
134
+ expect((0, formatters_1.formatReferences)([])).toBe('No result');
135
+ expect((0, formatters_1.formatSymbols)(null)).toBe('No result');
136
+ expect((0, formatters_1.formatDiagnostics)([], 'file')).toBe('No result');
137
+ expect((0, formatters_1.formatCompletion)(null)).toBe('No result');
138
+ expect((0, formatters_1.formatHealth)([])).toBe('No result');
139
+ });
140
+ it('formats multi-definition and string hover fallbacks', () => {
141
+ expect((0, formatters_1.formatHover)({ contents: 'Plain hover text' })).toBe('Plain hover text');
142
+ expect((0, formatters_1.formatDefinition)([
143
+ {
144
+ uri: 'file:///workspace/src/a.ts',
145
+ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }
146
+ },
147
+ {
148
+ uri: 'file:///workspace/src/b.ts',
149
+ range: { start: { line: 1, character: 1 }, end: { line: 1, character: 2 } }
150
+ }
151
+ ])).toContain('Found definitions:');
152
+ });
153
+ });
@@ -0,0 +1,281 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const promises_1 = require("node:fs/promises");
4
+ const read_tools_1 = require("../tools/read-tools");
5
+ const vscode_languageserver_protocol_1 = require("vscode-languageserver-protocol");
6
+ jest.mock('node:fs/promises', () => ({
7
+ readFile: jest.fn(),
8
+ stat: jest.fn()
9
+ }));
10
+ class FakeRegistrar {
11
+ tools = new Map();
12
+ registerTool(name, config, handler) {
13
+ this.tools.set(name, { description: config.description, handler });
14
+ }
15
+ }
16
+ describe('registerReadTools', () => {
17
+ beforeEach(() => {
18
+ jest.clearAllMocks();
19
+ promises_1.readFile.mockResolvedValue('const foo = 1;');
20
+ promises_1.stat.mockResolvedValue({ isDirectory: () => true });
21
+ });
22
+ it('initializes LSP with a valid root and reports health', async () => {
23
+ const registrar = new FakeRegistrar();
24
+ const initializeManager = jest.fn().mockResolvedValue({
25
+ root: '/workspace',
26
+ health: [
27
+ { language: 'typescript', status: 'ready' },
28
+ { language: 'python', status: 'error', error: 'missing pylsp' }
29
+ ]
30
+ });
31
+ (0, read_tools_1.registerReadTools)(registrar, createLifecycle({ fileClient: null }), { initializeManager });
32
+ await expect(getHandler(registrar, 'lsp_init')({ root: '/workspace' })).resolves.toEqual({
33
+ content: [{ type: 'text', text: 'Initialized LSP for /workspace. Detected languages: Typescript, Python. LSP servers: 1 started, 1 errors.' }],
34
+ text: 'Initialized LSP for /workspace. Detected languages: Typescript, Python. LSP servers: 1 started, 1 errors.',
35
+ raw: {
36
+ root: '/workspace',
37
+ languages: ['Typescript', 'Python'],
38
+ health: [
39
+ { language: 'typescript', status: 'ready' },
40
+ { language: 'python', status: 'error', error: 'missing pylsp' }
41
+ ]
42
+ }
43
+ });
44
+ expect(initializeManager).toHaveBeenCalledWith('/workspace', undefined);
45
+ });
46
+ it('rejects invalid lsp_init roots clearly', async () => {
47
+ const registrar = new FakeRegistrar();
48
+ const initializeManager = jest.fn();
49
+ promises_1.stat.mockRejectedValueOnce(new Error('ENOENT'));
50
+ (0, read_tools_1.registerReadTools)(registrar, createLifecycle({ fileClient: null }), { initializeManager });
51
+ await expect(getHandler(registrar, 'lsp_init')({ root: '/missing/project' })).resolves.toEqual({
52
+ content: [{ type: 'text', text: 'Project root does not exist: /missing/project' }],
53
+ error: true,
54
+ raw: null
55
+ });
56
+ expect(initializeManager).not.toHaveBeenCalled();
57
+ });
58
+ it('rejects missing, relative, and non-directory lsp_init roots', async () => {
59
+ const registrar = new FakeRegistrar();
60
+ const initializeManager = jest.fn();
61
+ (0, read_tools_1.registerReadTools)(registrar, createLifecycle({ fileClient: null }), { initializeManager });
62
+ await expect(getHandler(registrar, 'lsp_init')({})).resolves.toEqual({
63
+ content: [{ type: 'text', text: 'Project root is required. Provide lsp_init({ root: \'/absolute/path\' }).' }],
64
+ error: true,
65
+ raw: null
66
+ });
67
+ await expect(getHandler(registrar, 'lsp_init')({ root: 'relative/path' })).resolves.toEqual({
68
+ content: [{ type: 'text', text: 'Project root must be an absolute path: relative/path' }],
69
+ error: true,
70
+ raw: null
71
+ });
72
+ promises_1.stat.mockResolvedValueOnce({ isDirectory: () => false });
73
+ await expect(getHandler(registrar, 'lsp_init')({ root: '/workspace/file.ts' })).resolves.toEqual({
74
+ content: [{ type: 'text', text: 'Project root is not a directory: /workspace/file.ts' }],
75
+ error: true,
76
+ raw: null
77
+ });
78
+ expect(initializeManager).not.toHaveBeenCalled();
79
+ });
80
+ it('maps lsp_init startup failures into tool errors', async () => {
81
+ const registrar = new FakeRegistrar();
82
+ const initializeManager = jest.fn().mockRejectedValue(new Error('Lifecycle start timed out'));
83
+ (0, read_tools_1.registerReadTools)(registrar, createLifecycle({ fileClient: null }), { initializeManager });
84
+ await expect(getHandler(registrar, 'lsp_init')({ root: '/workspace' })).resolves.toEqual({
85
+ content: [{ type: 'text', text: 'Operation timed out after 30s — try a more specific query or check the LSP server health' }],
86
+ error: true,
87
+ raw: null
88
+ });
89
+ });
90
+ it('sends didOpen once and returns formatted hover results', async () => {
91
+ const registrar = new FakeRegistrar();
92
+ const client = createClient({ contents: 'hover docs' });
93
+ const lifecycle = createLifecycle({ fileClient: client });
94
+ (0, read_tools_1.registerReadTools)(registrar, lifecycle, { initializeManager: jest.fn() });
95
+ const hover = await getHandler(registrar, 'lsp_hover')({ file: '/workspace/src/index.ts', line: 2, character: 4 });
96
+ await getHandler(registrar, 'lsp_hover')({ file: '/workspace/src/index.ts', line: 2, character: 4 });
97
+ expect(promises_1.readFile).toHaveBeenCalledTimes(1);
98
+ expect(client.notify).toHaveBeenCalledWith('textDocument/didOpen', expect.objectContaining({
99
+ textDocument: expect.objectContaining({ uri: 'file:///workspace/src/index.ts', text: 'const foo = 1;' })
100
+ }));
101
+ expect(client.request).toHaveBeenCalledWith('textDocument/hover', {
102
+ textDocument: { uri: 'file:///workspace/src/index.ts' },
103
+ position: { line: 2, character: 4 }
104
+ }, 5000);
105
+ expect(hover).toEqual({
106
+ content: [{ type: 'text', text: 'hover docs' }],
107
+ raw: { contents: 'hover docs' }
108
+ });
109
+ });
110
+ it('formats definitions and converts URIs back to paths', async () => {
111
+ const registrar = new FakeRegistrar();
112
+ const lifecycle = createLifecycle({
113
+ fileClient: createClient([
114
+ {
115
+ uri: 'file:///workspace/src/defs.ts',
116
+ range: { start: { line: 3, character: 1 }, end: { line: 3, character: 2 } }
117
+ }
118
+ ])
119
+ });
120
+ (0, read_tools_1.registerReadTools)(registrar, lifecycle, { initializeManager: jest.fn() });
121
+ await expect(getHandler(registrar, 'lsp_definition')({ file: '/workspace/src/index.ts', line: 1, character: 1 })).resolves.toEqual({
122
+ content: [{ type: 'text', text: 'Found 1 definition: `/workspace/src/defs.ts:4:2`' }],
123
+ raw: [
124
+ {
125
+ path: '/workspace/src/defs.ts',
126
+ range: { start: { line: 3, character: 1 }, end: { line: 3, character: 2 } }
127
+ }
128
+ ]
129
+ });
130
+ });
131
+ it('uses the declaration flag when requesting references', async () => {
132
+ const registrar = new FakeRegistrar();
133
+ const client = createClient([]);
134
+ (0, read_tools_1.registerReadTools)(registrar, createLifecycle({ fileClient: client }), { initializeManager: jest.fn() });
135
+ await getHandler(registrar, 'lsp_references')({ file: '/workspace/src/index.ts', line: 0, character: 0, includeDeclaration: true });
136
+ expect(client.request).toHaveBeenCalledWith('textDocument/references', {
137
+ textDocument: { uri: 'file:///workspace/src/index.ts' },
138
+ position: { line: 0, character: 0 },
139
+ context: { includeDeclaration: true }
140
+ }, 15000);
141
+ });
142
+ it('merges workspace symbols across ready clients', async () => {
143
+ const registrar = new FakeRegistrar();
144
+ const firstClient = createClient([{ name: 'UserService', kind: 5, location: { uri: 'file:///workspace/src/user.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 4 } } } }]);
145
+ const secondClient = createClient([{ name: 'login', kind: 12, location: { uri: 'file:///workspace/src/auth.ts', range: { start: { line: 1, character: 0 }, end: { line: 1, character: 3 } } } }]);
146
+ (0, read_tools_1.registerReadTools)(registrar, createLifecycle({ workspaceClients: [firstClient, secondClient] }), { initializeManager: jest.fn() });
147
+ const result = await getHandler(registrar, 'lsp_workspace_symbols')({ query: 'log' });
148
+ expect(firstClient.request).toHaveBeenCalledWith('workspace/symbol', { query: 'log' }, 30000);
149
+ expect(secondClient.request).toHaveBeenCalledWith('workspace/symbol', { query: 'log' }, 30000);
150
+ expect(result).toEqual({
151
+ content: [{ type: 'text', text: expect.stringContaining('UserService') }],
152
+ raw: [
153
+ { name: 'UserService', kind: 5, path: '/workspace/src/user.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 4 } } },
154
+ { name: 'login', kind: 12, path: '/workspace/src/auth.ts', range: { start: { line: 1, character: 0 }, end: { line: 1, character: 3 } } }
155
+ ]
156
+ });
157
+ });
158
+ it('returns cached file diagnostics and aggregates workspace diagnostics', async () => {
159
+ const registrar = new FakeRegistrar();
160
+ const diagnostics = [{ uri: 'file:///workspace/src/index.ts', message: 'Boom', severity: vscode_languageserver_protocol_1.DiagnosticSeverity.Error, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }];
161
+ (0, read_tools_1.registerReadTools)(registrar, createLifecycle({ diagnostics, workspaceClients: [createClient([])] }), { initializeManager: jest.fn() });
162
+ await expect(getHandler(registrar, 'lsp_diagnostics')({ file: '/workspace/src/index.ts' })).resolves.toEqual({
163
+ content: [{ type: 'text', text: expect.stringContaining('File diagnostics: 1 issue(s)') }],
164
+ raw: diagnostics
165
+ });
166
+ await expect(getHandler(registrar, 'lsp_diagnostics')({ scope: 'workspace' })).resolves.toEqual({
167
+ content: [{ type: 'text', text: expect.stringContaining('Workspace diagnostics: 1 issue(s)') }],
168
+ raw: diagnostics
169
+ });
170
+ });
171
+ it('returns health instantly without LSP requests', async () => {
172
+ const registrar = new FakeRegistrar();
173
+ const lifecycle = createLifecycle({ health: [{ language: 'typescript', status: 'ready' }] });
174
+ (0, read_tools_1.registerReadTools)(registrar, lifecycle, { initializeManager: jest.fn() });
175
+ await expect(getHandler(registrar, 'lsp_health')({})).resolves.toEqual({
176
+ content: [{ type: 'text', text: '| Language | Status | Error |\n| --- | --- | --- |\n| typescript | ready | |' }],
177
+ raw: [{ language: 'typescript', status: 'ready' }]
178
+ });
179
+ });
180
+ it('supports document symbols, completion lists, and signature help fallbacks', async () => {
181
+ const registrar = new FakeRegistrar();
182
+ const client = createClient({ items: [{ label: 'x', kind: 3 }] });
183
+ (0, read_tools_1.registerReadTools)(registrar, createLifecycle({ fileClient: client }), { initializeManager: jest.fn() });
184
+ await expect(getHandler(registrar, 'lsp_completion')({ file: '/workspace/src/index.ts', line: 0, character: 0 })).resolves.toEqual({
185
+ content: [{ type: 'text', text: 'Showing 1 of 1 completion item(s)\n\n### Functions\n- `x`' }],
186
+ raw: [{ label: 'x', kind: 3 }]
187
+ });
188
+ client.request.mockResolvedValueOnce([{ name: 'DocSymbol', kind: 5, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }, selectionRange: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }]);
189
+ await expect(getHandler(registrar, 'lsp_document_symbols')({ file: '/workspace/src/index.ts' })).resolves.toEqual({
190
+ content: [{ type: 'text', text: '- 📦 `DocSymbol`' }],
191
+ raw: [{ name: 'DocSymbol', kind: 5, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }, selectionRange: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }]
192
+ });
193
+ client.request.mockResolvedValueOnce(null);
194
+ await expect(getHandler(registrar, 'lsp_signature_help')({ file: '/workspace/src/index.ts', line: 0, character: 0 })).resolves.toEqual({
195
+ content: [{ type: 'text', text: 'No result' }],
196
+ raw: null
197
+ });
198
+ });
199
+ it('supports type and implementation lookups plus empty completion results', async () => {
200
+ const registrar = new FakeRegistrar();
201
+ const client = createClient({
202
+ uri: 'file:///workspace/src/types.ts',
203
+ range: { start: { line: 1, character: 2 }, end: { line: 1, character: 6 } }
204
+ });
205
+ (0, read_tools_1.registerReadTools)(registrar, createLifecycle({ fileClient: client }), { initializeManager: jest.fn() });
206
+ await expect(getHandler(registrar, 'lsp_type_definition')({ file: '/workspace/src/index.ts', line: 0, character: 0 })).resolves.toEqual({
207
+ content: [{ type: 'text', text: 'Found 1 definition: `/workspace/src/types.ts:2:3`' }],
208
+ raw: [{ path: '/workspace/src/types.ts', range: { start: { line: 1, character: 2 }, end: { line: 1, character: 6 } } }]
209
+ });
210
+ client.request.mockResolvedValueOnce(null);
211
+ await expect(getHandler(registrar, 'lsp_implementation')({ file: '/workspace/src/index.ts', line: 0, character: 0 })).resolves.toEqual({
212
+ content: [{ type: 'text', text: 'No result' }],
213
+ raw: null
214
+ });
215
+ client.request.mockResolvedValueOnce(null);
216
+ await expect(getHandler(registrar, 'lsp_completion')({ file: '/workspace/src/index.ts', line: 0, character: 0 })).resolves.toEqual({
217
+ content: [{ type: 'text', text: 'No result' }],
218
+ raw: null
219
+ });
220
+ client.request.mockResolvedValueOnce({ signatures: [{ label: 'fn(x: string)' }] });
221
+ await expect(getHandler(registrar, 'lsp_signature_help')({ file: '/workspace/src/index.ts', line: 0, character: 0 })).resolves.toEqual({
222
+ content: [{ type: 'text', text: JSON.stringify({ signatures: [{ label: 'fn(x: string)' }] }, null, 2) }],
223
+ raw: { signatures: [{ label: 'fn(x: string)' }] }
224
+ });
225
+ });
226
+ it('turns LSP timeouts into retry guidance', async () => {
227
+ const registrar = new FakeRegistrar();
228
+ const client = createClient(new Error('LSP request timed out: textDocument/hover'));
229
+ (0, read_tools_1.registerReadTools)(registrar, createLifecycle({ fileClient: client }), { initializeManager: jest.fn() });
230
+ await expect(getHandler(registrar, 'lsp_hover')({ file: '/workspace/src/index.ts', line: 0, character: 0 })).resolves.toEqual({
231
+ content: [{ type: 'text', text: 'Operation timed out after 5s — try a more specific query or check the LSP server health' }],
232
+ error: true,
233
+ raw: null
234
+ });
235
+ });
236
+ it('returns a no-server error when no language server matches the file', async () => {
237
+ const registrar = new FakeRegistrar();
238
+ (0, read_tools_1.registerReadTools)(registrar, createLifecycle({ fileClient: null }), { initializeManager: jest.fn() });
239
+ await expect(getHandler(registrar, 'lsp_hover')({ file: '/workspace/README.md', line: 0, character: 0 })).resolves.toEqual({
240
+ content: [{ type: 'text', text: 'No language server available for .md files. Run lsp_health for details.' }],
241
+ error: true,
242
+ raw: null
243
+ });
244
+ });
245
+ it('returns restart guidance when the LSP crashed', async () => {
246
+ const registrar = new FakeRegistrar();
247
+ const client = createClient(new Error('LSP server exited unexpectedly (code: 1, signal: null)'));
248
+ (0, read_tools_1.registerReadTools)(registrar, createLifecycle({ fileClient: client }), { initializeManager: jest.fn() });
249
+ await expect(getHandler(registrar, 'lsp_hover')({ file: '/workspace/src/index.ts', line: 0, character: 0 })).resolves.toEqual({
250
+ content: [{ type: 'text', text: 'Der Language Server ist neu gestartet, bitte versuche es erneut.' }],
251
+ error: true,
252
+ raw: null
253
+ });
254
+ });
255
+ });
256
+ function getHandler(registrar, name) {
257
+ const tool = registrar.tools.get(name);
258
+ if (!tool) {
259
+ throw new Error(`Missing tool ${name}`);
260
+ }
261
+ return tool.handler;
262
+ }
263
+ function createLifecycle(options) {
264
+ return {
265
+ getClientForFile: jest.fn((_) => options.fileClient ?? null),
266
+ getReadyClients: jest.fn((_) => options.workspaceClients ?? []),
267
+ getFileDiagnostics: jest.fn((_) => (options.diagnostics ?? []).filter((diagnostic) => diagnostic.uri === 'file:///workspace/src/index.ts')),
268
+ getWorkspaceDiagnostics: jest.fn((_) => options.diagnostics ?? []),
269
+ getHealth: jest.fn(() => options.health ?? []),
270
+ ensureLanguageForFile: jest.fn().mockResolvedValue(undefined)
271
+ };
272
+ }
273
+ function createClient(result) {
274
+ return {
275
+ request: result instanceof Error
276
+ ? jest.fn().mockRejectedValue(result)
277
+ : jest.fn().mockResolvedValue(result),
278
+ notify: jest.fn(),
279
+ getCapabilities: jest.fn(() => ({ renameProvider: true }))
280
+ };
281
+ }