@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 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. The first action the model must take is calling `lsp_init`:
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 | Key Parameters |
104
- | ----------------------- | --------------------------------------- | ------------------------------------------------------ |
105
- | `lsp_init` | Initialize server for a project root | `root` (required), `languages` (optional string array) |
106
- | `lsp_definition` | Go to definition | `file`, `line`, `character` |
107
- | `lsp_references` | Find all references | `file`, `line`, `character` |
108
- | `lsp_document_symbols` | List symbols in a file | `file` |
109
- | `lsp_workspace_symbols` | Search symbols across workspace | `query` (limit: 100–500 results) |
110
- | `lsp_diagnostics` | Get errors & warnings | `file` (scope: `file` or `workspace`) |
111
- | `lsp_type_definition` | Go to type definition | `file`, `line`, `character` |
112
- | `lsp_implementation` | Find implementations | `file`, `line`, `character` |
113
- | `lsp_health` | Check status of all LSP servers | _(none)_ |
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 | Description | Key Parameters |
118
- | -------------------------- | ------------------------- | -------------------------------------- |
119
- | `lsp_rename` | Rename symbol | `file`, `line`, `character`, `newName` |
120
- | `lsp_code_action` | Apply / list code actions | `file`, `line`, `character`, `apply` |
121
- | `lsp_formatting` | Format document | `file` |
122
- | `lsp_range_formatting` | Format code range | `file`, `range` |
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.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({
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
- registerCapabilities: mockRegisterCapabilities,
11
- setRequestHandler: mockSetRequestHandler
12
- }
13
- }))
22
+ server: mockInnerServer,
23
+ })),
14
24
  }));
15
- jest.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({
16
- StdioServerTransport: jest.fn().mockImplementation(() => ({ kind: 'stdio' }))
25
+ jest.mock("@modelcontextprotocol/sdk/server/stdio.js", () => ({
26
+ StdioServerTransport: jest.fn().mockImplementation(() => ({ kind: "stdio" })),
17
27
  }));
18
- jest.mock('@modelcontextprotocol/sdk/server/zod-compat.js', () => ({
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('@modelcontextprotocol/sdk/server/zod-json-schema-compat.js', () => ({
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
- describe('McpServer', () => {
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('registers tool capabilities, request handlers, and connects on start', async () => {
38
- const server = new server_1.McpServer('info');
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(mockConnect).toHaveBeenCalledWith({ kind: 'stdio' });
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('lists all registered tools regardless of init state', async () => {
45
- const readTools = jest.requireMock('../tools/read-tools').registerReadTools;
46
- const writeTools = jest.requireMock('../tools/write-tools').registerWriteTools;
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('lsp_init', { description: 'init' }, async () => ({ content: [{ type: 'text', text: 'ok' }], raw: null }));
49
- registrar.registerTool('lsp_definition', { description: 'definition' }, async () => ({ content: [{ type: 'text', text: 'definition' }], raw: null }));
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('@modelcontextprotocol/sdk/types.js');
53
- const server = new server_1.McpServer('info');
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('lsp_init');
58
- expect(result.tools.map((t) => t.name)).toContain('lsp_definition');
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('lists all tools after initialization too', async () => {
61
- const readTools = jest.requireMock('../tools/read-tools').registerReadTools;
62
- const writeTools = jest.requireMock('../tools/write-tools').registerWriteTools;
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('lsp_init', { description: 'init' }, async () => ({ content: [{ type: 'text', text: 'ok' }], raw: null }));
65
- registrar.registerTool('lsp_definition', { description: 'definition' }, async () => ({ content: [{ type: 'text', text: 'definition' }], raw: null }));
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 { ListToolsRequestSchema } = jest.requireActual('@modelcontextprotocol/sdk/types.js');
69
- const server = new server_1.McpServer('info');
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
- server.setManager({});
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('lsp_init');
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('returns no-root error for non-init tools before lsp_init', async () => {
78
- const readTools = jest.requireMock('../tools/read-tools').registerReadTools;
79
- const writeTools = jest.requireMock('../tools/write-tools').registerWriteTools;
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('lsp_definition', { description: 'definition' }, async () => ({ content: [{ type: 'text', text: 'reachable' }], raw: null }));
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('@modelcontextprotocol/sdk/types.js');
85
- const server = new server_1.McpServer('info');
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({ params: { name: 'lsp_definition', arguments: {} } });
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('re-initializes with new root when lsp_init called again', async () => {
92
- const readTools = jest.requireMock('../tools/read-tools').registerReadTools;
93
- const writeTools = jest.requireMock('../tools/write-tools').registerWriteTools;
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('lsp_init', { description: 'init' }, async () => ({ content: [{ type: 'text', text: 'ok' }], raw: null }));
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('@modelcontextprotocol/sdk/types.js');
99
- const server = new server_1.McpServer('info');
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({ params: { name: 'lsp_init', arguments: { root: '/x' } } });
104
- expect(result.content[0].text).toMatch(/Already initialized/);
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('shuts down the old manager before starting a new one', async () => {
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 () => { order.push('shutdown-first'); }),
111
- getHealth: jest.fn().mockReturnValue([{ language: 'typescript', status: 'ready' }])
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 () => { order.push('start-second'); }),
266
+ start: jest.fn().mockImplementation(async () => {
267
+ order.push("start-second");
268
+ }),
115
269
  shutdown: jest.fn().mockResolvedValue(undefined),
116
- getHealth: jest.fn().mockReturnValue([{ language: 'python', status: 'ready' }])
270
+ getHealth: jest
271
+ .fn()
272
+ .mockReturnValue([{ language: "python", status: "ready" }]),
117
273
  };
118
- const factory = jest.fn()
274
+ const factory = jest
275
+ .fn()
119
276
  .mockReturnValueOnce(firstManager)
120
277
  .mockReturnValueOnce(secondManager);
121
- const server = new server_1.McpServer('debug', factory);
122
- await expect(server.initializeManager('/workspace-one')).resolves.toEqual({
123
- root: '/workspace-one',
124
- health: [{ language: 'typescript', status: 'ready' }]
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('/workspace-two')).resolves.toEqual({
127
- root: '/workspace-two',
128
- health: [{ language: 'python', status: 'ready' }]
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(['shutdown-first', 'start-second']);
287
+ expect(order).toEqual(["shutdown-first", "start-second"]);
131
288
  });
132
- it('tolerates shutdown with no active manager', async () => {
133
- const server = new server_1.McpServer('info');
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('shuts down active manager on server shutdown', async () => {
137
- const manager = { shutdown: jest.fn().mockResolvedValue(undefined) };
138
- const server = new server_1.McpServer('info');
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
  });
@@ -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) => await this.initializeManager(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()].map((tool) => ({
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theupsider/lsp-mcp",
3
- "version": "1.0.8",
3
+ "version": "1.1.0",
4
4
  "description": "Universal LSP MCP server",
5
5
  "license": "Apache-2.0",
6
6
  "type": "commonjs",