@theupsider/lsp-mcp 1.0.7 → 1.0.9
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/README.md +2 -6
- package/dist/mcp/__tests__/read-tools.test.js +37 -50
- package/dist/mcp/__tests__/server.test.js +212 -71
- package/dist/mcp/__tests__/write-tools.test.js +1 -51
- package/dist/mcp/server.js +80 -5
- package/dist/mcp/tools/read-tools.js +0 -42
- package/dist/mcp/tools/write-tools.js +0 -13
- package/dist/utils/workspace-config.js +62 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ A Model Context Protocol (MCP) server that gives language models access to **Lan
|
|
|
32
32
|
|
|
33
33
|
- **🔍 Automatic Language Detection** — Scans project root for language markers (`package.json`, `Cargo.toml`, `go.mod`, etc.)
|
|
34
34
|
- **🔄 Auto Language Server Selection** — Hardcoded mapping with fallback servers; installs missing LSPs automatically
|
|
35
|
-
- **🛠
|
|
35
|
+
- **🛠 12 LSP Tools** — Definition, references, symbols, diagnostics, rename, code actions, formatting, and more
|
|
36
36
|
- **📝 Read & Write Operations** — Both inspection and modification of code via LSP
|
|
37
37
|
- **🌐 Polyglot Support** — Multiple language servers run simultaneously in the same project
|
|
38
38
|
- **📋 Hybrid Responses** — Human-readable `text` field + raw LSP data in `raw` field
|
|
@@ -103,14 +103,11 @@ lsp_init({ root: "/path/to/project", languages: ["python", "typescript"] })
|
|
|
103
103
|
| Tool | Description | Key Parameters |
|
|
104
104
|
| ----------------------- | --------------------------------------- | ------------------------------------------------------ |
|
|
105
105
|
| `lsp_init` | Initialize server for a project root | `root` (required), `languages` (optional string array) |
|
|
106
|
-
| `lsp_hover` | Show type info / documentation | `file`, `line`, `character` |
|
|
107
106
|
| `lsp_definition` | Go to definition | `file`, `line`, `character` |
|
|
108
107
|
| `lsp_references` | Find all references | `file`, `line`, `character` |
|
|
109
108
|
| `lsp_document_symbols` | List symbols in a file | `file` |
|
|
110
109
|
| `lsp_workspace_symbols` | Search symbols across workspace | `query` (limit: 100–500 results) |
|
|
111
|
-
| `lsp_completion` | Code completion suggestions | `file`, `line`, `character` |
|
|
112
110
|
| `lsp_diagnostics` | Get errors & warnings | `file` (scope: `file` or `workspace`) |
|
|
113
|
-
| `lsp_signature_help` | Function signature help | `file`, `line`, `character` |
|
|
114
111
|
| `lsp_type_definition` | Go to type definition | `file`, `line`, `character` |
|
|
115
112
|
| `lsp_implementation` | Find implementations | `file`, `line`, `character` |
|
|
116
113
|
| `lsp_health` | Check status of all LSP servers | _(none)_ |
|
|
@@ -123,7 +120,6 @@ lsp_init({ root: "/path/to/project", languages: ["python", "typescript"] })
|
|
|
123
120
|
| `lsp_code_action` | Apply / list code actions | `file`, `line`, `character`, `apply` |
|
|
124
121
|
| `lsp_formatting` | Format document | `file` |
|
|
125
122
|
| `lsp_range_formatting` | Format code range | `file`, `range` |
|
|
126
|
-
| `lsp_apply_workspace_edit` | Apply raw workspace edit | `edit` (WorkspaceEdit object) |
|
|
127
123
|
|
|
128
124
|
## Configuration
|
|
129
125
|
|
|
@@ -190,7 +186,7 @@ Each language server runs as a separate process. For large projects with many la
|
|
|
190
186
|
┌─────────────────────────────────────────────┐
|
|
191
187
|
│ LSP MCP Server (Node.js) │
|
|
192
188
|
│ ┌───────────┐ ┌──────────┐ ┌───────────┐ │
|
|
193
|
-
│ │
|
|
189
|
+
│ │ lsp_init │ │ lsp_... │ │ lsp_... │ │
|
|
194
190
|
│ └─────┬─────┘ └────┬─────┘ └─────┬─────┘ │
|
|
195
191
|
│ └─────────────┼─────────────┘ │
|
|
196
192
|
│ ▼ │
|
|
@@ -126,22 +126,41 @@ describe("registerReadTools", () => {
|
|
|
126
126
|
raw: null,
|
|
127
127
|
});
|
|
128
128
|
});
|
|
129
|
-
it("sends didOpen
|
|
129
|
+
it("sends didOpen and returns formatted definition results", async () => {
|
|
130
130
|
const registrar = new FakeRegistrar();
|
|
131
|
-
const client = createClient({
|
|
131
|
+
const client = createClient({
|
|
132
|
+
uri: "file:///workspace/src/defs.ts",
|
|
133
|
+
range: {
|
|
134
|
+
start: { line: 3, character: 1 },
|
|
135
|
+
end: { line: 3, character: 2 },
|
|
136
|
+
},
|
|
137
|
+
});
|
|
132
138
|
const lifecycle = createLifecycle({ fileClient: client });
|
|
133
139
|
(0, read_tools_1.registerReadTools)(registrar, lifecycle, { initializeManager: jest.fn() });
|
|
134
|
-
const
|
|
135
|
-
await getHandler(registrar, "
|
|
140
|
+
const definition = await getHandler(registrar, "lsp_definition")({ file: "/workspace/src/index.ts", line: 2, character: 4 });
|
|
141
|
+
await getHandler(registrar, "lsp_definition")({ file: "/workspace/src/index.ts", line: 2, character: 4 });
|
|
136
142
|
expect(client.ensureDidOpen).toHaveBeenCalledTimes(2);
|
|
137
143
|
expect(client.ensureDidOpen).toHaveBeenCalledWith("/workspace/src/index.ts");
|
|
138
|
-
expect(client.request).toHaveBeenCalledWith("textDocument/
|
|
144
|
+
expect(client.request).toHaveBeenCalledWith("textDocument/definition", {
|
|
139
145
|
textDocument: { uri: "file:///workspace/src/index.ts" },
|
|
140
146
|
position: { line: 2, character: 4 },
|
|
141
147
|
}, 5000);
|
|
142
|
-
expect(
|
|
143
|
-
content: [
|
|
144
|
-
|
|
148
|
+
expect(definition).toEqual({
|
|
149
|
+
content: [
|
|
150
|
+
{
|
|
151
|
+
type: "text",
|
|
152
|
+
text: "Found 1 definition: `/workspace/src/defs.ts:4:2`",
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
raw: [
|
|
156
|
+
{
|
|
157
|
+
path: "/workspace/src/defs.ts",
|
|
158
|
+
range: {
|
|
159
|
+
start: { line: 3, character: 1 },
|
|
160
|
+
end: { line: 3, character: 2 },
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
],
|
|
145
164
|
});
|
|
146
165
|
});
|
|
147
166
|
it("formats definitions and converts URIs back to paths", async () => {
|
|
@@ -303,22 +322,9 @@ describe("registerReadTools", () => {
|
|
|
303
322
|
raw: [{ language: "typescript", status: "ready" }],
|
|
304
323
|
});
|
|
305
324
|
});
|
|
306
|
-
it("supports document symbols
|
|
325
|
+
it("supports document symbols", async () => {
|
|
307
326
|
const registrar = new FakeRegistrar();
|
|
308
|
-
const client = createClient(
|
|
309
|
-
(0, read_tools_1.registerReadTools)(registrar, createLifecycle({ fileClient: client }), {
|
|
310
|
-
initializeManager: jest.fn(),
|
|
311
|
-
});
|
|
312
|
-
await expect(getHandler(registrar, "lsp_completion")({ file: "/workspace/src/index.ts", line: 0, character: 0 })).resolves.toEqual({
|
|
313
|
-
content: [
|
|
314
|
-
{
|
|
315
|
-
type: "text",
|
|
316
|
-
text: "Showing 1 of 1 completion item(s)\n\n### Functions\n- `x`",
|
|
317
|
-
},
|
|
318
|
-
],
|
|
319
|
-
raw: [{ label: "x", kind: 3 }],
|
|
320
|
-
});
|
|
321
|
-
client.request.mockResolvedValueOnce([
|
|
327
|
+
const client = createClient([
|
|
322
328
|
{
|
|
323
329
|
name: "DocSymbol",
|
|
324
330
|
kind: 5,
|
|
@@ -332,6 +338,9 @@ describe("registerReadTools", () => {
|
|
|
332
338
|
},
|
|
333
339
|
},
|
|
334
340
|
]);
|
|
341
|
+
(0, read_tools_1.registerReadTools)(registrar, createLifecycle({ fileClient: client }), {
|
|
342
|
+
initializeManager: jest.fn(),
|
|
343
|
+
});
|
|
335
344
|
await expect(getHandler(registrar, "lsp_document_symbols")({ file: "/workspace/src/index.ts" })).resolves.toEqual({
|
|
336
345
|
content: [{ type: "text", text: "- 📦 `DocSymbol`" }],
|
|
337
346
|
raw: [
|
|
@@ -349,13 +358,8 @@ describe("registerReadTools", () => {
|
|
|
349
358
|
},
|
|
350
359
|
],
|
|
351
360
|
});
|
|
352
|
-
client.request.mockResolvedValueOnce(null);
|
|
353
|
-
await expect(getHandler(registrar, "lsp_signature_help")({ file: "/workspace/src/index.ts", line: 0, character: 0 })).resolves.toEqual({
|
|
354
|
-
content: [{ type: "text", text: "No result" }],
|
|
355
|
-
raw: null,
|
|
356
|
-
});
|
|
357
361
|
});
|
|
358
|
-
it("supports type and implementation lookups
|
|
362
|
+
it("supports type and implementation lookups", async () => {
|
|
359
363
|
const registrar = new FakeRegistrar();
|
|
360
364
|
const client = createClient({
|
|
361
365
|
uri: "file:///workspace/src/types.ts",
|
|
@@ -389,31 +393,14 @@ describe("registerReadTools", () => {
|
|
|
389
393
|
content: [{ type: "text", text: "No result" }],
|
|
390
394
|
raw: null,
|
|
391
395
|
});
|
|
392
|
-
client.request.mockResolvedValueOnce(null);
|
|
393
|
-
await expect(getHandler(registrar, "lsp_completion")({ file: "/workspace/src/index.ts", line: 0, character: 0 })).resolves.toEqual({
|
|
394
|
-
content: [{ type: "text", text: "No result" }],
|
|
395
|
-
raw: null,
|
|
396
|
-
});
|
|
397
|
-
client.request.mockResolvedValueOnce({
|
|
398
|
-
signatures: [{ label: "fn(x: string)" }],
|
|
399
|
-
});
|
|
400
|
-
await expect(getHandler(registrar, "lsp_signature_help")({ file: "/workspace/src/index.ts", line: 0, character: 0 })).resolves.toEqual({
|
|
401
|
-
content: [
|
|
402
|
-
{
|
|
403
|
-
type: "text",
|
|
404
|
-
text: JSON.stringify({ signatures: [{ label: "fn(x: string)" }] }, null, 2),
|
|
405
|
-
},
|
|
406
|
-
],
|
|
407
|
-
raw: { signatures: [{ label: "fn(x: string)" }] },
|
|
408
|
-
});
|
|
409
396
|
});
|
|
410
397
|
it("turns LSP timeouts into retry guidance", async () => {
|
|
411
398
|
const registrar = new FakeRegistrar();
|
|
412
|
-
const client = createClient(new Error("LSP request timed out: textDocument/
|
|
399
|
+
const client = createClient(new Error("LSP request timed out: textDocument/definition"));
|
|
413
400
|
(0, read_tools_1.registerReadTools)(registrar, createLifecycle({ fileClient: client }), {
|
|
414
401
|
initializeManager: jest.fn(),
|
|
415
402
|
});
|
|
416
|
-
await expect(getHandler(registrar, "
|
|
403
|
+
await expect(getHandler(registrar, "lsp_definition")({ file: "/workspace/src/index.ts", line: 0, character: 0 })).resolves.toEqual({
|
|
417
404
|
content: [
|
|
418
405
|
{
|
|
419
406
|
type: "text",
|
|
@@ -429,7 +416,7 @@ describe("registerReadTools", () => {
|
|
|
429
416
|
(0, read_tools_1.registerReadTools)(registrar, createLifecycle({ fileClient: null }), {
|
|
430
417
|
initializeManager: jest.fn(),
|
|
431
418
|
});
|
|
432
|
-
await expect(getHandler(registrar, "
|
|
419
|
+
await expect(getHandler(registrar, "lsp_definition")({ file: "/workspace/README.md", line: 0, character: 0 })).resolves.toEqual({
|
|
433
420
|
content: [
|
|
434
421
|
{
|
|
435
422
|
type: "text",
|
|
@@ -446,7 +433,7 @@ describe("registerReadTools", () => {
|
|
|
446
433
|
(0, read_tools_1.registerReadTools)(registrar, createLifecycle({ fileClient: client }), {
|
|
447
434
|
initializeManager: jest.fn(),
|
|
448
435
|
});
|
|
449
|
-
await expect(getHandler(registrar, "
|
|
436
|
+
await expect(getHandler(registrar, "lsp_definition")({ file: "/workspace/src/index.ts", line: 0, character: 0 })).resolves.toEqual({
|
|
450
437
|
content: [
|
|
451
438
|
{
|
|
452
439
|
type: "text",
|
|
@@ -2,27 +2,42 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
const mockRegisterCapabilities = jest.fn();
|
|
4
4
|
const mockSetRequestHandler = jest.fn();
|
|
5
|
+
const mockSetNotificationHandler = jest.fn();
|
|
5
6
|
const mockConnect = jest.fn().mockResolvedValue(undefined);
|
|
6
|
-
jest.
|
|
7
|
+
const mockGetClientCapabilities = jest.fn().mockReturnValue(undefined);
|
|
8
|
+
const mockListRoots = jest.fn().mockResolvedValue({ roots: [] });
|
|
9
|
+
const mockSendToolListChanged = jest.fn().mockResolvedValue(undefined);
|
|
10
|
+
const mockInnerServer = {
|
|
11
|
+
registerCapabilities: mockRegisterCapabilities,
|
|
12
|
+
setRequestHandler: mockSetRequestHandler,
|
|
13
|
+
setNotificationHandler: mockSetNotificationHandler,
|
|
14
|
+
getClientCapabilities: mockGetClientCapabilities,
|
|
15
|
+
listRoots: mockListRoots,
|
|
16
|
+
sendToolListChanged: mockSendToolListChanged,
|
|
17
|
+
oninitialized: undefined,
|
|
18
|
+
};
|
|
19
|
+
jest.mock("@modelcontextprotocol/sdk/server/mcp.js", () => ({
|
|
7
20
|
McpServer: jest.fn().mockImplementation(() => ({
|
|
8
21
|
connect: mockConnect,
|
|
9
|
-
server:
|
|
10
|
-
|
|
11
|
-
setRequestHandler: mockSetRequestHandler
|
|
12
|
-
}
|
|
13
|
-
}))
|
|
22
|
+
server: mockInnerServer,
|
|
23
|
+
})),
|
|
14
24
|
}));
|
|
15
|
-
jest.mock(
|
|
16
|
-
StdioServerTransport: jest.fn().mockImplementation(() => ({ kind:
|
|
25
|
+
jest.mock("@modelcontextprotocol/sdk/server/stdio.js", () => ({
|
|
26
|
+
StdioServerTransport: jest.fn().mockImplementation(() => ({ kind: "stdio" })),
|
|
17
27
|
}));
|
|
18
|
-
jest.mock(
|
|
19
|
-
normalizeObjectSchema: jest.fn().mockReturnValue(null)
|
|
28
|
+
jest.mock("@modelcontextprotocol/sdk/server/zod-compat.js", () => ({
|
|
29
|
+
normalizeObjectSchema: jest.fn().mockReturnValue(null),
|
|
20
30
|
}));
|
|
21
|
-
jest.mock(
|
|
22
|
-
toJsonSchemaCompat: jest.fn().mockReturnValue({})
|
|
31
|
+
jest.mock("@modelcontextprotocol/sdk/server/zod-json-schema-compat.js", () => ({
|
|
32
|
+
toJsonSchemaCompat: jest.fn().mockReturnValue({}),
|
|
33
|
+
}));
|
|
34
|
+
jest.mock("../tools/read-tools", () => ({ registerReadTools: jest.fn() }));
|
|
35
|
+
jest.mock("../tools/write-tools", () => ({ registerWriteTools: jest.fn() }));
|
|
36
|
+
jest.mock("../../utils/workspace-config", () => ({
|
|
37
|
+
loadWorkspaceConfig: jest.fn().mockResolvedValue(null),
|
|
38
|
+
saveWorkspaceConfig: jest.fn().mockResolvedValue(undefined),
|
|
39
|
+
loadLastRoot: jest.fn().mockResolvedValue(null),
|
|
23
40
|
}));
|
|
24
|
-
jest.mock('../tools/read-tools', () => ({ registerReadTools: jest.fn() }));
|
|
25
|
-
jest.mock('../tools/write-tools', () => ({ registerWriteTools: jest.fn() }));
|
|
26
41
|
const server_1 = require("../server");
|
|
27
42
|
function getHandler(schema) {
|
|
28
43
|
const call = mockSetRequestHandler.mock.calls.find(([s]) => s === schema);
|
|
@@ -30,114 +45,240 @@ function getHandler(schema) {
|
|
|
30
45
|
throw new Error(`No handler registered for schema`);
|
|
31
46
|
return call[1];
|
|
32
47
|
}
|
|
33
|
-
describe(
|
|
48
|
+
describe("McpServer", () => {
|
|
34
49
|
beforeEach(() => {
|
|
35
50
|
jest.clearAllMocks();
|
|
51
|
+
mockInnerServer.oninitialized = undefined;
|
|
52
|
+
mockGetClientCapabilities.mockReturnValue(undefined);
|
|
53
|
+
mockListRoots.mockResolvedValue({ roots: [] });
|
|
54
|
+
mockSendToolListChanged.mockResolvedValue(undefined);
|
|
36
55
|
});
|
|
37
|
-
it(
|
|
38
|
-
const server = new server_1.McpServer(
|
|
56
|
+
it("registers tool capabilities, request handlers, notification handler, and connects on start", async () => {
|
|
57
|
+
const server = new server_1.McpServer("info");
|
|
39
58
|
await server.start();
|
|
40
59
|
expect(mockRegisterCapabilities).toHaveBeenCalledWith({ tools: {} });
|
|
41
60
|
expect(mockSetRequestHandler).toHaveBeenCalledTimes(2);
|
|
42
|
-
expect(
|
|
61
|
+
expect(mockSetNotificationHandler).toHaveBeenCalledTimes(1);
|
|
62
|
+
expect(mockConnect).toHaveBeenCalledWith({ kind: "stdio" });
|
|
63
|
+
});
|
|
64
|
+
it("sets oninitialized callback on start", async () => {
|
|
65
|
+
const server = new server_1.McpServer("info");
|
|
66
|
+
await server.start();
|
|
67
|
+
expect(mockInnerServer.oninitialized).toBeInstanceOf(Function);
|
|
43
68
|
});
|
|
44
|
-
it(
|
|
45
|
-
const readTools = jest.requireMock(
|
|
46
|
-
|
|
69
|
+
it("lists lsp_init before auto-init", async () => {
|
|
70
|
+
const readTools = jest.requireMock("../tools/read-tools")
|
|
71
|
+
.registerReadTools;
|
|
72
|
+
const writeTools = jest.requireMock("../tools/write-tools")
|
|
73
|
+
.registerWriteTools;
|
|
47
74
|
readTools.mockImplementationOnce((registrar) => {
|
|
48
|
-
registrar.registerTool(
|
|
49
|
-
registrar.registerTool(
|
|
75
|
+
registrar.registerTool("lsp_init", { description: "init" }, async () => ({ content: [{ type: "text", text: "ok" }], raw: null }));
|
|
76
|
+
registrar.registerTool("lsp_definition", { description: "definition" }, async () => ({
|
|
77
|
+
content: [{ type: "text", text: "definition" }],
|
|
78
|
+
raw: null,
|
|
79
|
+
}));
|
|
50
80
|
});
|
|
51
81
|
writeTools.mockImplementationOnce(() => undefined);
|
|
52
|
-
const { ListToolsRequestSchema } = jest.requireActual(
|
|
53
|
-
const server = new server_1.McpServer(
|
|
82
|
+
const { ListToolsRequestSchema } = jest.requireActual("@modelcontextprotocol/sdk/types.js");
|
|
83
|
+
const server = new server_1.McpServer("info");
|
|
54
84
|
await server.start();
|
|
55
85
|
const listHandler = getHandler(ListToolsRequestSchema);
|
|
56
86
|
const result = await listHandler({});
|
|
57
|
-
expect(result.tools.map((t) => t.name)).toContain(
|
|
58
|
-
expect(result.tools.map((t) => t.name)).toContain(
|
|
87
|
+
expect(result.tools.map((t) => t.name)).toContain("lsp_init");
|
|
88
|
+
expect(result.tools.map((t) => t.name)).toContain("lsp_definition");
|
|
59
89
|
});
|
|
60
|
-
it(
|
|
61
|
-
const readTools = jest.requireMock(
|
|
62
|
-
|
|
90
|
+
it("hides lsp_init after successful auto-init (all servers ready)", async () => {
|
|
91
|
+
const readTools = jest.requireMock("../tools/read-tools")
|
|
92
|
+
.registerReadTools;
|
|
93
|
+
const writeTools = jest.requireMock("../tools/write-tools")
|
|
94
|
+
.registerWriteTools;
|
|
63
95
|
readTools.mockImplementationOnce((registrar) => {
|
|
64
|
-
registrar.registerTool(
|
|
65
|
-
registrar.registerTool(
|
|
96
|
+
registrar.registerTool("lsp_init", { description: "init" }, async () => ({ content: [{ type: "text", text: "ok" }], raw: null }));
|
|
97
|
+
registrar.registerTool("lsp_definition", { description: "definition" }, async () => ({
|
|
98
|
+
content: [{ type: "text", text: "definition" }],
|
|
99
|
+
raw: null,
|
|
100
|
+
}));
|
|
66
101
|
});
|
|
67
102
|
writeTools.mockImplementationOnce(() => undefined);
|
|
68
|
-
const {
|
|
69
|
-
const
|
|
103
|
+
const health = [{ language: "typescript", status: "ready" }];
|
|
104
|
+
const manager = {
|
|
105
|
+
start: jest.fn().mockResolvedValue(undefined),
|
|
106
|
+
shutdown: jest.fn().mockResolvedValue(undefined),
|
|
107
|
+
getHealth: jest.fn().mockReturnValue(health),
|
|
108
|
+
};
|
|
109
|
+
const factory = jest.fn().mockReturnValue(manager);
|
|
110
|
+
mockGetClientCapabilities.mockReturnValue({ roots: { listChanged: true } });
|
|
111
|
+
mockListRoots.mockResolvedValue({
|
|
112
|
+
roots: [{ uri: "file:///workspace/project" }],
|
|
113
|
+
});
|
|
114
|
+
const { ListToolsRequestSchema } = jest.requireActual("@modelcontextprotocol/sdk/types.js");
|
|
115
|
+
const server = new server_1.McpServer("info", factory);
|
|
70
116
|
await server.start();
|
|
71
|
-
|
|
117
|
+
// Trigger the oninitialized callback
|
|
118
|
+
await mockInnerServer.oninitialized();
|
|
119
|
+
const listHandler = getHandler(ListToolsRequestSchema);
|
|
120
|
+
const result = await listHandler({});
|
|
121
|
+
expect(result.tools.map((t) => t.name)).not.toContain("lsp_init");
|
|
122
|
+
expect(result.tools.map((t) => t.name)).toContain("lsp_definition");
|
|
123
|
+
});
|
|
124
|
+
it("keeps lsp_init visible after auto-init when a server has errors", async () => {
|
|
125
|
+
const readTools = jest.requireMock("../tools/read-tools")
|
|
126
|
+
.registerReadTools;
|
|
127
|
+
const writeTools = jest.requireMock("../tools/write-tools")
|
|
128
|
+
.registerWriteTools;
|
|
129
|
+
readTools.mockImplementationOnce((registrar) => {
|
|
130
|
+
registrar.registerTool("lsp_init", { description: "init" }, async () => ({ content: [{ type: "text", text: "ok" }], raw: null }));
|
|
131
|
+
});
|
|
132
|
+
writeTools.mockImplementationOnce(() => undefined);
|
|
133
|
+
const health = [
|
|
134
|
+
{ language: "typescript", status: "ready" },
|
|
135
|
+
{ language: "python", status: "error", error: "pylsp not found" },
|
|
136
|
+
];
|
|
137
|
+
const manager = {
|
|
138
|
+
start: jest.fn().mockResolvedValue(undefined),
|
|
139
|
+
shutdown: jest.fn().mockResolvedValue(undefined),
|
|
140
|
+
getHealth: jest.fn().mockReturnValue(health),
|
|
141
|
+
};
|
|
142
|
+
const factory = jest.fn().mockReturnValue(manager);
|
|
143
|
+
mockGetClientCapabilities.mockReturnValue({ roots: { listChanged: true } });
|
|
144
|
+
mockListRoots.mockResolvedValue({
|
|
145
|
+
roots: [{ uri: "file:///workspace/project" }],
|
|
146
|
+
});
|
|
147
|
+
const { ListToolsRequestSchema } = jest.requireActual("@modelcontextprotocol/sdk/types.js");
|
|
148
|
+
const server = new server_1.McpServer("info", factory);
|
|
149
|
+
await server.start();
|
|
150
|
+
await mockInnerServer.oninitialized();
|
|
72
151
|
const listHandler = getHandler(ListToolsRequestSchema);
|
|
73
152
|
const result = await listHandler({});
|
|
74
|
-
expect(result.tools.map((t) => t.name)).toContain(
|
|
75
|
-
expect(result.tools.map((t) => t.name)).toContain('lsp_hover');
|
|
153
|
+
expect(result.tools.map((t) => t.name)).toContain("lsp_init");
|
|
76
154
|
});
|
|
77
|
-
it(
|
|
78
|
-
const
|
|
79
|
-
|
|
155
|
+
it("falls back to persisted last root when client has no roots capability", async () => {
|
|
156
|
+
const { loadLastRoot, loadWorkspaceConfig } = jest.requireMock("../../utils/workspace-config");
|
|
157
|
+
loadLastRoot.mockResolvedValueOnce("/persisted/root");
|
|
158
|
+
loadWorkspaceConfig.mockResolvedValueOnce({ languages: ["typescript"] });
|
|
159
|
+
const health = [{ language: "typescript", status: "ready" }];
|
|
160
|
+
const manager = {
|
|
161
|
+
start: jest.fn().mockResolvedValue(undefined),
|
|
162
|
+
shutdown: jest.fn().mockResolvedValue(undefined),
|
|
163
|
+
getHealth: jest.fn().mockReturnValue(health),
|
|
164
|
+
};
|
|
165
|
+
const factory = jest.fn().mockReturnValue(manager);
|
|
166
|
+
mockGetClientCapabilities.mockReturnValue(undefined); // no roots capability
|
|
167
|
+
const server = new server_1.McpServer("info", factory);
|
|
168
|
+
await server.start();
|
|
169
|
+
await mockInnerServer.oninitialized();
|
|
170
|
+
expect(manager.start).toHaveBeenCalled();
|
|
171
|
+
expect(server.isInitToolHidden()).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
it("returns no-root error for non-init tools before initialization", async () => {
|
|
174
|
+
const readTools = jest.requireMock("../tools/read-tools")
|
|
175
|
+
.registerReadTools;
|
|
176
|
+
const writeTools = jest.requireMock("../tools/write-tools")
|
|
177
|
+
.registerWriteTools;
|
|
80
178
|
readTools.mockImplementationOnce((registrar) => {
|
|
81
|
-
registrar.registerTool(
|
|
179
|
+
registrar.registerTool("lsp_definition", { description: "definition" }, async () => ({
|
|
180
|
+
content: [{ type: "text", text: "reachable" }],
|
|
181
|
+
raw: null,
|
|
182
|
+
}));
|
|
82
183
|
});
|
|
83
184
|
writeTools.mockImplementationOnce(() => undefined);
|
|
84
|
-
const { CallToolRequestSchema } = jest.requireActual(
|
|
85
|
-
const server = new server_1.McpServer(
|
|
185
|
+
const { CallToolRequestSchema } = jest.requireActual("@modelcontextprotocol/sdk/types.js");
|
|
186
|
+
const server = new server_1.McpServer("info");
|
|
86
187
|
await server.start();
|
|
87
188
|
const callHandler = getHandler(CallToolRequestSchema);
|
|
88
|
-
const result = await callHandler({
|
|
189
|
+
const result = await callHandler({
|
|
190
|
+
params: { name: "lsp_definition", arguments: {} },
|
|
191
|
+
});
|
|
89
192
|
expect(result.content[0].text).toMatch(/No project root set/);
|
|
90
193
|
});
|
|
91
|
-
it(
|
|
92
|
-
const readTools = jest.requireMock(
|
|
93
|
-
|
|
194
|
+
it("allows calling lsp_init directly even after initialization (re-init)", async () => {
|
|
195
|
+
const readTools = jest.requireMock("../tools/read-tools")
|
|
196
|
+
.registerReadTools;
|
|
197
|
+
const writeTools = jest.requireMock("../tools/write-tools")
|
|
198
|
+
.registerWriteTools;
|
|
94
199
|
readTools.mockImplementationOnce((registrar) => {
|
|
95
|
-
registrar.registerTool(
|
|
200
|
+
registrar.registerTool("lsp_init", { description: "init" }, async () => ({
|
|
201
|
+
content: [{ type: "text", text: "initialized" }],
|
|
202
|
+
raw: null,
|
|
203
|
+
}));
|
|
96
204
|
});
|
|
97
205
|
writeTools.mockImplementationOnce(() => undefined);
|
|
98
|
-
const { CallToolRequestSchema } = jest.requireActual(
|
|
99
|
-
const server = new server_1.McpServer(
|
|
206
|
+
const { CallToolRequestSchema } = jest.requireActual("@modelcontextprotocol/sdk/types.js");
|
|
207
|
+
const server = new server_1.McpServer("info");
|
|
100
208
|
await server.start();
|
|
101
209
|
server.setManager({});
|
|
102
210
|
const callHandler = getHandler(CallToolRequestSchema);
|
|
103
|
-
const result = await callHandler({
|
|
104
|
-
|
|
211
|
+
const result = await callHandler({
|
|
212
|
+
params: { name: "lsp_init", arguments: { root: "/x" } },
|
|
213
|
+
});
|
|
214
|
+
expect(result.content[0].text).toBe("initialized");
|
|
105
215
|
});
|
|
106
|
-
it(
|
|
216
|
+
it("shuts down the old manager before starting a new one", async () => {
|
|
107
217
|
const order = [];
|
|
108
218
|
const firstManager = {
|
|
109
219
|
start: jest.fn().mockResolvedValue(undefined),
|
|
110
|
-
shutdown: jest.fn().mockImplementation(async () => {
|
|
111
|
-
|
|
220
|
+
shutdown: jest.fn().mockImplementation(async () => {
|
|
221
|
+
order.push("shutdown-first");
|
|
222
|
+
}),
|
|
223
|
+
getHealth: jest
|
|
224
|
+
.fn()
|
|
225
|
+
.mockReturnValue([{ language: "typescript", status: "ready" }]),
|
|
112
226
|
};
|
|
113
227
|
const secondManager = {
|
|
114
|
-
start: jest.fn().mockImplementation(async () => {
|
|
228
|
+
start: jest.fn().mockImplementation(async () => {
|
|
229
|
+
order.push("start-second");
|
|
230
|
+
}),
|
|
115
231
|
shutdown: jest.fn().mockResolvedValue(undefined),
|
|
116
|
-
getHealth: jest
|
|
232
|
+
getHealth: jest
|
|
233
|
+
.fn()
|
|
234
|
+
.mockReturnValue([{ language: "python", status: "ready" }]),
|
|
117
235
|
};
|
|
118
|
-
const factory = jest
|
|
236
|
+
const factory = jest
|
|
237
|
+
.fn()
|
|
119
238
|
.mockReturnValueOnce(firstManager)
|
|
120
239
|
.mockReturnValueOnce(secondManager);
|
|
121
|
-
const server = new server_1.McpServer(
|
|
122
|
-
await expect(server.initializeManager(
|
|
123
|
-
root:
|
|
124
|
-
health: [{ language:
|
|
240
|
+
const server = new server_1.McpServer("debug", factory);
|
|
241
|
+
await expect(server.initializeManager("/workspace-one")).resolves.toEqual({
|
|
242
|
+
root: "/workspace-one",
|
|
243
|
+
health: [{ language: "typescript", status: "ready" }],
|
|
125
244
|
});
|
|
126
|
-
await expect(server.initializeManager(
|
|
127
|
-
root:
|
|
128
|
-
health: [{ language:
|
|
245
|
+
await expect(server.initializeManager("/workspace-two")).resolves.toEqual({
|
|
246
|
+
root: "/workspace-two",
|
|
247
|
+
health: [{ language: "python", status: "ready" }],
|
|
129
248
|
});
|
|
130
|
-
expect(order).toEqual([
|
|
249
|
+
expect(order).toEqual(["shutdown-first", "start-second"]);
|
|
131
250
|
});
|
|
132
|
-
it(
|
|
133
|
-
const server = new server_1.McpServer(
|
|
251
|
+
it("tolerates shutdown with no active manager", async () => {
|
|
252
|
+
const server = new server_1.McpServer("info");
|
|
134
253
|
await expect(server.shutdown()).resolves.toBeUndefined();
|
|
135
254
|
});
|
|
136
|
-
it(
|
|
137
|
-
const manager = {
|
|
138
|
-
|
|
255
|
+
it("shuts down active manager on server shutdown", async () => {
|
|
256
|
+
const manager = {
|
|
257
|
+
shutdown: jest.fn().mockResolvedValue(undefined),
|
|
258
|
+
};
|
|
259
|
+
const server = new server_1.McpServer("info");
|
|
139
260
|
server.setManager(manager);
|
|
140
261
|
await server.shutdown();
|
|
141
262
|
expect(manager.shutdown).toHaveBeenCalledTimes(1);
|
|
142
263
|
});
|
|
264
|
+
it("sends tool list changed notification when roots change", async () => {
|
|
265
|
+
const { RootsListChangedNotificationSchema } = jest.requireActual("@modelcontextprotocol/sdk/types.js");
|
|
266
|
+
const health = [{ language: "typescript", status: "ready" }];
|
|
267
|
+
const manager = {
|
|
268
|
+
start: jest.fn().mockResolvedValue(undefined),
|
|
269
|
+
shutdown: jest.fn().mockResolvedValue(undefined),
|
|
270
|
+
getHealth: jest.fn().mockReturnValue(health),
|
|
271
|
+
};
|
|
272
|
+
const factory = jest.fn().mockReturnValue(manager);
|
|
273
|
+
mockGetClientCapabilities.mockReturnValue({ roots: {} });
|
|
274
|
+
mockListRoots.mockResolvedValue({
|
|
275
|
+
roots: [{ uri: "file:///workspace/new-project" }],
|
|
276
|
+
});
|
|
277
|
+
const server = new server_1.McpServer("info", factory);
|
|
278
|
+
await server.start();
|
|
279
|
+
const notificationHandler = mockSetNotificationHandler.mock.calls.find(([s]) => s === RootsListChangedNotificationSchema)?.[1];
|
|
280
|
+
expect(notificationHandler).toBeDefined();
|
|
281
|
+
await notificationHandler({});
|
|
282
|
+
expect(mockSendToolListChanged).toHaveBeenCalled();
|
|
283
|
+
});
|
|
143
284
|
});
|
|
@@ -171,29 +171,7 @@ describe("registerWriteTools", () => {
|
|
|
171
171
|
options: { tabSize: 4, insertSpaces: false },
|
|
172
172
|
}, 15000);
|
|
173
173
|
});
|
|
174
|
-
it("
|
|
175
|
-
const registrar = new FakeRegistrar();
|
|
176
|
-
const client = createClient(null);
|
|
177
|
-
(0, write_tools_1.registerWriteTools)(registrar, createLifecycle(client));
|
|
178
|
-
const edit = {
|
|
179
|
-
changes: {
|
|
180
|
-
"file:///workspace/src/index.ts": [
|
|
181
|
-
{
|
|
182
|
-
range: {
|
|
183
|
-
start: { line: 0, character: 6 },
|
|
184
|
-
end: { line: 0, character: 9 },
|
|
185
|
-
},
|
|
186
|
-
newText: "bar",
|
|
187
|
-
},
|
|
188
|
-
],
|
|
189
|
-
},
|
|
190
|
-
};
|
|
191
|
-
await expect(getHandler(registrar, "lsp_apply_workspace_edit")({ edit })).resolves.toEqual({
|
|
192
|
-
content: [{ type: "text", text: "Applied workspace edit to 1 file(s)" }],
|
|
193
|
-
raw: { changedFiles: ["/workspace/src/index.ts"] },
|
|
194
|
-
});
|
|
195
|
-
});
|
|
196
|
-
it("supports indexed code actions, empty edits, and document changes", async () => {
|
|
174
|
+
it("supports indexed code actions and document changes", async () => {
|
|
197
175
|
const registrar = new FakeRegistrar();
|
|
198
176
|
const client = createClient([
|
|
199
177
|
{ title: "Skip me" },
|
|
@@ -230,34 +208,6 @@ describe("registerWriteTools", () => {
|
|
|
230
208
|
content: [{ type: "text", text: "Applied code action: Apply me" }],
|
|
231
209
|
raw: { title: "Apply me", changedFiles: ["/workspace/src/index.ts"] },
|
|
232
210
|
});
|
|
233
|
-
client.request.mockResolvedValueOnce(null);
|
|
234
|
-
await expect(getHandler(registrar, "lsp_apply_workspace_edit")({ edit: null })).resolves.toEqual({
|
|
235
|
-
content: [{ type: "text", text: "No result" }],
|
|
236
|
-
raw: null,
|
|
237
|
-
});
|
|
238
|
-
});
|
|
239
|
-
it("returns an error when no write-capable client is ready", async () => {
|
|
240
|
-
const registrar = new FakeRegistrar();
|
|
241
|
-
(0, write_tools_1.registerWriteTools)(registrar, {
|
|
242
|
-
getClientForFile: jest.fn(() => null),
|
|
243
|
-
getReadyClients: jest.fn(() => []),
|
|
244
|
-
getFileDiagnostics: jest.fn((_) => []),
|
|
245
|
-
getWorkspaceDiagnostics: jest.fn(() => []),
|
|
246
|
-
getHealth: jest.fn(() => []),
|
|
247
|
-
ensureLanguageForFile: jest.fn().mockResolvedValue(undefined),
|
|
248
|
-
ensureSeedFilesOpen: jest.fn().mockResolvedValue(undefined),
|
|
249
|
-
analyzeWorkspace: jest.fn().mockResolvedValue(undefined),
|
|
250
|
-
});
|
|
251
|
-
await expect(getHandler(registrar, "lsp_apply_workspace_edit")({ edit: {} })).resolves.toEqual({
|
|
252
|
-
content: [
|
|
253
|
-
{
|
|
254
|
-
type: "text",
|
|
255
|
-
text: "No language servers are ready. Run lsp_health for details.",
|
|
256
|
-
},
|
|
257
|
-
],
|
|
258
|
-
error: true,
|
|
259
|
-
raw: null,
|
|
260
|
-
});
|
|
261
211
|
});
|
|
262
212
|
it("handles missing clients, formatter defaults, and empty code actions", async () => {
|
|
263
213
|
const registrar = new FakeRegistrar();
|
package/dist/mcp/server.js
CHANGED
|
@@ -8,12 +8,15 @@ const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
|
8
8
|
const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
|
|
9
9
|
const lifecycle_manager_1 = require("../lsp/lifecycle-manager");
|
|
10
10
|
const shared_1 = require("./tools/shared");
|
|
11
|
+
const workspace_config_1 = require("../utils/workspace-config");
|
|
11
12
|
const read_tools_1 = require("./tools/read-tools");
|
|
12
13
|
const write_tools_1 = require("./tools/write-tools");
|
|
13
14
|
class McpServer {
|
|
14
15
|
currentManager = null;
|
|
15
16
|
currentRoot = null;
|
|
16
17
|
initialized = false;
|
|
18
|
+
/** When true, lsp_init is hidden from ListTools (client auto-initialized). */
|
|
19
|
+
hideInitTool = false;
|
|
17
20
|
logLevel;
|
|
18
21
|
createLifecycleManager;
|
|
19
22
|
server;
|
|
@@ -39,10 +42,24 @@ class McpServer {
|
|
|
39
42
|
},
|
|
40
43
|
};
|
|
41
44
|
(0, read_tools_1.registerReadTools)(registrar, lifecycleProxy, {
|
|
42
|
-
initializeManager: async (root, languages) =>
|
|
45
|
+
initializeManager: async (root, languages) => {
|
|
46
|
+
const result = await this.initializeManager(root, languages);
|
|
47
|
+
// When lsp_init is called explicitly, hide it on success, re-expose on error
|
|
48
|
+
this.updateInitVisibility(result.health);
|
|
49
|
+
await this.server.server.sendToolListChanged();
|
|
50
|
+
return result;
|
|
51
|
+
},
|
|
43
52
|
});
|
|
44
53
|
(0, write_tools_1.registerWriteTools)(registrar, lifecycleProxy);
|
|
45
54
|
this.configureToolHandlers();
|
|
55
|
+
// Hook into post-handshake to attempt auto-init from roots or persisted config.
|
|
56
|
+
// Cast to `() => void` to satisfy the SDK type while still returning the promise
|
|
57
|
+
// so test harnesses can await it when needed.
|
|
58
|
+
this.server.server.oninitialized = (() => this.tryAutoInit());
|
|
59
|
+
// Handle roots/list_changed: client switched workspace
|
|
60
|
+
this.server.server.setNotificationHandler(types_js_1.RootsListChangedNotificationSchema, async () => {
|
|
61
|
+
await this.tryAutoInitFromRoots();
|
|
62
|
+
});
|
|
46
63
|
const transport = new stdio_js_1.StdioServerTransport();
|
|
47
64
|
await this.server.connect(transport);
|
|
48
65
|
}
|
|
@@ -68,10 +85,69 @@ class McpServer {
|
|
|
68
85
|
this.currentManager = null;
|
|
69
86
|
this.currentRoot = null;
|
|
70
87
|
this.initialized = false;
|
|
88
|
+
this.hideInitTool = false;
|
|
71
89
|
if (activeManager) {
|
|
72
90
|
await activeManager.shutdown();
|
|
73
91
|
}
|
|
74
92
|
}
|
|
93
|
+
/** True when lsp_init is currently hidden from the tool list. */
|
|
94
|
+
isInitToolHidden() {
|
|
95
|
+
return this.hideInitTool;
|
|
96
|
+
}
|
|
97
|
+
updateInitVisibility(health) {
|
|
98
|
+
const hasErrors = health.some((entry) => entry.status === "error");
|
|
99
|
+
this.hideInitTool = !hasErrors;
|
|
100
|
+
}
|
|
101
|
+
async tryAutoInit() {
|
|
102
|
+
// Prefer roots from the client if supported
|
|
103
|
+
const caps = this.server.server.getClientCapabilities();
|
|
104
|
+
if (caps?.roots) {
|
|
105
|
+
await this.tryAutoInitFromRoots();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
// Fallback: load the last-used root from persisted config
|
|
109
|
+
try {
|
|
110
|
+
const lastRoot = await (0, workspace_config_1.loadLastRoot)();
|
|
111
|
+
if (lastRoot) {
|
|
112
|
+
await this.autoInitRoot(lastRoot);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Non-fatal; lsp_init stays visible
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
async tryAutoInitFromRoots() {
|
|
120
|
+
try {
|
|
121
|
+
const { roots } = await this.server.server.listRoots();
|
|
122
|
+
const firstUri = roots[0]?.uri;
|
|
123
|
+
if (!firstUri?.startsWith("file://"))
|
|
124
|
+
return;
|
|
125
|
+
// file:///path/to/project → /path/to/project
|
|
126
|
+
const root = decodeURIComponent(new URL(firstUri).pathname);
|
|
127
|
+
await this.autoInitRoot(root);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// Client may not support roots (e.g. Cursor bug) — stay visible
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async autoInitRoot(root) {
|
|
134
|
+
const saved = await (0, workspace_config_1.loadWorkspaceConfig)(root);
|
|
135
|
+
const languages = saved?.languages;
|
|
136
|
+
const result = await this.initializeManager(root, languages);
|
|
137
|
+
this.updateInitVisibility(result.health);
|
|
138
|
+
// Persist successful init so we can restore on next startup
|
|
139
|
+
const readyLanguages = result.health
|
|
140
|
+
.filter((e) => e.status === "ready")
|
|
141
|
+
.map((e) => e.language);
|
|
142
|
+
await (0, workspace_config_1.saveWorkspaceConfig)(root, { languages: readyLanguages });
|
|
143
|
+
// Notify client that tool list may have changed (lsp_init hidden/shown)
|
|
144
|
+
try {
|
|
145
|
+
await this.server.server.sendToolListChanged();
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// Client may not support list-changed notifications
|
|
149
|
+
}
|
|
150
|
+
}
|
|
75
151
|
configureToolHandlers() {
|
|
76
152
|
this.server.server.registerCapabilities({ tools: {} });
|
|
77
153
|
this.server.server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
|
|
@@ -79,9 +155,6 @@ class McpServer {
|
|
|
79
155
|
}));
|
|
80
156
|
this.server.server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
81
157
|
const toolName = request.params.name;
|
|
82
|
-
if (toolName === "lsp_init" && this.initialized) {
|
|
83
|
-
return (0, shared_1.failure)(`Already initialized at ${this.currentRoot ?? "unknown"}. Call lsp_init again with a new root to switch projects.`);
|
|
84
|
-
}
|
|
85
158
|
if (toolName !== "lsp_init" && !this.initialized) {
|
|
86
159
|
return (0, shared_1.noProjectRootResult)();
|
|
87
160
|
}
|
|
@@ -93,7 +166,9 @@ class McpServer {
|
|
|
93
166
|
});
|
|
94
167
|
}
|
|
95
168
|
listTools() {
|
|
96
|
-
return [...this.tools.values()]
|
|
169
|
+
return [...this.tools.values()]
|
|
170
|
+
.filter((tool) => !(tool.name === "lsp_init" && this.hideInitTool))
|
|
171
|
+
.map((tool) => ({
|
|
97
172
|
name: tool.name,
|
|
98
173
|
description: tool.description,
|
|
99
174
|
inputSchema: this.toInputSchema(tool.inputSchema),
|
|
@@ -20,16 +20,6 @@ function registerReadTools(registrar, lifecycleManager, options) {
|
|
|
20
20
|
}, async (args) => {
|
|
21
21
|
return await handleInitTool(args, options);
|
|
22
22
|
});
|
|
23
|
-
registrar.registerTool("lsp_hover", { description: "Show hover information", inputSchema: positionSchema }, async (args) => {
|
|
24
|
-
return await runFileRequest({
|
|
25
|
-
args,
|
|
26
|
-
lifecycleManager,
|
|
27
|
-
method: "textDocument/hover",
|
|
28
|
-
timeoutMs: 5000,
|
|
29
|
-
format: formatters_1.formatHover,
|
|
30
|
-
raw: (result) => result,
|
|
31
|
-
});
|
|
32
|
-
});
|
|
33
23
|
registrar.registerTool("lsp_definition", { description: "Find definitions", inputSchema: positionSchema }, async (args) => {
|
|
34
24
|
return await runFileRequest({
|
|
35
25
|
args,
|
|
@@ -86,16 +76,6 @@ function registerReadTools(registrar, lifecycleManager, options) {
|
|
|
86
76
|
const merged = results.flat().slice(0, query === "" ? 100 : 500);
|
|
87
77
|
return (0, shared_1.success)((0, formatters_1.formatSymbols)(merged), (0, shared_1.normalizeSymbols)(merged));
|
|
88
78
|
});
|
|
89
|
-
registrar.registerTool("lsp_completion", { description: "Get completions", inputSchema: positionSchema }, async (args) => {
|
|
90
|
-
return await runFileRequest({
|
|
91
|
-
args,
|
|
92
|
-
lifecycleManager,
|
|
93
|
-
method: "textDocument/completion",
|
|
94
|
-
timeoutMs: 5000,
|
|
95
|
-
format: (result) => (0, formatters_1.formatCompletion)(asCompletionItems(result)),
|
|
96
|
-
raw: (result) => asCompletionItems(result)?.slice(0, 50) ?? null,
|
|
97
|
-
});
|
|
98
|
-
});
|
|
99
79
|
registrar.registerTool("lsp_diagnostics", {
|
|
100
80
|
description: "Get errors and warnings. Pass a file path to trigger analysis of that file and its language server, then return diagnostics for that file (scope: file) or all files seen so far (scope: workspace). Omit file for workspace scope to query whatever has been opened previously.",
|
|
101
81
|
inputSchema: zod_1.z.object({
|
|
@@ -129,16 +109,6 @@ function registerReadTools(registrar, lifecycleManager, options) {
|
|
|
129
109
|
const diagnostics = lifecycleManager.getFileDiagnostics(filePath);
|
|
130
110
|
return (0, shared_1.success)((0, formatters_1.formatDiagnostics)(diagnostics, "file"), diagnostics);
|
|
131
111
|
});
|
|
132
|
-
registrar.registerTool("lsp_signature_help", { description: "Show signature help", inputSchema: positionSchema }, async (args) => {
|
|
133
|
-
return await runFileRequest({
|
|
134
|
-
args,
|
|
135
|
-
lifecycleManager,
|
|
136
|
-
method: "textDocument/signatureHelp",
|
|
137
|
-
timeoutMs: 5000,
|
|
138
|
-
format: stringifyResult,
|
|
139
|
-
raw: (result) => result,
|
|
140
|
-
});
|
|
141
|
-
});
|
|
142
112
|
registrar.registerTool("lsp_type_definition", { description: "Find type definitions", inputSchema: positionSchema }, async (args) => {
|
|
143
113
|
return await runFileRequest({
|
|
144
114
|
args,
|
|
@@ -242,18 +212,6 @@ function asLocationArray(result) {
|
|
|
242
212
|
}
|
|
243
213
|
return Array.isArray(result) ? result : [result];
|
|
244
214
|
}
|
|
245
|
-
function asCompletionItems(result) {
|
|
246
|
-
if (!result) {
|
|
247
|
-
return null;
|
|
248
|
-
}
|
|
249
|
-
return Array.isArray(result) ? result : result.items;
|
|
250
|
-
}
|
|
251
|
-
function stringifyResult(result) {
|
|
252
|
-
if (!result) {
|
|
253
|
-
return "No result";
|
|
254
|
-
}
|
|
255
|
-
return JSON.stringify(result, null, 2);
|
|
256
|
-
}
|
|
257
215
|
function formatLanguageList(languages) {
|
|
258
216
|
return languages.length === 0 ? "None" : languages.join(", ");
|
|
259
217
|
}
|
|
@@ -83,19 +83,6 @@ function registerWriteTools(registrar, lifecycleManager) {
|
|
|
83
83
|
}, 15000);
|
|
84
84
|
});
|
|
85
85
|
});
|
|
86
|
-
registrar.registerTool('lsp_apply_workspace_edit', { description: 'Apply raw workspace edit', inputSchema: zod_1.z.object({ edit: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()) }) }, async (args) => {
|
|
87
|
-
const clients = lifecycleManager.getReadyClients();
|
|
88
|
-
const client = clients[0] ?? null;
|
|
89
|
-
if (!client) {
|
|
90
|
-
return (0, shared_1.failure)('No language servers are ready. Run lsp_health for details.');
|
|
91
|
-
}
|
|
92
|
-
try {
|
|
93
|
-
return await applyWorkspaceEdit((args.edit ?? null), lifecycleManager, client, 'Applied workspace edit to');
|
|
94
|
-
}
|
|
95
|
-
catch (error) {
|
|
96
|
-
return (0, shared_1.mapToolError)(error, 15);
|
|
97
|
-
}
|
|
98
|
-
});
|
|
99
86
|
}
|
|
100
87
|
const positionSchema = zod_1.z.object({ line: zod_1.z.number().int(), character: zod_1.z.number().int() });
|
|
101
88
|
const rangeSchema = zod_1.z.object({ start: positionSchema, end: positionSchema });
|
|
@@ -0,0 +1,62 @@
|
|
|
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.loadWorkspaceConfig = loadWorkspaceConfig;
|
|
7
|
+
exports.saveWorkspaceConfig = saveWorkspaceConfig;
|
|
8
|
+
exports.loadLastRoot = loadLastRoot;
|
|
9
|
+
const promises_1 = require("node:fs/promises");
|
|
10
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
11
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
12
|
+
function getConfigDir() {
|
|
13
|
+
const platform = node_os_1.default.platform();
|
|
14
|
+
if (platform === "win32") {
|
|
15
|
+
const appData = process.env["APPDATA"];
|
|
16
|
+
if (appData)
|
|
17
|
+
return node_path_1.default.join(appData, "lsp-mcp");
|
|
18
|
+
return node_path_1.default.join(node_os_1.default.homedir(), "AppData", "Roaming", "lsp-mcp");
|
|
19
|
+
}
|
|
20
|
+
if (platform === "darwin") {
|
|
21
|
+
return node_path_1.default.join(node_os_1.default.homedir(), "Library", "Application Support", "lsp-mcp");
|
|
22
|
+
}
|
|
23
|
+
// Linux / other POSIX — respect XDG
|
|
24
|
+
const xdgConfig = process.env["XDG_CONFIG_HOME"];
|
|
25
|
+
const base = xdgConfig ?? node_path_1.default.join(node_os_1.default.homedir(), ".config");
|
|
26
|
+
return node_path_1.default.join(base, "lsp-mcp");
|
|
27
|
+
}
|
|
28
|
+
const CONFIG_FILE = node_path_1.default.join(getConfigDir(), "config.json");
|
|
29
|
+
async function readConfig() {
|
|
30
|
+
try {
|
|
31
|
+
const raw = await (0, promises_1.readFile)(CONFIG_FILE, "utf8");
|
|
32
|
+
const parsed = JSON.parse(raw);
|
|
33
|
+
if (typeof parsed === "object" &&
|
|
34
|
+
parsed !== null &&
|
|
35
|
+
"workspaces" in parsed &&
|
|
36
|
+
typeof parsed.workspaces === "object") {
|
|
37
|
+
return parsed;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// File missing or malformed — start fresh
|
|
42
|
+
}
|
|
43
|
+
return { workspaces: {} };
|
|
44
|
+
}
|
|
45
|
+
async function writeConfig(config) {
|
|
46
|
+
await (0, promises_1.mkdir)(node_path_1.default.dirname(CONFIG_FILE), { recursive: true });
|
|
47
|
+
await (0, promises_1.writeFile)(CONFIG_FILE, JSON.stringify(config, null, 2), "utf8");
|
|
48
|
+
}
|
|
49
|
+
async function loadWorkspaceConfig(root) {
|
|
50
|
+
const config = await readConfig();
|
|
51
|
+
return config.workspaces[root] ?? null;
|
|
52
|
+
}
|
|
53
|
+
async function saveWorkspaceConfig(root, entry) {
|
|
54
|
+
const config = await readConfig();
|
|
55
|
+
config.workspaces[root] = entry;
|
|
56
|
+
config.lastRoot = root;
|
|
57
|
+
await writeConfig(config);
|
|
58
|
+
}
|
|
59
|
+
async function loadLastRoot() {
|
|
60
|
+
const config = await readConfig();
|
|
61
|
+
return config.lastRoot ?? null;
|
|
62
|
+
}
|