@theupsider/lsp-mcp 1.0.9 → 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
 
@@ -45,6 +45,9 @@ function getHandler(schema) {
45
45
  throw new Error(`No handler registered for schema`);
46
46
  return call[1];
47
47
  }
48
+ async function flushAutoInit() {
49
+ await new Promise((resolve) => setImmediate(resolve));
50
+ }
48
51
  describe("McpServer", () => {
49
52
  beforeEach(() => {
50
53
  jest.clearAllMocks();
@@ -87,7 +90,7 @@ describe("McpServer", () => {
87
90
  expect(result.tools.map((t) => t.name)).toContain("lsp_init");
88
91
  expect(result.tools.map((t) => t.name)).toContain("lsp_definition");
89
92
  });
90
- it("hides lsp_init after successful auto-init (all servers ready)", async () => {
93
+ it("keeps lsp_init visible after successful auto-init (all servers ready)", async () => {
91
94
  const readTools = jest.requireMock("../tools/read-tools")
92
95
  .registerReadTools;
93
96
  const writeTools = jest.requireMock("../tools/write-tools")
@@ -114,11 +117,11 @@ describe("McpServer", () => {
114
117
  const { ListToolsRequestSchema } = jest.requireActual("@modelcontextprotocol/sdk/types.js");
115
118
  const server = new server_1.McpServer("info", factory);
116
119
  await server.start();
117
- // Trigger the oninitialized callback
118
- await mockInnerServer.oninitialized();
120
+ mockInnerServer.oninitialized();
121
+ await flushAutoInit();
119
122
  const listHandler = getHandler(ListToolsRequestSchema);
120
123
  const result = await listHandler({});
121
- expect(result.tools.map((t) => t.name)).not.toContain("lsp_init");
124
+ expect(result.tools.map((t) => t.name)).toContain("lsp_init");
122
125
  expect(result.tools.map((t) => t.name)).toContain("lsp_definition");
123
126
  });
124
127
  it("keeps lsp_init visible after auto-init when a server has errors", async () => {
@@ -147,7 +150,8 @@ describe("McpServer", () => {
147
150
  const { ListToolsRequestSchema } = jest.requireActual("@modelcontextprotocol/sdk/types.js");
148
151
  const server = new server_1.McpServer("info", factory);
149
152
  await server.start();
150
- await mockInnerServer.oninitialized();
153
+ mockInnerServer.oninitialized();
154
+ await flushAutoInit();
151
155
  const listHandler = getHandler(ListToolsRequestSchema);
152
156
  const result = await listHandler({});
153
157
  expect(result.tools.map((t) => t.name)).toContain("lsp_init");
@@ -166,9 +170,43 @@ describe("McpServer", () => {
166
170
  mockGetClientCapabilities.mockReturnValue(undefined); // no roots capability
167
171
  const server = new server_1.McpServer("info", factory);
168
172
  await server.start();
169
- await mockInnerServer.oninitialized();
173
+ mockInnerServer.oninitialized();
174
+ await flushAutoInit();
170
175
  expect(manager.start).toHaveBeenCalled();
171
- expect(server.isInitToolHidden()).toBe(true);
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
+ });
207
+ const listHandler = getHandler(ListToolsRequestSchema);
208
+ const result = await listHandler({});
209
+ expect(result.tools.map((t) => t.name)).not.toContain("lsp_init");
172
210
  });
173
211
  it("returns no-root error for non-init tools before initialization", async () => {
174
212
  const readTools = jest.requireMock("../tools/read-tools")
@@ -44,8 +44,8 @@ class McpServer {
44
44
  (0, read_tools_1.registerReadTools)(registrar, lifecycleProxy, {
45
45
  initializeManager: async (root, languages) => {
46
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);
47
+ // Hide lsp_init only after an explicit init call.
48
+ this.updateInitVisibility(result.health, "explicit");
49
49
  await this.server.server.sendToolListChanged();
50
50
  return result;
51
51
  },
@@ -53,9 +53,12 @@ class McpServer {
53
53
  (0, write_tools_1.registerWriteTools)(registrar, lifecycleProxy);
54
54
  this.configureToolHandlers();
55
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());
56
+ const onInitialized = async () => {
57
+ await this.tryAutoInit();
58
+ };
59
+ this.server.server.oninitialized = () => {
60
+ void onInitialized();
61
+ };
59
62
  // Handle roots/list_changed: client switched workspace
60
63
  this.server.server.setNotificationHandler(types_js_1.RootsListChangedNotificationSchema, async () => {
61
64
  await this.tryAutoInitFromRoots();
@@ -94,9 +97,9 @@ class McpServer {
94
97
  isInitToolHidden() {
95
98
  return this.hideInitTool;
96
99
  }
97
- updateInitVisibility(health) {
100
+ updateInitVisibility(health, source) {
98
101
  const hasErrors = health.some((entry) => entry.status === "error");
99
- this.hideInitTool = !hasErrors;
102
+ this.hideInitTool = source === "explicit" && !hasErrors;
100
103
  }
101
104
  async tryAutoInit() {
102
105
  // Prefer roots from the client if supported
@@ -134,7 +137,7 @@ class McpServer {
134
137
  const saved = await (0, workspace_config_1.loadWorkspaceConfig)(root);
135
138
  const languages = saved?.languages;
136
139
  const result = await this.initializeManager(root, languages);
137
- this.updateInitVisibility(result.health);
140
+ this.updateInitVisibility(result.health, "auto");
138
141
  // Persist successful init so we can restore on next startup
139
142
  const readyLanguages = result.health
140
143
  .filter((e) => e.status === "ready")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theupsider/lsp-mcp",
3
- "version": "1.0.9",
3
+ "version": "1.1.0",
4
4
  "description": "Universal LSP MCP server",
5
5
  "license": "Apache-2.0",
6
6
  "type": "commonjs",