@theupsider/lsp-mcp 1.0.8 → 1.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/README.md +34 -25
- package/dist/mcp/__tests__/server.test.js +250 -71
- package/dist/mcp/server.js +83 -5
- package/dist/utils/workspace-config.js +62 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,6 +32,9 @@ 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
|
+
- **🤖 Hands-Free Initialization** — Clients that support [MCP Roots](https://modelcontextprotocol.io/docs/concepts/roots) auto-initialize on connect — `lsp_init` disappears from the tool list once all servers start cleanly
|
|
36
|
+
- **💾 Cross-Session Persistence** — Initialized workspaces and their ready languages are remembered across server restarts
|
|
37
|
+
- **🔁 Graceful Degradation** — `lsp_init` reappears if a previously-working server starts failing (e.g. language added, binary missing)
|
|
35
38
|
- **🛠 12 LSP Tools** — Definition, references, symbols, diagnostics, rename, code actions, formatting, and more
|
|
36
39
|
- **📝 Read & Write Operations** — Both inspection and modification of code via LSP
|
|
37
40
|
- **🌐 Polyglot Support** — Multiple language servers run simultaneously in the same project
|
|
@@ -45,10 +48,10 @@ A Model Context Protocol (MCP) server that gives language models access to **Lan
|
|
|
45
48
|
|
|
46
49
|
```bash
|
|
47
50
|
# npm
|
|
48
|
-
npm install -g @theupsider/lsp-mcp
|
|
51
|
+
npm install -g @theupsider/lsp-mcp@latest
|
|
49
52
|
|
|
50
53
|
# bun
|
|
51
|
-
bun install -g @theupsider/lsp-mcp
|
|
54
|
+
bun install -g @theupsider/lsp-mcp@latest
|
|
52
55
|
```
|
|
53
56
|
|
|
54
57
|
## Quickstart
|
|
@@ -63,7 +66,7 @@ Add to your **workspace** `.vscode/mcp.json` (recommended — ensures the server
|
|
|
63
66
|
"lsp-mcp": {
|
|
64
67
|
"type": "stdio",
|
|
65
68
|
"command": "npx",
|
|
66
|
-
"args": ["-y", "@theupsider/lsp-mcp"]
|
|
69
|
+
"args": ["-y", "@theupsider/lsp-mcp@latest"]
|
|
67
70
|
}
|
|
68
71
|
}
|
|
69
72
|
}
|
|
@@ -78,7 +81,9 @@ Add to your **workspace** `.vscode/mcp.json` (recommended — ensures the server
|
|
|
78
81
|
lsp-mcp
|
|
79
82
|
```
|
|
80
83
|
|
|
81
|
-
The server starts with no active project.
|
|
84
|
+
The server starts with no active project. **Most clients auto-initialize** when they support the MCP Roots protocol (VS Code, Copilot, etc.), but `lsp_init` remains available as a manual override.
|
|
85
|
+
|
|
86
|
+
For clients without Roots support, the first action the model must take is calling `lsp_init`:
|
|
82
87
|
|
|
83
88
|
```
|
|
84
89
|
lsp_init({ root: "/path/to/your/project" })
|
|
@@ -87,7 +92,7 @@ lsp_init({ root: "/path/to/your/project" })
|
|
|
87
92
|
`lsp_init` will:
|
|
88
93
|
|
|
89
94
|
1. Scan the root for language markers and start matching language servers (best-effort)
|
|
90
|
-
2. Disappear from the tool list — subsequent calls to any LSP tool trigger lazy server startup for that file's language if no server was detected at init time
|
|
95
|
+
2. Disappear from the tool list when you called it explicitly — subsequent calls to any LSP tool trigger lazy server startup for that file's language if no server was detected at init time
|
|
91
96
|
3. Return health status for all servers that were started eagerly
|
|
92
97
|
|
|
93
98
|
**Optional: pre-warm specific languages** (skips detection, faster cold start):
|
|
@@ -96,36 +101,40 @@ lsp_init({ root: "/path/to/your/project" })
|
|
|
96
101
|
lsp_init({ root: "/path/to/project", languages: ["python", "typescript"] })
|
|
97
102
|
```
|
|
98
103
|
|
|
104
|
+
**Persistence:** Once initialized, the workspace configuration (detected languages) is saved to your OS-standard config directory (`~/.config/lsp-mcp/` on Linux, `~/Library/Application Support/lsp-mcp/` on macOS, `%APPDATA%\lsp-mcp\` on Windows). On subsequent server startups, the MCP server will automatically reconnect using the last-known root, so you rarely need to call `lsp_init` again.
|
|
105
|
+
|
|
106
|
+
**Re-emergence:** If a language server that was previously healthy fails to start (e.g. you added a new language or removed a binary), `lsp_init` will reappear in the tool list, signaling the model should re-initialize.
|
|
107
|
+
|
|
99
108
|
## Available Tools
|
|
100
109
|
|
|
101
110
|
### Read-Only Tools
|
|
102
111
|
|
|
103
|
-
| Tool | Description
|
|
104
|
-
| ----------------------- |
|
|
105
|
-
| `lsp_init` | Initialize server for a project root
|
|
106
|
-
| `lsp_definition` | Go to definition
|
|
107
|
-
| `lsp_references` | Find all references
|
|
108
|
-
| `lsp_document_symbols` | List symbols in a file
|
|
109
|
-
| `lsp_workspace_symbols` | Search symbols across workspace
|
|
110
|
-
| `lsp_diagnostics` | Get errors & warnings
|
|
111
|
-
| `lsp_type_definition` | Go to type definition
|
|
112
|
-
| `lsp_implementation` | Find implementations
|
|
113
|
-
| `lsp_health` | Check status of all LSP servers
|
|
112
|
+
| Tool | Description | Key Parameters | Visibility |
|
|
113
|
+
| ----------------------- | ------------------------------------ | ------------------------------------------------------ | ---------------------------------------------------------------- |
|
|
114
|
+
| `lsp_init` | Initialize server for a project root | `root` (required), `languages` (optional string array) | Conditional — hidden after a successful explicit `lsp_init` call |
|
|
115
|
+
| `lsp_definition` | Go to definition | `file`, `line`, `character` | Always |
|
|
116
|
+
| `lsp_references` | Find all references | `file`, `line`, `character` | Always |
|
|
117
|
+
| `lsp_document_symbols` | List symbols in a file | `file` | Always |
|
|
118
|
+
| `lsp_workspace_symbols` | Search symbols across workspace | `query` (limit: 100–500 results) | Always |
|
|
119
|
+
| `lsp_diagnostics` | Get errors & warnings | `file` (scope: `file` or `workspace`) | Always |
|
|
120
|
+
| `lsp_type_definition` | Go to type definition | `file`, `line`, `character` | Always |
|
|
121
|
+
| `lsp_implementation` | Find implementations | `file`, `line`, `character` | Always |
|
|
122
|
+
| `lsp_health` | Check status of all LSP servers | _(none)_ | Always |
|
|
114
123
|
|
|
115
124
|
### Write Tools
|
|
116
125
|
|
|
117
|
-
| Tool
|
|
118
|
-
|
|
|
119
|
-
| `lsp_rename`
|
|
120
|
-
| `lsp_code_action`
|
|
121
|
-
| `lsp_formatting`
|
|
122
|
-
| `lsp_range_formatting`
|
|
126
|
+
| Tool | Description | Key Parameters |
|
|
127
|
+
| ---------------------- | ------------------------- | -------------------------------------- |
|
|
128
|
+
| `lsp_rename` | Rename symbol | `file`, `line`, `character`, `newName` |
|
|
129
|
+
| `lsp_code_action` | Apply / list code actions | `file`, `line`, `character`, `apply` |
|
|
130
|
+
| `lsp_formatting` | Format document | `file` |
|
|
131
|
+
| `lsp_range_formatting` | Format code range | `file`, `range` |
|
|
123
132
|
|
|
124
133
|
## Configuration
|
|
125
134
|
|
|
126
|
-
| Environment Variable | Description | Default
|
|
127
|
-
| -------------------- | ----------------------------------- |
|
|
128
|
-
| `LSP_MCP_LOG_LEVEL` | Log level: `error`, `info`, `debug` | `info`
|
|
135
|
+
| Environment Variable | Description | Default |
|
|
136
|
+
| -------------------- | ----------------------------------- | ------- |
|
|
137
|
+
| `LSP_MCP_LOG_LEVEL` | Log level: `error`, `info`, `debug` | `info` |
|
|
129
138
|
|
|
130
139
|
## Setup Scripts
|
|
131
140
|
|
|
@@ -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,278 @@ function getHandler(schema) {
|
|
|
30
45
|
throw new Error(`No handler registered for schema`);
|
|
31
46
|
return call[1];
|
|
32
47
|
}
|
|
33
|
-
|
|
48
|
+
async function flushAutoInit() {
|
|
49
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
50
|
+
}
|
|
51
|
+
describe("McpServer", () => {
|
|
34
52
|
beforeEach(() => {
|
|
35
53
|
jest.clearAllMocks();
|
|
54
|
+
mockInnerServer.oninitialized = undefined;
|
|
55
|
+
mockGetClientCapabilities.mockReturnValue(undefined);
|
|
56
|
+
mockListRoots.mockResolvedValue({ roots: [] });
|
|
57
|
+
mockSendToolListChanged.mockResolvedValue(undefined);
|
|
36
58
|
});
|
|
37
|
-
it(
|
|
38
|
-
const server = new server_1.McpServer(
|
|
59
|
+
it("registers tool capabilities, request handlers, notification handler, and connects on start", async () => {
|
|
60
|
+
const server = new server_1.McpServer("info");
|
|
39
61
|
await server.start();
|
|
40
62
|
expect(mockRegisterCapabilities).toHaveBeenCalledWith({ tools: {} });
|
|
41
63
|
expect(mockSetRequestHandler).toHaveBeenCalledTimes(2);
|
|
42
|
-
expect(
|
|
64
|
+
expect(mockSetNotificationHandler).toHaveBeenCalledTimes(1);
|
|
65
|
+
expect(mockConnect).toHaveBeenCalledWith({ kind: "stdio" });
|
|
66
|
+
});
|
|
67
|
+
it("sets oninitialized callback on start", async () => {
|
|
68
|
+
const server = new server_1.McpServer("info");
|
|
69
|
+
await server.start();
|
|
70
|
+
expect(mockInnerServer.oninitialized).toBeInstanceOf(Function);
|
|
43
71
|
});
|
|
44
|
-
it(
|
|
45
|
-
const readTools = jest.requireMock(
|
|
46
|
-
|
|
72
|
+
it("lists lsp_init before auto-init", async () => {
|
|
73
|
+
const readTools = jest.requireMock("../tools/read-tools")
|
|
74
|
+
.registerReadTools;
|
|
75
|
+
const writeTools = jest.requireMock("../tools/write-tools")
|
|
76
|
+
.registerWriteTools;
|
|
47
77
|
readTools.mockImplementationOnce((registrar) => {
|
|
48
|
-
registrar.registerTool(
|
|
49
|
-
registrar.registerTool(
|
|
78
|
+
registrar.registerTool("lsp_init", { description: "init" }, async () => ({ content: [{ type: "text", text: "ok" }], raw: null }));
|
|
79
|
+
registrar.registerTool("lsp_definition", { description: "definition" }, async () => ({
|
|
80
|
+
content: [{ type: "text", text: "definition" }],
|
|
81
|
+
raw: null,
|
|
82
|
+
}));
|
|
50
83
|
});
|
|
51
84
|
writeTools.mockImplementationOnce(() => undefined);
|
|
52
|
-
const { ListToolsRequestSchema } = jest.requireActual(
|
|
53
|
-
const server = new server_1.McpServer(
|
|
85
|
+
const { ListToolsRequestSchema } = jest.requireActual("@modelcontextprotocol/sdk/types.js");
|
|
86
|
+
const server = new server_1.McpServer("info");
|
|
54
87
|
await server.start();
|
|
55
88
|
const listHandler = getHandler(ListToolsRequestSchema);
|
|
56
89
|
const result = await listHandler({});
|
|
57
|
-
expect(result.tools.map((t) => t.name)).toContain(
|
|
58
|
-
expect(result.tools.map((t) => t.name)).toContain(
|
|
90
|
+
expect(result.tools.map((t) => t.name)).toContain("lsp_init");
|
|
91
|
+
expect(result.tools.map((t) => t.name)).toContain("lsp_definition");
|
|
59
92
|
});
|
|
60
|
-
it(
|
|
61
|
-
const readTools = jest.requireMock(
|
|
62
|
-
|
|
93
|
+
it("keeps lsp_init visible after successful auto-init (all servers ready)", async () => {
|
|
94
|
+
const readTools = jest.requireMock("../tools/read-tools")
|
|
95
|
+
.registerReadTools;
|
|
96
|
+
const writeTools = jest.requireMock("../tools/write-tools")
|
|
97
|
+
.registerWriteTools;
|
|
63
98
|
readTools.mockImplementationOnce((registrar) => {
|
|
64
|
-
registrar.registerTool(
|
|
65
|
-
registrar.registerTool(
|
|
99
|
+
registrar.registerTool("lsp_init", { description: "init" }, async () => ({ content: [{ type: "text", text: "ok" }], raw: null }));
|
|
100
|
+
registrar.registerTool("lsp_definition", { description: "definition" }, async () => ({
|
|
101
|
+
content: [{ type: "text", text: "definition" }],
|
|
102
|
+
raw: null,
|
|
103
|
+
}));
|
|
66
104
|
});
|
|
67
105
|
writeTools.mockImplementationOnce(() => undefined);
|
|
68
|
-
const {
|
|
69
|
-
const
|
|
106
|
+
const health = [{ language: "typescript", status: "ready" }];
|
|
107
|
+
const manager = {
|
|
108
|
+
start: jest.fn().mockResolvedValue(undefined),
|
|
109
|
+
shutdown: jest.fn().mockResolvedValue(undefined),
|
|
110
|
+
getHealth: jest.fn().mockReturnValue(health),
|
|
111
|
+
};
|
|
112
|
+
const factory = jest.fn().mockReturnValue(manager);
|
|
113
|
+
mockGetClientCapabilities.mockReturnValue({ roots: { listChanged: true } });
|
|
114
|
+
mockListRoots.mockResolvedValue({
|
|
115
|
+
roots: [{ uri: "file:///workspace/project" }],
|
|
116
|
+
});
|
|
117
|
+
const { ListToolsRequestSchema } = jest.requireActual("@modelcontextprotocol/sdk/types.js");
|
|
118
|
+
const server = new server_1.McpServer("info", factory);
|
|
70
119
|
await server.start();
|
|
71
|
-
|
|
120
|
+
mockInnerServer.oninitialized();
|
|
121
|
+
await flushAutoInit();
|
|
122
|
+
const listHandler = getHandler(ListToolsRequestSchema);
|
|
123
|
+
const result = await listHandler({});
|
|
124
|
+
expect(result.tools.map((t) => t.name)).toContain("lsp_init");
|
|
125
|
+
expect(result.tools.map((t) => t.name)).toContain("lsp_definition");
|
|
126
|
+
});
|
|
127
|
+
it("keeps lsp_init visible after auto-init when a server has errors", async () => {
|
|
128
|
+
const readTools = jest.requireMock("../tools/read-tools")
|
|
129
|
+
.registerReadTools;
|
|
130
|
+
const writeTools = jest.requireMock("../tools/write-tools")
|
|
131
|
+
.registerWriteTools;
|
|
132
|
+
readTools.mockImplementationOnce((registrar) => {
|
|
133
|
+
registrar.registerTool("lsp_init", { description: "init" }, async () => ({ content: [{ type: "text", text: "ok" }], raw: null }));
|
|
134
|
+
});
|
|
135
|
+
writeTools.mockImplementationOnce(() => undefined);
|
|
136
|
+
const health = [
|
|
137
|
+
{ language: "typescript", status: "ready" },
|
|
138
|
+
{ language: "python", status: "error", error: "pylsp not found" },
|
|
139
|
+
];
|
|
140
|
+
const manager = {
|
|
141
|
+
start: jest.fn().mockResolvedValue(undefined),
|
|
142
|
+
shutdown: jest.fn().mockResolvedValue(undefined),
|
|
143
|
+
getHealth: jest.fn().mockReturnValue(health),
|
|
144
|
+
};
|
|
145
|
+
const factory = jest.fn().mockReturnValue(manager);
|
|
146
|
+
mockGetClientCapabilities.mockReturnValue({ roots: { listChanged: true } });
|
|
147
|
+
mockListRoots.mockResolvedValue({
|
|
148
|
+
roots: [{ uri: "file:///workspace/project" }],
|
|
149
|
+
});
|
|
150
|
+
const { ListToolsRequestSchema } = jest.requireActual("@modelcontextprotocol/sdk/types.js");
|
|
151
|
+
const server = new server_1.McpServer("info", factory);
|
|
152
|
+
await server.start();
|
|
153
|
+
mockInnerServer.oninitialized();
|
|
154
|
+
await flushAutoInit();
|
|
155
|
+
const listHandler = getHandler(ListToolsRequestSchema);
|
|
156
|
+
const result = await listHandler({});
|
|
157
|
+
expect(result.tools.map((t) => t.name)).toContain("lsp_init");
|
|
158
|
+
});
|
|
159
|
+
it("falls back to persisted last root when client has no roots capability", async () => {
|
|
160
|
+
const { loadLastRoot, loadWorkspaceConfig } = jest.requireMock("../../utils/workspace-config");
|
|
161
|
+
loadLastRoot.mockResolvedValueOnce("/persisted/root");
|
|
162
|
+
loadWorkspaceConfig.mockResolvedValueOnce({ languages: ["typescript"] });
|
|
163
|
+
const health = [{ language: "typescript", status: "ready" }];
|
|
164
|
+
const manager = {
|
|
165
|
+
start: jest.fn().mockResolvedValue(undefined),
|
|
166
|
+
shutdown: jest.fn().mockResolvedValue(undefined),
|
|
167
|
+
getHealth: jest.fn().mockReturnValue(health),
|
|
168
|
+
};
|
|
169
|
+
const factory = jest.fn().mockReturnValue(manager);
|
|
170
|
+
mockGetClientCapabilities.mockReturnValue(undefined); // no roots capability
|
|
171
|
+
const server = new server_1.McpServer("info", factory);
|
|
172
|
+
await server.start();
|
|
173
|
+
mockInnerServer.oninitialized();
|
|
174
|
+
await flushAutoInit();
|
|
175
|
+
expect(manager.start).toHaveBeenCalled();
|
|
176
|
+
expect(server.isInitToolHidden()).toBe(false);
|
|
177
|
+
});
|
|
178
|
+
it("hides lsp_init after a successful explicit init", async () => {
|
|
179
|
+
const readTools = jest.requireMock("../tools/read-tools")
|
|
180
|
+
.registerReadTools;
|
|
181
|
+
const writeTools = jest.requireMock("../tools/write-tools")
|
|
182
|
+
.registerWriteTools;
|
|
183
|
+
readTools.mockImplementationOnce((registrar, _lifecycle, options) => {
|
|
184
|
+
registrar.registerTool("lsp_init", { description: "init" }, async (args) => {
|
|
185
|
+
const result = await options.initializeManager(args.root);
|
|
186
|
+
return {
|
|
187
|
+
content: [{ type: "text", text: result.root }],
|
|
188
|
+
raw: result,
|
|
189
|
+
};
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
writeTools.mockImplementationOnce(() => undefined);
|
|
193
|
+
const health = [{ language: "typescript", status: "ready" }];
|
|
194
|
+
const manager = {
|
|
195
|
+
start: jest.fn().mockResolvedValue(undefined),
|
|
196
|
+
shutdown: jest.fn().mockResolvedValue(undefined),
|
|
197
|
+
getHealth: jest.fn().mockReturnValue(health),
|
|
198
|
+
};
|
|
199
|
+
const factory = jest.fn().mockReturnValue(manager);
|
|
200
|
+
const { CallToolRequestSchema, ListToolsRequestSchema } = jest.requireActual("@modelcontextprotocol/sdk/types.js");
|
|
201
|
+
const server = new server_1.McpServer("info", factory);
|
|
202
|
+
await server.start();
|
|
203
|
+
const callHandler = getHandler(CallToolRequestSchema);
|
|
204
|
+
await callHandler({
|
|
205
|
+
params: { name: "lsp_init", arguments: { root: "/workspace/project" } },
|
|
206
|
+
});
|
|
72
207
|
const listHandler = getHandler(ListToolsRequestSchema);
|
|
73
208
|
const result = await listHandler({});
|
|
74
|
-
expect(result.tools.map((t) => t.name)).toContain(
|
|
75
|
-
expect(result.tools.map((t) => t.name)).toContain('lsp_definition');
|
|
209
|
+
expect(result.tools.map((t) => t.name)).not.toContain("lsp_init");
|
|
76
210
|
});
|
|
77
|
-
it(
|
|
78
|
-
const readTools = jest.requireMock(
|
|
79
|
-
|
|
211
|
+
it("returns no-root error for non-init tools before initialization", async () => {
|
|
212
|
+
const readTools = jest.requireMock("../tools/read-tools")
|
|
213
|
+
.registerReadTools;
|
|
214
|
+
const writeTools = jest.requireMock("../tools/write-tools")
|
|
215
|
+
.registerWriteTools;
|
|
80
216
|
readTools.mockImplementationOnce((registrar) => {
|
|
81
|
-
registrar.registerTool(
|
|
217
|
+
registrar.registerTool("lsp_definition", { description: "definition" }, async () => ({
|
|
218
|
+
content: [{ type: "text", text: "reachable" }],
|
|
219
|
+
raw: null,
|
|
220
|
+
}));
|
|
82
221
|
});
|
|
83
222
|
writeTools.mockImplementationOnce(() => undefined);
|
|
84
|
-
const { CallToolRequestSchema } = jest.requireActual(
|
|
85
|
-
const server = new server_1.McpServer(
|
|
223
|
+
const { CallToolRequestSchema } = jest.requireActual("@modelcontextprotocol/sdk/types.js");
|
|
224
|
+
const server = new server_1.McpServer("info");
|
|
86
225
|
await server.start();
|
|
87
226
|
const callHandler = getHandler(CallToolRequestSchema);
|
|
88
|
-
const result = await callHandler({
|
|
227
|
+
const result = await callHandler({
|
|
228
|
+
params: { name: "lsp_definition", arguments: {} },
|
|
229
|
+
});
|
|
89
230
|
expect(result.content[0].text).toMatch(/No project root set/);
|
|
90
231
|
});
|
|
91
|
-
it(
|
|
92
|
-
const readTools = jest.requireMock(
|
|
93
|
-
|
|
232
|
+
it("allows calling lsp_init directly even after initialization (re-init)", async () => {
|
|
233
|
+
const readTools = jest.requireMock("../tools/read-tools")
|
|
234
|
+
.registerReadTools;
|
|
235
|
+
const writeTools = jest.requireMock("../tools/write-tools")
|
|
236
|
+
.registerWriteTools;
|
|
94
237
|
readTools.mockImplementationOnce((registrar) => {
|
|
95
|
-
registrar.registerTool(
|
|
238
|
+
registrar.registerTool("lsp_init", { description: "init" }, async () => ({
|
|
239
|
+
content: [{ type: "text", text: "initialized" }],
|
|
240
|
+
raw: null,
|
|
241
|
+
}));
|
|
96
242
|
});
|
|
97
243
|
writeTools.mockImplementationOnce(() => undefined);
|
|
98
|
-
const { CallToolRequestSchema } = jest.requireActual(
|
|
99
|
-
const server = new server_1.McpServer(
|
|
244
|
+
const { CallToolRequestSchema } = jest.requireActual("@modelcontextprotocol/sdk/types.js");
|
|
245
|
+
const server = new server_1.McpServer("info");
|
|
100
246
|
await server.start();
|
|
101
247
|
server.setManager({});
|
|
102
248
|
const callHandler = getHandler(CallToolRequestSchema);
|
|
103
|
-
const result = await callHandler({
|
|
104
|
-
|
|
249
|
+
const result = await callHandler({
|
|
250
|
+
params: { name: "lsp_init", arguments: { root: "/x" } },
|
|
251
|
+
});
|
|
252
|
+
expect(result.content[0].text).toBe("initialized");
|
|
105
253
|
});
|
|
106
|
-
it(
|
|
254
|
+
it("shuts down the old manager before starting a new one", async () => {
|
|
107
255
|
const order = [];
|
|
108
256
|
const firstManager = {
|
|
109
257
|
start: jest.fn().mockResolvedValue(undefined),
|
|
110
|
-
shutdown: jest.fn().mockImplementation(async () => {
|
|
111
|
-
|
|
258
|
+
shutdown: jest.fn().mockImplementation(async () => {
|
|
259
|
+
order.push("shutdown-first");
|
|
260
|
+
}),
|
|
261
|
+
getHealth: jest
|
|
262
|
+
.fn()
|
|
263
|
+
.mockReturnValue([{ language: "typescript", status: "ready" }]),
|
|
112
264
|
};
|
|
113
265
|
const secondManager = {
|
|
114
|
-
start: jest.fn().mockImplementation(async () => {
|
|
266
|
+
start: jest.fn().mockImplementation(async () => {
|
|
267
|
+
order.push("start-second");
|
|
268
|
+
}),
|
|
115
269
|
shutdown: jest.fn().mockResolvedValue(undefined),
|
|
116
|
-
getHealth: jest
|
|
270
|
+
getHealth: jest
|
|
271
|
+
.fn()
|
|
272
|
+
.mockReturnValue([{ language: "python", status: "ready" }]),
|
|
117
273
|
};
|
|
118
|
-
const factory = jest
|
|
274
|
+
const factory = jest
|
|
275
|
+
.fn()
|
|
119
276
|
.mockReturnValueOnce(firstManager)
|
|
120
277
|
.mockReturnValueOnce(secondManager);
|
|
121
|
-
const server = new server_1.McpServer(
|
|
122
|
-
await expect(server.initializeManager(
|
|
123
|
-
root:
|
|
124
|
-
health: [{ language:
|
|
278
|
+
const server = new server_1.McpServer("debug", factory);
|
|
279
|
+
await expect(server.initializeManager("/workspace-one")).resolves.toEqual({
|
|
280
|
+
root: "/workspace-one",
|
|
281
|
+
health: [{ language: "typescript", status: "ready" }],
|
|
125
282
|
});
|
|
126
|
-
await expect(server.initializeManager(
|
|
127
|
-
root:
|
|
128
|
-
health: [{ language:
|
|
283
|
+
await expect(server.initializeManager("/workspace-two")).resolves.toEqual({
|
|
284
|
+
root: "/workspace-two",
|
|
285
|
+
health: [{ language: "python", status: "ready" }],
|
|
129
286
|
});
|
|
130
|
-
expect(order).toEqual([
|
|
287
|
+
expect(order).toEqual(["shutdown-first", "start-second"]);
|
|
131
288
|
});
|
|
132
|
-
it(
|
|
133
|
-
const server = new server_1.McpServer(
|
|
289
|
+
it("tolerates shutdown with no active manager", async () => {
|
|
290
|
+
const server = new server_1.McpServer("info");
|
|
134
291
|
await expect(server.shutdown()).resolves.toBeUndefined();
|
|
135
292
|
});
|
|
136
|
-
it(
|
|
137
|
-
const manager = {
|
|
138
|
-
|
|
293
|
+
it("shuts down active manager on server shutdown", async () => {
|
|
294
|
+
const manager = {
|
|
295
|
+
shutdown: jest.fn().mockResolvedValue(undefined),
|
|
296
|
+
};
|
|
297
|
+
const server = new server_1.McpServer("info");
|
|
139
298
|
server.setManager(manager);
|
|
140
299
|
await server.shutdown();
|
|
141
300
|
expect(manager.shutdown).toHaveBeenCalledTimes(1);
|
|
142
301
|
});
|
|
302
|
+
it("sends tool list changed notification when roots change", async () => {
|
|
303
|
+
const { RootsListChangedNotificationSchema } = jest.requireActual("@modelcontextprotocol/sdk/types.js");
|
|
304
|
+
const health = [{ language: "typescript", status: "ready" }];
|
|
305
|
+
const manager = {
|
|
306
|
+
start: jest.fn().mockResolvedValue(undefined),
|
|
307
|
+
shutdown: jest.fn().mockResolvedValue(undefined),
|
|
308
|
+
getHealth: jest.fn().mockReturnValue(health),
|
|
309
|
+
};
|
|
310
|
+
const factory = jest.fn().mockReturnValue(manager);
|
|
311
|
+
mockGetClientCapabilities.mockReturnValue({ roots: {} });
|
|
312
|
+
mockListRoots.mockResolvedValue({
|
|
313
|
+
roots: [{ uri: "file:///workspace/new-project" }],
|
|
314
|
+
});
|
|
315
|
+
const server = new server_1.McpServer("info", factory);
|
|
316
|
+
await server.start();
|
|
317
|
+
const notificationHandler = mockSetNotificationHandler.mock.calls.find(([s]) => s === RootsListChangedNotificationSchema)?.[1];
|
|
318
|
+
expect(notificationHandler).toBeDefined();
|
|
319
|
+
await notificationHandler({});
|
|
320
|
+
expect(mockSendToolListChanged).toHaveBeenCalled();
|
|
321
|
+
});
|
|
143
322
|
});
|
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,27 @@ 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
|
+
// Hide lsp_init only after an explicit init call.
|
|
48
|
+
this.updateInitVisibility(result.health, "explicit");
|
|
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
|
+
const onInitialized = async () => {
|
|
57
|
+
await this.tryAutoInit();
|
|
58
|
+
};
|
|
59
|
+
this.server.server.oninitialized = () => {
|
|
60
|
+
void onInitialized();
|
|
61
|
+
};
|
|
62
|
+
// Handle roots/list_changed: client switched workspace
|
|
63
|
+
this.server.server.setNotificationHandler(types_js_1.RootsListChangedNotificationSchema, async () => {
|
|
64
|
+
await this.tryAutoInitFromRoots();
|
|
65
|
+
});
|
|
46
66
|
const transport = new stdio_js_1.StdioServerTransport();
|
|
47
67
|
await this.server.connect(transport);
|
|
48
68
|
}
|
|
@@ -68,10 +88,69 @@ class McpServer {
|
|
|
68
88
|
this.currentManager = null;
|
|
69
89
|
this.currentRoot = null;
|
|
70
90
|
this.initialized = false;
|
|
91
|
+
this.hideInitTool = false;
|
|
71
92
|
if (activeManager) {
|
|
72
93
|
await activeManager.shutdown();
|
|
73
94
|
}
|
|
74
95
|
}
|
|
96
|
+
/** True when lsp_init is currently hidden from the tool list. */
|
|
97
|
+
isInitToolHidden() {
|
|
98
|
+
return this.hideInitTool;
|
|
99
|
+
}
|
|
100
|
+
updateInitVisibility(health, source) {
|
|
101
|
+
const hasErrors = health.some((entry) => entry.status === "error");
|
|
102
|
+
this.hideInitTool = source === "explicit" && !hasErrors;
|
|
103
|
+
}
|
|
104
|
+
async tryAutoInit() {
|
|
105
|
+
// Prefer roots from the client if supported
|
|
106
|
+
const caps = this.server.server.getClientCapabilities();
|
|
107
|
+
if (caps?.roots) {
|
|
108
|
+
await this.tryAutoInitFromRoots();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
// Fallback: load the last-used root from persisted config
|
|
112
|
+
try {
|
|
113
|
+
const lastRoot = await (0, workspace_config_1.loadLastRoot)();
|
|
114
|
+
if (lastRoot) {
|
|
115
|
+
await this.autoInitRoot(lastRoot);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// Non-fatal; lsp_init stays visible
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async tryAutoInitFromRoots() {
|
|
123
|
+
try {
|
|
124
|
+
const { roots } = await this.server.server.listRoots();
|
|
125
|
+
const firstUri = roots[0]?.uri;
|
|
126
|
+
if (!firstUri?.startsWith("file://"))
|
|
127
|
+
return;
|
|
128
|
+
// file:///path/to/project → /path/to/project
|
|
129
|
+
const root = decodeURIComponent(new URL(firstUri).pathname);
|
|
130
|
+
await this.autoInitRoot(root);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// Client may not support roots (e.g. Cursor bug) — stay visible
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async autoInitRoot(root) {
|
|
137
|
+
const saved = await (0, workspace_config_1.loadWorkspaceConfig)(root);
|
|
138
|
+
const languages = saved?.languages;
|
|
139
|
+
const result = await this.initializeManager(root, languages);
|
|
140
|
+
this.updateInitVisibility(result.health, "auto");
|
|
141
|
+
// Persist successful init so we can restore on next startup
|
|
142
|
+
const readyLanguages = result.health
|
|
143
|
+
.filter((e) => e.status === "ready")
|
|
144
|
+
.map((e) => e.language);
|
|
145
|
+
await (0, workspace_config_1.saveWorkspaceConfig)(root, { languages: readyLanguages });
|
|
146
|
+
// Notify client that tool list may have changed (lsp_init hidden/shown)
|
|
147
|
+
try {
|
|
148
|
+
await this.server.server.sendToolListChanged();
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// Client may not support list-changed notifications
|
|
152
|
+
}
|
|
153
|
+
}
|
|
75
154
|
configureToolHandlers() {
|
|
76
155
|
this.server.server.registerCapabilities({ tools: {} });
|
|
77
156
|
this.server.server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
|
|
@@ -79,9 +158,6 @@ class McpServer {
|
|
|
79
158
|
}));
|
|
80
159
|
this.server.server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
81
160
|
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
161
|
if (toolName !== "lsp_init" && !this.initialized) {
|
|
86
162
|
return (0, shared_1.noProjectRootResult)();
|
|
87
163
|
}
|
|
@@ -93,7 +169,9 @@ class McpServer {
|
|
|
93
169
|
});
|
|
94
170
|
}
|
|
95
171
|
listTools() {
|
|
96
|
-
return [...this.tools.values()]
|
|
172
|
+
return [...this.tools.values()]
|
|
173
|
+
.filter((tool) => !(tool.name === "lsp_init" && this.hideInitTool))
|
|
174
|
+
.map((tool) => ({
|
|
97
175
|
name: tool.name,
|
|
98
176
|
description: tool.description,
|
|
99
177
|
inputSchema: this.toInputSchema(tool.inputSchema),
|
|
@@ -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
|
+
}
|