# Directory Structure
```
├── .gitignore
├── app
│ └── Server.hs
├── index.ts
├── LICENSE
├── lsp-mcp.cabal
├── package.json
├── README.md
├── src
│ ├── extensions
│ │ ├── haskell.ts
│ │ └── index.ts
│ ├── logging
│ │ └── index.ts
│ ├── lspClient.ts
│ ├── prompts
│ │ └── index.ts
│ ├── resources
│ │ └── index.ts
│ ├── tools
│ │ └── index.ts
│ └── types
│ └── index.ts
├── test
│ ├── prompts.test.js
│ ├── ts-project
│ │ ├── src
│ │ │ └── example.ts
│ │ └── tsconfig.json
│ └── typescript-lsp.test.js
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | dist-newstyle/
2 | node_modules/
3 | dist/
4 | .mcp.json
5 |
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # LSP MCP Server
2 |
3 | An MCP (Model Context Protocol) server for interacting with LSP (Language Server Protocol) interface.
4 | This server acts as a bridge that allows LLMs to query LSP Hover and Completion providers.
5 |
6 | ## Overview
7 |
8 | The MCP Server works by:
9 | 1. Starting an LSP client that connects to a LSP server
10 | 2. Exposing MCP tools that send requests to the LSP server
11 | 3. Returning the results in a format that LLMs can understand and use
12 |
13 | This enables LLMs to utilize LSPs for more accurate code suggestions.
14 |
15 |
16 | ## Configuration:
17 |
18 | ```json
19 | {
20 | "mcpServers": {
21 | "lsp-mcp": {
22 | "type": "stdio",
23 | "command": "npx",
24 | "args": [
25 | "tritlo/lsp-mcp",
26 | "<language-id>",
27 | "<path-to-lsp>",
28 | "<lsp-args>"
29 | ]
30 | }
31 | }
32 | }
33 | ```
34 |
35 |
36 | ## Features
37 |
38 | ### MCP Tools
39 | - `get_info_on_location`: Get hover information at a specific location in a file
40 | - `get_completions`: Get completion suggestions at a specific location in a file
41 | - `get_code_actions`: Get code actions for a specific range in a file
42 | - `open_document`: Open a file in the LSP server for analysis
43 | - `close_document`: Close a file in the LSP server
44 | - `get_diagnostics`: Get diagnostic messages (errors, warnings) for open files
45 | - `start_lsp`: Start the LSP server with a specified root directory
46 | - `restart_lsp_server`: Restart the LSP server without restarting the MCP server
47 | - `set_log_level`: Change the server's logging verbosity level at runtime
48 |
49 | ### MCP Resources
50 | - `lsp-diagnostics://` resources for accessing diagnostic messages with real-time updates via subscriptions
51 | - `lsp-hover://` resources for retrieving hover information at specific file locations
52 | - `lsp-completions://` resources for getting code completion suggestions at specific positions
53 |
54 | ### Additional Features
55 | - Comprehensive logging system with multiple severity levels
56 | - Colorized console output for better readability
57 | - Runtime-configurable log level
58 | - Detailed error handling and reporting
59 | - Simple command-line interface
60 |
61 | ## Prerequisites
62 |
63 | - Node.js (v16 or later)
64 | - npm
65 |
66 | For the demo server:
67 | - GHC (8.10 or later)
68 | - Cabal (3.0 or later)
69 |
70 | ## Installation
71 |
72 | ### Building the MCP Server
73 |
74 | 1. Clone this repository:
75 | ```
76 | git clone https://github.com/your-username/lsp-mcp.git
77 | cd lsp-mcp
78 | ```
79 |
80 | 2. Install dependencies:
81 | ```
82 | npm install
83 | ```
84 |
85 | 3. Build the MCP server:
86 | ```
87 | npm run build
88 | ```
89 |
90 | ## Testing
91 |
92 | The project includes integration tests for the TypeScript LSP support. These tests verify that the LSP-MCP server correctly handles LSP operations like hover information, completions, diagnostics, and code actions.
93 |
94 | ### Running Tests
95 |
96 | To run the TypeScript LSP tests:
97 |
98 | ```
99 | npm test
100 | ```
101 |
102 | or specifically:
103 |
104 | ```
105 | npm run test:typescript
106 | ```
107 |
108 | ### Test Coverage
109 |
110 | The tests verify the following functionality:
111 | - Initializing the TypeScript LSP with a mock project
112 | - Opening TypeScript files for analysis
113 | - Getting hover information for functions and types
114 | - Getting code completion suggestions
115 | - Getting diagnostic error messages
116 | - Getting code actions for errors
117 |
118 | The test project is located in `test/ts-project/` and contains TypeScript files with intentional errors to test diagnostic feedback.
119 |
120 | ## Usage
121 |
122 | Run the MCP server by providing the path to the LSP executable and any arguments to pass to the LSP server:
123 |
124 | ```
125 | npx tritlo/lsp-mcp <language> /path/to/lsp [lsp-args...]
126 | ```
127 |
128 | For example:
129 | ```
130 | npx tritlo/lsp-mcp haskell /usr/bin/haskell-language-server-wrapper lsp
131 | ```
132 |
133 | ### Important: Starting the LSP Server
134 |
135 | With version 0.2.0 and later, you must explicitly start the LSP server by calling the `start_lsp` tool before using any LSP functionality. This ensures proper initialization with the correct root directory, which is especially important when using tools like npx:
136 |
137 | ```json
138 | {
139 | "tool": "start_lsp",
140 | "arguments": {
141 | "root_dir": "/path/to/your/project"
142 | }
143 | }
144 | ```
145 |
146 | ### Logging
147 |
148 | The server includes a comprehensive logging system with 8 severity levels:
149 | - `debug`: Detailed information for debugging purposes
150 | - `info`: General informational messages about system operation
151 | - `notice`: Significant operational events
152 | - `warning`: Potential issues that might need attention
153 | - `error`: Error conditions that affect operation but don't halt the system
154 | - `critical`: Critical conditions requiring immediate attention
155 | - `alert`: System is in an unstable state
156 | - `emergency`: System is unusable
157 |
158 | By default, logs are sent to:
159 | 1. Console output with color-coding for better readability
160 | 2. MCP notifications to the client (via the `notifications/message` method)
161 |
162 | #### Viewing Debug Logs
163 |
164 | For detailed debugging, you can:
165 |
166 | 1. Use the `claude --mcp-debug` flag when running Claude to see all MCP traffic between Claude and the server:
167 | ```
168 | claude --mcp-debug
169 | ```
170 |
171 | 2. Change the log level at runtime using the `set_log_level` tool:
172 | ```json
173 | {
174 | "tool": "set_log_level",
175 | "arguments": {
176 | "level": "debug"
177 | }
178 | }
179 | ```
180 |
181 | The default log level is `info`, which shows moderate operational detail while filtering out verbose debug messages.
182 |
183 | ## API
184 |
185 | The server provides the following MCP tools:
186 |
187 | ### get_info_on_location
188 |
189 | Gets hover information at a specific location in a file.
190 |
191 | Parameters:
192 | - `file_path`: Path to the file
193 | - `language_id`: The programming language the file is written in (e.g., "haskell")
194 | - `line`: Line number
195 | - `column`: Column position
196 |
197 | Example:
198 | ```json
199 | {
200 | "tool": "get_info_on_location",
201 | "arguments": {
202 | "file_path": "/path/to/your/file",
203 | "language_id": "haskell",
204 | "line": 3,
205 | "column": 5
206 | }
207 | }
208 | ```
209 |
210 | ### get_completions
211 |
212 | Gets completion suggestions at a specific location in a file.
213 |
214 | Parameters:
215 | - `file_path`: Path to the file
216 | - `language_id`: The programming language the file is written in (e.g., "haskell")
217 | - `line`: Line number
218 | - `column`: Column position
219 |
220 | Example:
221 | ```json
222 | {
223 | "tool": "get_completions",
224 | "arguments": {
225 | "file_path": "/path/to/your/file",
226 | "language_id": "haskell",
227 | "line": 3,
228 | "column": 10
229 | }
230 | }
231 | ```
232 |
233 | ### get_code_actions
234 |
235 | Gets code actions for a specific range in a file.
236 |
237 | Parameters:
238 | - `file_path`: Path to the file
239 | - `language_id`: The programming language the file is written in (e.g., "haskell")
240 | - `start_line`: Start line number
241 | - `start_column`: Start column position
242 | - `end_line`: End line number
243 | - `end_column`: End column position
244 |
245 | Example:
246 | ```json
247 | {
248 | "tool": "get_code_actions",
249 | "arguments": {
250 | "file_path": "/path/to/your/file",
251 | "language_id": "haskell",
252 | "start_line": 3,
253 | "start_column": 5,
254 | "end_line": 3,
255 | "end_column": 10
256 | }
257 | }
258 | ```
259 |
260 | ### start_lsp
261 |
262 | Starts the LSP server with a specified root directory. This must be called before using any other LSP-related tools.
263 |
264 | Parameters:
265 | - `root_dir`: The root directory for the LSP server (absolute path recommended)
266 |
267 | Example:
268 | ```json
269 | {
270 | "tool": "start_lsp",
271 | "arguments": {
272 | "root_dir": "/path/to/your/project"
273 | }
274 | }
275 | ```
276 |
277 | ### restart_lsp_server
278 |
279 | Restarts the LSP server process without restarting the MCP server. This is useful for recovering from LSP server issues or for applying changes to the LSP server configuration.
280 |
281 | Parameters:
282 | - `root_dir`: (Optional) The root directory for the LSP server. If provided, the server will be initialized with this directory after restart.
283 |
284 | Example without root_dir (uses previously set root directory):
285 | ```json
286 | {
287 | "tool": "restart_lsp_server",
288 | "arguments": {}
289 | }
290 | ```
291 |
292 | Example with root_dir:
293 | ```json
294 | {
295 | "tool": "restart_lsp_server",
296 | "arguments": {
297 | "root_dir": "/path/to/your/project"
298 | }
299 | }
300 | ```
301 |
302 | ### open_document
303 |
304 | Opens a file in the LSP server for analysis. This must be called before accessing diagnostics or performing other operations on the file.
305 |
306 | Parameters:
307 | - `file_path`: Path to the file to open
308 | - `language_id`: The programming language the file is written in (e.g., "haskell")
309 |
310 | Example:
311 | ```json
312 | {
313 | "tool": "open_document",
314 | "arguments": {
315 | "file_path": "/path/to/your/file",
316 | "language_id": "haskell"
317 | }
318 | }
319 | ```
320 |
321 | ### close_document
322 |
323 | Closes a file in the LSP server when you're done working with it. This helps manage resources and cleanup.
324 |
325 | Parameters:
326 | - `file_path`: Path to the file to close
327 |
328 | Example:
329 | ```json
330 | {
331 | "tool": "close_document",
332 | "arguments": {
333 | "file_path": "/path/to/your/file"
334 | }
335 | }
336 | ```
337 |
338 | ### get_diagnostics
339 |
340 | Gets diagnostic messages (errors, warnings) for one or all open files.
341 |
342 | Parameters:
343 | - `file_path`: (Optional) Path to the file to get diagnostics for. If not provided, returns diagnostics for all open files.
344 |
345 | Example for a specific file:
346 | ```json
347 | {
348 | "tool": "get_diagnostics",
349 | "arguments": {
350 | "file_path": "/path/to/your/file"
351 | }
352 | }
353 | ```
354 |
355 | Example for all open files:
356 | ```json
357 | {
358 | "tool": "get_diagnostics",
359 | "arguments": {}
360 | }
361 | ```
362 |
363 | ### set_log_level
364 |
365 | Sets the server's logging level to control verbosity of log messages.
366 |
367 | Parameters:
368 | - `level`: The logging level to set. One of: `debug`, `info`, `notice`, `warning`, `error`, `critical`, `alert`, `emergency`.
369 |
370 | Example:
371 | ```json
372 | {
373 | "tool": "set_log_level",
374 | "arguments": {
375 | "level": "debug"
376 | }
377 | }
378 | ```
379 |
380 | ## MCP Resources
381 |
382 | In addition to tools, the server provides resources for accessing LSP features including diagnostics, hover information, and code completions:
383 |
384 | ### Diagnostic Resources
385 |
386 | The server exposes diagnostic information via the `lsp-diagnostics://` resource scheme. These resources can be subscribed to for real-time updates when diagnostics change.
387 |
388 | Resource URIs:
389 | - `lsp-diagnostics://` - Diagnostics for all open files
390 | - `lsp-diagnostics:///path/to/file` - Diagnostics for a specific file
391 |
392 | Important: Files must be opened using the `open_document` tool before diagnostics can be accessed.
393 |
394 | ### Hover Information Resources
395 |
396 | The server exposes hover information via the `lsp-hover://` resource scheme. This allows you to get information about code elements at specific positions in files.
397 |
398 | Resource URI format:
399 | ```
400 | lsp-hover:///path/to/file?line={line}&column={column}&language_id={language_id}
401 | ```
402 |
403 | Parameters:
404 | - `line`: Line number (1-based)
405 | - `column`: Column position (1-based)
406 | - `language_id`: The programming language (e.g., "haskell")
407 |
408 | Example:
409 | ```
410 | lsp-hover:///home/user/project/src/Main.hs?line=42&column=10&language_id=haskell
411 | ```
412 |
413 | ### Code Completion Resources
414 |
415 | The server exposes code completion suggestions via the `lsp-completions://` resource scheme. This allows you to get completion candidates at specific positions in files.
416 |
417 | Resource URI format:
418 | ```
419 | lsp-completions:///path/to/file?line={line}&column={column}&language_id={language_id}
420 | ```
421 |
422 | Parameters:
423 | - `line`: Line number (1-based)
424 | - `column`: Column position (1-based)
425 | - `language_id`: The programming language (e.g., "haskell")
426 |
427 | Example:
428 | ```
429 | lsp-completions:///home/user/project/src/Main.hs?line=42&column=10&language_id=haskell
430 | ```
431 |
432 | ### Listing Available Resources
433 |
434 | To discover available resources, use the MCP `resources/list` endpoint. The response will include all available resources for currently open files, including:
435 | - Diagnostics resources for all open files
436 | - Hover information templates for all open files
437 | - Code completion templates for all open files
438 |
439 | ### Subscribing to Resource Updates
440 |
441 | Diagnostic resources support subscriptions to receive real-time updates when diagnostics change (e.g., when files are modified and new errors or warnings appear). Subscribe to diagnostic resources using the MCP `resources/subscribe` endpoint.
442 |
443 | Note: Hover and completion resources don't support subscriptions as they represent point-in-time queries.
444 |
445 | ### Working with Resources vs. Tools
446 |
447 | You can choose between two approaches for accessing LSP features:
448 |
449 | 1. Tool-based approach: Use the `get_diagnostics`, `get_info_on_location`, and `get_completions` tools for a simple, direct way to fetch information.
450 | 2. Resource-based approach: Use the `lsp-diagnostics://`, `lsp-hover://`, and `lsp-completions://` resources for a more RESTful approach.
451 |
452 | Both approaches provide the same data in the same format and enforce the same requirement that files must be opened first.
453 |
454 |
455 | ## Troubleshooting
456 |
457 | - If the server fails to start, make sure the path to the LSP executable is correct
458 | - Check the log file (if configured) for detailed error messages
459 |
460 | ## License
461 |
462 | MIT License
463 |
464 |
465 |
466 | ## Extensions
467 |
468 | The LSP-MCP server supports language-specific extensions that enhance its capabilities for different programming languages. Extensions can provide:
469 |
470 | - Custom LSP-specific tools and functionality
471 | - Language-specific resource handlers and templates
472 | - Specialized prompts for language-related tasks
473 | - Custom subscription handlers for real-time data
474 |
475 | ### Available Extensions
476 |
477 | Currently, the following extensions are available:
478 |
479 | - **Haskell**: Provides specialized prompts for Haskell development, including typed-hole exploration guidance
480 |
481 | ### Using Extensions
482 |
483 | Extensions are loaded automatically when you specify a language ID when starting the server:
484 |
485 | ```
486 | npx tritlo/lsp-mcp haskell /path/to/haskell-language-server-wrapper lsp
487 | ```
488 |
489 | ### Extension Namespacing
490 |
491 | All extension-provided features are namespaced with the language ID. For example, the Haskell extension's typed-hole prompt is available as `haskell.typed-hole-use`.
492 |
493 | ### Creating New Extensions
494 |
495 | To create a new extension:
496 |
497 | 1. Create a new TypeScript file in `src/extensions/` named after your language (e.g., `typescript.ts`)
498 | 2. Implement the Extension interface with any of these optional functions:
499 | - `getToolHandlers()`: Provide custom tool implementations
500 | - `getToolDefinitions()`: Define custom tools in the MCP API
501 | - `getResourceHandlers()`: Implement custom resource handlers
502 | - `getSubscriptionHandlers()`: Implement custom subscription handlers
503 | - `getUnsubscriptionHandlers()`: Implement custom unsubscription handlers
504 | - `getResourceTemplates()`: Define custom resource templates
505 | - `getPromptDefinitions()`: Define custom prompts for language tasks
506 | - `getPromptHandlers()`: Implement custom prompt handlers
507 |
508 | 3. Export your implementation functions
509 |
510 | The extension system will automatically load your extension when the matching language ID is specified.
511 |
512 | ## Acknowledgments
513 |
514 | - HLS team for the Language Server Protocol implementation
515 | - Anthropic for the Model Context Protocol specification
516 |
```
--------------------------------------------------------------------------------
/test/ts-project/tsconfig.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "compilerOptions": {
3 | "target": "es2020",
4 | "module": "esnext",
5 | "moduleResolution": "node",
6 | "esModuleInterop": true,
7 | "strict": true,
8 | "outDir": "./dist",
9 | "sourceMap": true
10 | },
11 | "include": ["src/**/*"]
12 | }
```
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "compilerOptions": {
3 | "outDir": "./dist",
4 | "rootDir": ".",
5 | "moduleResolution": "NodeNext",
6 | "module": "NodeNext",
7 | "target": "ES2022",
8 | "strict": true,
9 | "esModuleInterop": true,
10 | "skipLibCheck": true,
11 | "forceConsistentCasingInFileNames": true
12 | },
13 | "include": ["./index.ts", "./src/**/*.ts"]
14 | }
```
--------------------------------------------------------------------------------
/test/ts-project/src/example.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * A simple function that adds two numbers
3 | */
4 | export function add(a: number, b: number): number {
5 | return a + b;
6 | }
7 |
8 | /**
9 | * An interface representing a person
10 | */
11 | export interface Person {
12 | name: string;
13 | age: number;
14 | email?: string;
15 | }
16 |
17 | /**
18 | * A class representing a greeter
19 | */
20 | export class Greeter {
21 | private greeting: string;
22 |
23 | constructor(greeting: string) {
24 | this.greeting = greeting;
25 | }
26 |
27 | /**
28 | * Greets a person
29 | */
30 | greet(person: Person): string {
31 | return `${this.greeting}, ${person.name}!`;
32 | }
33 | }
34 |
35 | // This will generate a diagnostic error - missing return type
36 | export function multiply(a: number, b: number) {
37 | return a * b;
38 | }
39 |
40 | // This will generate a diagnostic error - unused variable
41 | const unused = "This variable is not used";
42 |
43 | // This will generate a diagnostic error - undefined variable
44 | const result = undefinedVariable + 10;
45 |
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "lsp-mcp-server",
3 | "version": "0.2.0",
4 | "description": "MCP server for Language Server Protocol (LSP) integration, providing hover information, code completions, diagnostics, and code actions with resource-based access",
5 | "license": "MIT",
6 | "type": "module",
7 | "bin": {
8 | "lsp-mcp-server": "dist/index.js"
9 | },
10 | "files": [
11 | "dist"
12 | ],
13 | "scripts": {
14 | "build": "tsc",
15 | "prepare": "npm run build",
16 | "watch": "tsc --watch",
17 | "test": "npm run test:typescript && npm run test:prompts",
18 | "test:typescript": "node test/typescript-lsp.test.js",
19 | "test:prompts": "node test/prompts.test.js"
20 | },
21 | "dependencies": {
22 | "@modelcontextprotocol/sdk": "^0.5.0",
23 | "zod": "^3.22.4",
24 | "zod-to-json-schema": "^3.24.5"
25 | },
26 | "devDependencies": {
27 | "@types/node": "^22",
28 | "typescript": "^5.3.3",
29 | "typescript-language-server": "^4.3.4"
30 | }
31 | }
32 |
```
--------------------------------------------------------------------------------
/src/extensions/haskell.ts:
--------------------------------------------------------------------------------
```typescript
1 | // Haskell extension for LSP-MCP
2 | import {
3 | ToolHandler,
4 | ResourceHandler,
5 | SubscriptionHandler,
6 | UnsubscriptionHandler,
7 | PromptHandler,
8 | Prompt,
9 | ToolInput
10 | } from "../types/index.js";
11 |
12 | // This extension provides no additional tools, resources, or prompts
13 | // It's just a simple example of the extension structure
14 |
15 | // Export tool handlers
16 | export const getToolHandlers = (): Record<string, { schema: any, handler: ToolHandler }> => {
17 | return {};
18 | };
19 |
20 | // Export tool definitions
21 | export const getToolDefinitions = (): Array<{
22 | name: string;
23 | description: string;
24 | inputSchema: ToolInput;
25 | }> => {
26 | return [];
27 | };
28 |
29 | // Export resource handlers
30 | export const getResourceHandlers = (): Record<string, ResourceHandler> => {
31 | return {};
32 | };
33 |
34 | // Export subscription handlers
35 | export const getSubscriptionHandlers = (): Record<string, SubscriptionHandler> => {
36 | return {};
37 | };
38 |
39 | // Export unsubscription handlers
40 | export const getUnsubscriptionHandlers = (): Record<string, UnsubscriptionHandler> => {
41 | return {};
42 | };
43 |
44 | // Export resource templates
45 | export const getResourceTemplates = (): Array<{
46 | name: string;
47 | scheme: string;
48 | pattern: string;
49 | description: string;
50 | subscribe: boolean;
51 | }> => {
52 | return [];
53 | };
54 |
55 | // Export prompt definitions
56 | export const getPromptDefinitions = (): Prompt[] => {
57 | return [
58 | {
59 | name: "typed-hole-use",
60 | description: "Guide on using typed-holes in Haskell to explore type information and function possibilities"
61 | }
62 | ];
63 | };
64 |
65 | // Export prompt handlers
66 | export const getPromptHandlers = (): Record<string, PromptHandler> => {
67 | return {
68 | "typed-hole-use": async (args?: Record<string,string>) => {
69 | return {
70 | messages: [
71 | {
72 | role: "user",
73 | content: {
74 | type: "text",
75 | text: `
76 | Please use a typed-hole to synthesize replacement code for this expression.
77 |
78 | You do this by replacing the expression with a hole \`_mcp_typed_hole\`
79 | and calling the code action on the location of the hole.
80 |
81 | Make sure you call it on the hole, i.e. the line should be the actual line of the hole
82 | and the column should one
83 |
84 | Then, looking at the labels that the code-action returns,
85 | you can see the identifiers that can be used to fill in the hole.
86 | `
87 | }
88 | },
89 | ]
90 | };
91 | }
92 | };
93 | };
94 |
```
--------------------------------------------------------------------------------
/src/prompts/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | // Prompts module for LSP MCP
2 | import { Prompt, PromptHandler } from "../types/index.js";
3 | import { debug, info } from "../logging/index.js";
4 |
5 | // Enum for prompt names
6 | enum PromptName {
7 | LSP_GUIDE = "lsp_guide",
8 | LANGUAGE_HELP = "language_help",
9 | }
10 |
11 | // Get prompt definitions for the server
12 | export const getPromptDefinitions = (): Prompt[] => {
13 | return [
14 | {
15 | name: PromptName.LSP_GUIDE,
16 | description: "A guide on how to use the LSP (Language Server Protocol) functions available through this MCP server",
17 | }
18 | ];
19 | };
20 |
21 | // Define handlers for each prompt
22 | export const getPromptHandlers = (): Record<string, PromptHandler> => {
23 | return {
24 | [PromptName.LSP_GUIDE]: async () => {
25 | debug(`Handling LSP guide prompt`);
26 |
27 | return {
28 | messages: [
29 | {
30 | role: "user",
31 | content: {
32 | type: "text",
33 | text: "How do I use the LSP functions in this server?",
34 | },
35 | },
36 | {
37 | role: "assistant",
38 | content: {
39 | type: "text",
40 | text: `# LSP MCP Server Guide
41 |
42 | This server provides access to Language Server Protocol (LSP) features through MCP tools. Here's how to use them:
43 |
44 | ## Getting Started
45 |
46 | 1. First, start the LSP server with a root directory:
47 | \`\`\`
48 | start_lsp(root_dir: "/path/to/your/project")
49 | \`\`\`
50 |
51 | 2. Open a file for analysis:
52 | \`\`\`
53 | open_document(file_path: "/path/to/your/project/src/file.ts", language_id: "typescript")
54 | \`\`\`
55 |
56 | ## Available Tools
57 |
58 | - **get_info_on_location**: Get hover information (types, documentation) at a specific position
59 | - **get_completions**: Get code completion suggestions at a cursor position
60 | - **get_code_actions**: Get available code refactorings and quick fixes for a selection
61 | - **get_diagnostics**: Get errors and warnings for open files
62 | - **open_document**: Open a file for analysis
63 | - **close_document**: Close a file when done
64 | - **restart_lsp_server**: Restart the LSP server if needed
65 | - **set_log_level**: Control the server's logging verbosity
66 |
67 | ## Workflow Example
68 |
69 | 1. Start LSP: \`start_lsp(root_dir: "/my/project")\`
70 | 2. Open file: \`open_document(file_path: "/my/project/src/app.ts", language_id: "typescript")\`
71 | 3. Get diagnostics: \`get_diagnostics(file_path: "/my/project/src/app.ts")\`
72 | 4. Get hover info: \`get_info_on_location(file_path: "/my/project/src/app.ts", line: 10, character: 15, language_id: "typescript")\`
73 | 5. Get completions: \`get_completions(file_path: "/my/project/src/app.ts", line: 12, character: 8, language_id: "typescript")\`
74 | 6. Close file when done: \`close_document(file_path: "/my/project/src/app.ts")\`
75 |
76 | Remember that line and character positions are 1-based (first line is 1, first character is 1), but LSP internally uses 0-based positions.`,
77 | },
78 | },
79 | ],
80 | };
81 | },
82 | };
83 | };
84 |
```
--------------------------------------------------------------------------------
/src/types/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | // Type definitions
2 |
3 | import { z } from "zod";
4 | import { ToolSchema } from "@modelcontextprotocol/sdk/types.js";
5 |
6 | // LSP message handling
7 | export interface LSPMessage {
8 | jsonrpc: string;
9 | id?: number | string;
10 | method?: string;
11 | params?: any;
12 | result?: any;
13 | error?: any;
14 | }
15 |
16 | // Define a type for diagnostic subscribers
17 | export type DiagnosticUpdateCallback = (uri: string, diagnostics: any[]) => void;
18 |
19 | // Define a type for subscription context
20 | export interface SubscriptionContext {
21 | callback: DiagnosticUpdateCallback;
22 | }
23 |
24 | // Logging level type
25 | export type LoggingLevel = 'debug' | 'info' | 'notice' | 'warning' | 'error' | 'critical' | 'alert' | 'emergency';
26 |
27 | // Tool input type
28 | export const ToolInputSchema = ToolSchema.shape.inputSchema;
29 | export type ToolInput = z.infer<typeof ToolInputSchema>;
30 |
31 | // Tool handler types
32 | export type ToolHandler = (args: any) => Promise<{ content: Array<{ type: string, text: string }>, isError?: boolean }>;
33 |
34 | // Resource handler type
35 | export type ResourceHandler = (uri: string) => Promise<{ contents: Array<{ type: string, text: string, uri: string }> }>;
36 |
37 | // Subscription handler type
38 | export type SubscriptionHandler = (uri: string) => Promise<{ ok: boolean, context?: SubscriptionContext, error?: string }>;
39 |
40 | // Unsubscription handler type
41 | export type UnsubscriptionHandler = (uri: string, context: any) => Promise<{ ok: boolean, error?: string }>;
42 |
43 | // Prompt types
44 | export interface Prompt {
45 | name: string;
46 | description: string;
47 | arguments?: Array<{
48 | name: string;
49 | description: string;
50 | required: boolean;
51 | }>;
52 | }
53 |
54 | export type PromptHandler = (args?: Record<string, string>) => Promise<{
55 | messages: Array<{
56 | role: string;
57 | content: {
58 | type: string;
59 | text: string;
60 | };
61 | }>;
62 | }>;
63 |
64 | // Schema definitions
65 | export const GetInfoOnLocationArgsSchema = z.object({
66 | file_path: z.string().describe("Path to the file"),
67 | language_id: z.string().describe("The programming language the file is written in"),
68 | line: z.number().describe(`Line number`),
69 | column: z.number().describe(`Column position`),
70 | });
71 |
72 | export const GetCompletionsArgsSchema = z.object({
73 | file_path: z.string().describe(`Path to the file`),
74 | language_id: z.string().describe(`The programming language the file is written in`),
75 | line: z.number().describe(`Line number`),
76 | column: z.number().describe(`Column position`),
77 | });
78 |
79 | export const GetCodeActionsArgsSchema = z.object({
80 | file_path: z.string().describe(`Path to the file`),
81 | language_id: z.string().describe(`The programming language the file is written in`),
82 | start_line: z.number().describe(`Start line number`),
83 | start_column: z.number().describe(`Start column position`),
84 | end_line: z.number().describe(`End line number`),
85 | end_column: z.number().describe(`End column position`),
86 | });
87 |
88 | export const OpenDocumentArgsSchema = z.object({
89 | file_path: z.string().describe(`Path to the file to open`),
90 | language_id: z.string().describe(`The programming language the file is written in`),
91 | });
92 |
93 | export const CloseDocumentArgsSchema = z.object({
94 | file_path: z.string().describe(`Path to the file to close`),
95 | });
96 |
97 | export const GetDiagnosticsArgsSchema = z.object({
98 | file_path: z.string().optional().describe(`Path to the file to get diagnostics for. If not provided, returns diagnostics for all open files.`),
99 | });
100 |
101 | export const SetLogLevelArgsSchema = z.object({
102 | level: z.enum(['debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency'])
103 | .describe("The logging level to set")
104 | });
105 |
106 | export const RestartLSPServerArgsSchema = z.object({
107 | root_dir: z.string().optional().describe("The root directory for the LSP server. If not provided, the server will not be initialized automatically."),
108 | });
109 |
110 | export const StartLSPArgsSchema = z.object({
111 | root_dir: z.string().describe("The root directory for the LSP server"),
112 | });
113 |
```
--------------------------------------------------------------------------------
/src/logging/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { LoggingLevel } from "../types/index.js";
2 |
3 | // Store original console methods before we do anything else
4 | const originalConsoleLog = console.log;
5 | const originalConsoleWarn = console.warn;
6 | const originalConsoleError = console.error;
7 |
8 | // Current log level - can be changed at runtime
9 | // Initialize with default or from environment variable
10 | let logLevel: LoggingLevel = (process.env.LOG_LEVEL as LoggingLevel) || 'info';
11 |
12 | // Validate that the log level is valid, default to 'info' if not
13 | if (!['debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency'].includes(logLevel)) {
14 | logLevel = 'info';
15 | }
16 |
17 | // Map of log levels and their priorities (higher number = higher priority)
18 | const LOG_LEVEL_PRIORITY: Record<LoggingLevel, number> = {
19 | 'debug': 0,
20 | 'info': 1,
21 | 'notice': 2,
22 | 'warning': 3,
23 | 'error': 4,
24 | 'critical': 5,
25 | 'alert': 6,
26 | 'emergency': 7
27 | };
28 |
29 | // Check if message should be logged based on current level
30 | const shouldLog = (level: LoggingLevel): boolean => {
31 | return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[logLevel];
32 | };
33 |
34 | // Reference to the server for sending notifications
35 | let serverInstance: any = null;
36 |
37 | // Set the server instance for notifications
38 | export const setServer = (server: any): void => {
39 | serverInstance = server;
40 | };
41 |
42 | // Flag to prevent recursion in logging
43 | let isLogging = false;
44 |
45 | // Core logging function
46 | export const log = (level: LoggingLevel, ...args: any[]): void => {
47 | if (!shouldLog(level)) return;
48 |
49 | const timestamp = new Date().toISOString();
50 | const message = args.map(arg =>
51 | typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg)
52 | ).join(' ');
53 |
54 | // Format for console output with color coding
55 | let consoleMethod = originalConsoleLog; // Use original methods to prevent recursion
56 | let consolePrefix = '';
57 |
58 | switch(level) {
59 | case 'debug':
60 | consolePrefix = '\x1b[90m[DEBUG]\x1b[0m'; // Gray
61 | consoleMethod = originalConsoleWarn || originalConsoleLog;
62 | break;
63 | case 'info':
64 | consolePrefix = '\x1b[36m[INFO]\x1b[0m'; // Cyan
65 | break;
66 | case 'notice':
67 | consolePrefix = '\x1b[32m[NOTICE]\x1b[0m'; // Green
68 | break;
69 | case 'warning':
70 | consolePrefix = '\x1b[33m[WARNING]\x1b[0m'; // Yellow
71 | consoleMethod = originalConsoleWarn || originalConsoleLog;
72 | break;
73 | case 'error':
74 | consolePrefix = '\x1b[31m[ERROR]\x1b[0m'; // Red
75 | consoleMethod = originalConsoleError;
76 | break;
77 | case 'critical':
78 | consolePrefix = '\x1b[41m\x1b[37m[CRITICAL]\x1b[0m'; // White on red
79 | consoleMethod = originalConsoleError;
80 | break;
81 | case 'alert':
82 | consolePrefix = '\x1b[45m\x1b[37m[ALERT]\x1b[0m'; // White on purple
83 | consoleMethod = originalConsoleError;
84 | break;
85 | case 'emergency':
86 | consolePrefix = '\x1b[41m\x1b[1m[EMERGENCY]\x1b[0m'; // Bold white on red
87 | consoleMethod = originalConsoleError;
88 | break;
89 | }
90 |
91 | consoleMethod(`${consolePrefix} ${message}`);
92 |
93 | // Send notification to MCP client if server is available and initialized
94 | if (serverInstance && typeof serverInstance.notification === 'function') {
95 | try {
96 | serverInstance.notification({
97 | method: "notifications/message",
98 | params: {
99 | level,
100 | logger: "lsp-mcp-server",
101 | data: message,
102 | },
103 | });
104 | } catch (error) {
105 | // Use original console methods to avoid recursion
106 | originalConsoleError("Error sending notification:", error);
107 | }
108 | }
109 | };
110 |
111 | // Create helper functions for each log level
112 | export const debug = (...args: any[]): void => log('debug', ...args);
113 | export const info = (...args: any[]): void => log('info', ...args);
114 | export const notice = (...args: any[]): void => log('notice', ...args);
115 | export const warning = (...args: any[]): void => log('warning', ...args);
116 | export const logError = (...args: any[]): void => log('error', ...args);
117 | export const critical = (...args: any[]): void => log('critical', ...args);
118 | export const alert = (...args: any[]): void => log('alert', ...args);
119 | export const emergency = (...args: any[]): void => log('emergency', ...args);
120 |
121 | // Set log level function - defined after log function to avoid circular references
122 | export const setLogLevel = (level: LoggingLevel): void => {
123 | const oldLevel = logLevel;
124 | logLevel = level;
125 |
126 | // Always log this message regardless of the new log level
127 | // Use notice level to ensure it's visible
128 | originalConsoleLog(`\x1b[32m[NOTICE]\x1b[0m Log level changed from ${oldLevel} to ${level}`);
129 |
130 | // Also log through standard channels
131 | log('notice', `Log level set to: ${level}`);
132 | };
133 |
134 | // Override console methods to use our logging system
135 | console.log = function(...args) {
136 | if (isLogging) {
137 | // Use original method to prevent recursion
138 | originalConsoleLog(...args);
139 | return;
140 | }
141 |
142 | isLogging = true;
143 | info(...args);
144 | isLogging = false;
145 | };
146 |
147 | console.warn = function(...args) {
148 | if (isLogging) {
149 | // Use original method to prevent recursion
150 | originalConsoleWarn(...args);
151 | return;
152 | }
153 |
154 | isLogging = true;
155 | warning(...args);
156 | isLogging = false;
157 | };
158 |
159 | console.error = function(...args) {
160 | if (isLogging) {
161 | // Use original method to prevent recursion
162 | originalConsoleError(...args);
163 | return;
164 | }
165 |
166 | isLogging = true;
167 | logError(...args);
168 | isLogging = false;
169 | };
```
--------------------------------------------------------------------------------
/src/extensions/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | // Extensions management system for LSP-MCP
2 | import * as fs from "fs/promises";
3 | import * as path from "path";
4 | import { debug, info, warning, logError } from "../logging/index.js";
5 | import {
6 | ToolHandler,
7 | ResourceHandler,
8 | SubscriptionHandler,
9 | UnsubscriptionHandler,
10 | PromptHandler,
11 | Prompt,
12 | ToolInput
13 | } from "../types/index.js";
14 |
15 | // Type definitions for extension structure
16 | interface Extension {
17 | getToolHandlers?: () => Record<string, { schema: any, handler: ToolHandler }>;
18 | getToolDefinitions?: () => Array<{
19 | name: string;
20 | description: string;
21 | inputSchema: ToolInput;
22 | }>;
23 | getResourceHandlers?: () => Record<string, ResourceHandler>;
24 | getSubscriptionHandlers?: () => Record<string, SubscriptionHandler>;
25 | getUnsubscriptionHandlers?: () => Record<string, UnsubscriptionHandler>;
26 | getResourceTemplates?: () => Array<{
27 | name: string;
28 | scheme: string;
29 | pattern: string;
30 | description: string;
31 | subscribe: boolean;
32 | }>;
33 | getPromptDefinitions?: () => Prompt[];
34 | getPromptHandlers?: () => Record<string, PromptHandler>;
35 | }
36 |
37 | // Track active extensions
38 | const activeExtensions: Record<string, Extension> = {};
39 |
40 | // Import an extension module by language ID
41 | async function importExtension(languageId: string): Promise<Extension | null> {
42 | try {
43 | // Normalize language ID to use only alphanumeric characters and hyphens
44 | const safeLanguageId = languageId.replace(/[^a-zA-Z0-9-]/g, '');
45 |
46 | // Check if extension file exists
47 | const extensionPath = path.resolve(process.cwd(), 'dist', 'src', 'extensions', `${safeLanguageId}.js`);
48 | try {
49 | await fs.access(extensionPath);
50 | } catch (error) {
51 | info(`No extension found for language: ${languageId}`);
52 | return null;
53 | }
54 |
55 | // Import the extension module
56 | const extensionModule = await import(`./${safeLanguageId}.js`);
57 | return extensionModule as Extension;
58 | } catch (error) {
59 | const errorMessage = error instanceof Error ? error.message : String(error);
60 | logError(`Error importing extension for ${languageId}: ${errorMessage}`);
61 | return null;
62 | }
63 | }
64 |
65 | // Activate an extension by language ID
66 | export async function activateExtension(languageId: string): Promise<{success: boolean}> {
67 | try {
68 | // Check if already active
69 | if (activeExtensions[languageId]) {
70 | info(`Extension for ${languageId} is already active`);
71 | return { success: true };
72 | }
73 |
74 | // Import the extension
75 | const extension = await importExtension(languageId);
76 | if (!extension) {
77 | info(`No extension found for language: ${languageId}`);
78 | return { success: false };
79 | }
80 |
81 | // Store the active extension
82 | activeExtensions[languageId] = extension;
83 | info(`Activated extension for language: ${languageId}`);
84 | return { success: true };
85 | } catch (error) {
86 | const errorMessage = error instanceof Error ? error.message : String(error);
87 | logError(`Error activating extension for ${languageId}: ${errorMessage}`);
88 | return { success: false };
89 | }
90 | }
91 |
92 | // Deactivate an extension by language ID
93 | export function deactivateExtension(languageId: string): {success: boolean} {
94 | try {
95 | // Check if active
96 | if (!activeExtensions[languageId]) {
97 | info(`No active extension found for language: ${languageId}`);
98 | return { success: false };
99 | }
100 |
101 | // Remove the extension
102 | delete activeExtensions[languageId];
103 | info(`Deactivated extension for language: ${languageId}`);
104 |
105 | return { success: true };
106 | } catch (error) {
107 | const errorMessage = error instanceof Error ? error.message : String(error);
108 | logError(`Error deactivating extension for ${languageId}: ${errorMessage}`);
109 | return { success: false };
110 | }
111 | }
112 |
113 | // List all active extensions
114 | export function listActiveExtensions(): string[] {
115 | return Object.keys(activeExtensions);
116 | }
117 |
118 | // Get all tool handlers from active extensions
119 | export function getExtensionToolHandlers(): Record<string, { schema: any, handler: ToolHandler }> {
120 | const handlers: Record<string, { schema: any, handler: ToolHandler }> = {};
121 |
122 | for (const [languageId, extension] of Object.entries(activeExtensions)) {
123 | if (extension.getToolHandlers) {
124 | const extensionHandlers = extension.getToolHandlers();
125 | for (const [name, handler] of Object.entries(extensionHandlers)) {
126 | handlers[`${languageId}.${name}`] = handler;
127 | }
128 | }
129 | }
130 |
131 | return handlers;
132 | }
133 |
134 | // Get all tool definitions from active extensions
135 | export function getExtensionToolDefinitions(): Array<{
136 | name: string;
137 | description: string;
138 | inputSchema: ToolInput;
139 | }> {
140 | const definitions: Array<{
141 | name: string;
142 | description: string;
143 | inputSchema: ToolInput;
144 | }> = [];
145 |
146 | for (const [languageId, extension] of Object.entries(activeExtensions)) {
147 | if (extension.getToolDefinitions) {
148 | const extensionDefinitions = extension.getToolDefinitions();
149 | for (const def of extensionDefinitions) {
150 | definitions.push({
151 | name: `${languageId}.${def.name}`,
152 | description: def.description,
153 | inputSchema: def.inputSchema
154 | });
155 | }
156 | }
157 | }
158 |
159 | return definitions;
160 | }
161 |
162 | // Get all resource handlers from active extensions
163 | export function getExtensionResourceHandlers(): Record<string, ResourceHandler> {
164 | const handlers: Record<string, ResourceHandler> = {};
165 |
166 | for (const [languageId, extension] of Object.entries(activeExtensions)) {
167 | if (extension.getResourceHandlers) {
168 | const extensionHandlers = extension.getResourceHandlers();
169 | for (const [scheme, handler] of Object.entries(extensionHandlers)) {
170 | handlers[`${languageId}.${scheme}`] = handler;
171 | }
172 | }
173 | }
174 |
175 | return handlers;
176 | }
177 |
178 | // Get all subscription handlers from active extensions
179 | export function getExtensionSubscriptionHandlers(): Record<string, SubscriptionHandler> {
180 | const handlers: Record<string, SubscriptionHandler> = {};
181 |
182 | for (const [languageId, extension] of Object.entries(activeExtensions)) {
183 | if (extension.getSubscriptionHandlers) {
184 | const extensionHandlers = extension.getSubscriptionHandlers();
185 | for (const [scheme, handler] of Object.entries(extensionHandlers)) {
186 | handlers[`${languageId}.${scheme}`] = handler;
187 | }
188 | }
189 | }
190 |
191 | return handlers;
192 | }
193 |
194 | // Get all unsubscription handlers from active extensions
195 | export function getExtensionUnsubscriptionHandlers(): Record<string, UnsubscriptionHandler> {
196 | const handlers: Record<string, UnsubscriptionHandler> = {};
197 |
198 | for (const [languageId, extension] of Object.entries(activeExtensions)) {
199 | if (extension.getUnsubscriptionHandlers) {
200 | const extensionHandlers = extension.getUnsubscriptionHandlers();
201 | for (const [scheme, handler] of Object.entries(extensionHandlers)) {
202 | handlers[`${languageId}.${scheme}`] = handler;
203 | }
204 | }
205 | }
206 |
207 | return handlers;
208 | }
209 |
210 | // Get all resource templates from active extensions
211 | export function getExtensionResourceTemplates(): Array<{
212 | name: string;
213 | scheme: string;
214 | pattern: string;
215 | description: string;
216 | subscribe: boolean;
217 | }> {
218 | const templates: Array<{
219 | name: string;
220 | scheme: string;
221 | pattern: string;
222 | description: string;
223 | subscribe: boolean;
224 | }> = [];
225 |
226 | for (const [languageId, extension] of Object.entries(activeExtensions)) {
227 | if (extension.getResourceTemplates) {
228 | const extensionTemplates = extension.getResourceTemplates();
229 | for (const template of extensionTemplates) {
230 | templates.push({
231 | name: `${languageId}.${template.name}`,
232 | scheme: `${languageId}.${template.scheme}`,
233 | pattern: template.pattern,
234 | description: template.description,
235 | subscribe: template.subscribe
236 | });
237 | }
238 | }
239 | }
240 |
241 | return templates;
242 | }
243 |
244 | // Get all prompt definitions from active extensions
245 | export function getExtensionPromptDefinitions(): Prompt[] {
246 | const definitions: Prompt[] = [];
247 |
248 | for (const [languageId, extension] of Object.entries(activeExtensions)) {
249 | if (extension.getPromptDefinitions) {
250 | const extensionDefinitions = extension.getPromptDefinitions();
251 | for (const def of extensionDefinitions) {
252 | definitions.push({
253 | name: `${languageId}.${def.name}`,
254 | description: def.description,
255 | arguments: def.arguments
256 | });
257 | }
258 | }
259 | }
260 |
261 | return definitions;
262 | }
263 |
264 | // Get all prompt handlers from active extensions
265 | export function getExtensionPromptHandlers(): Record<string, PromptHandler> {
266 | const handlers: Record<string, PromptHandler> = {};
267 |
268 | for (const [languageId, extension] of Object.entries(activeExtensions)) {
269 | if (extension.getPromptHandlers) {
270 | const extensionHandlers = extension.getPromptHandlers();
271 | for (const [name, handler] of Object.entries(extensionHandlers)) {
272 | handlers[`${languageId}.${name}`] = handler;
273 | }
274 | }
275 | }
276 |
277 | return handlers;
278 | }
279 |
```
--------------------------------------------------------------------------------
/test/prompts.test.js:
--------------------------------------------------------------------------------
```javascript
1 | #!/usr/bin/env node
2 | // Prompts feature test for LSP MCP server
3 |
4 | import { spawn } from 'child_process';
5 | import { fileURLToPath } from 'url';
6 | import path from 'path';
7 | import fsSync from 'fs';
8 | import assert from 'assert';
9 | import { Client } from '@modelcontextprotocol/sdk/client/index.js';
10 | import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/sdk/shared/stdio.js';
11 |
12 | // Get the current file's directory
13 | const __filename = fileURLToPath(import.meta.url);
14 | const __dirname = path.dirname(__filename);
15 |
16 | // Custom transport that works with an existing child process
17 | class CustomStdioTransport {
18 | constructor(childProcess) {
19 | this.childProcess = childProcess;
20 | this.readBuffer = new ReadBuffer();
21 | this.onmessage = null;
22 | this.onerror = null;
23 | this.onclose = null;
24 |
25 | this._setupListeners();
26 | }
27 |
28 | _setupListeners() {
29 | // Set up stdout handler for responses
30 | this.childProcess.stdout.on('data', (data) => {
31 | this.readBuffer.append(data);
32 | this._processReadBuffer();
33 | });
34 |
35 | // Set up error handler
36 | this.childProcess.on('error', (error) => {
37 | if (this.onerror) this.onerror(error);
38 | });
39 |
40 | // Set up close handler
41 | this.childProcess.on('close', (code) => {
42 | if (this.onclose) this.onclose();
43 | });
44 |
45 | // Handle errors on streams
46 | this.childProcess.stdout.on('error', (error) => {
47 | if (this.onerror) this.onerror(error);
48 | });
49 |
50 | this.childProcess.stdin.on('error', (error) => {
51 | if (this.onerror) this.onerror(error);
52 | });
53 | }
54 |
55 | _processReadBuffer() {
56 | while (true) {
57 | try {
58 | const message = this.readBuffer.readMessage();
59 | if (message === null) {
60 | break;
61 | }
62 | if (this.onmessage) this.onmessage(message);
63 | } catch (error) {
64 | if (this.onerror) this.onerror(error);
65 | }
66 | }
67 | }
68 |
69 | async start() {
70 | // No need to start since we're using an existing process
71 | return Promise.resolve();
72 | }
73 |
74 | async close() {
75 | // Don't actually kill the process here - we'll handle that separately
76 | this.readBuffer.clear();
77 | }
78 |
79 | send(message) {
80 | return new Promise((resolve) => {
81 | if (!this.childProcess.stdin) {
82 | throw new Error('Not connected');
83 | }
84 |
85 | const json = serializeMessage(message);
86 | console.log('>>> SENDING:', json.toString().trim());
87 |
88 | if (this.childProcess.stdin.write(json)) {
89 | resolve();
90 | } else {
91 | this.childProcess.stdin.once('drain', resolve);
92 | }
93 | });
94 | }
95 | }
96 |
97 | // Path to our compiled server script and the typescript-language-server binary
98 | const LSP_MCP_SERVER = path.join(__dirname, '..', 'dist', 'index.js');
99 | const TS_SERVER_BIN = path.join(__dirname, '..', 'node_modules', '.bin', 'typescript-language-server');
100 |
101 | // Check prerequisites
102 | try {
103 | const stats = fsSync.statSync(TS_SERVER_BIN);
104 | if (!stats.isFile()) {
105 | console.error(`Error: The typescript-language-server at '${TS_SERVER_BIN}' is not a file`);
106 | process.exit(1);
107 | }
108 | } catch (error) {
109 | console.error(`Error: Could not find typescript-language-server at '${TS_SERVER_BIN}'`);
110 | console.error('Make sure you have installed the typescript-language-server as a dev dependency');
111 | process.exit(1);
112 | }
113 |
114 | if (!fsSync.existsSync(LSP_MCP_SERVER)) {
115 | console.error(`ERROR: LSP MCP server not found at ${LSP_MCP_SERVER}`);
116 | console.error(`Make sure you've built the project with 'npm run build'`);
117 | process.exit(1);
118 | }
119 |
120 | class PromptsTester {
121 | constructor() {
122 | this.client = null;
123 | this.serverProcess = null;
124 | this.testResults = {
125 | passed: [],
126 | failed: []
127 | };
128 | }
129 |
130 | async start() {
131 | // Start the MCP server
132 | console.log(`Starting MCP server: node ${LSP_MCP_SERVER} typescript ${TS_SERVER_BIN} --stdio`);
133 |
134 | this.serverProcess = spawn('node', [LSP_MCP_SERVER, 'typescript', TS_SERVER_BIN, '--stdio'], {
135 | env: {
136 | ...process.env,
137 | DEBUG: 'true',
138 | LOG_LEVEL: 'debug'
139 | },
140 | stdio: ['pipe', 'pipe', 'pipe']
141 | });
142 |
143 | console.log(`MCP server started with PID: ${this.serverProcess.pid}`);
144 |
145 | // Set up stderr handler for logging
146 | this.serverProcess.stderr.on('data', (data) => {
147 | console.log(`SERVER STDERR: ${data.toString().trim()}`);
148 | });
149 |
150 | // Set up error handler
151 | this.serverProcess.on('error', (error) => {
152 | console.error(`SERVER ERROR: ${error.message}`);
153 | });
154 |
155 | // Create our custom transport with the existing server process
156 | const transport = new CustomStdioTransport(this.serverProcess);
157 |
158 | // Create the client with proper initialization
159 | this.client = new Client(
160 | // clientInfo
161 | {
162 | name: "prompts-test-client",
163 | version: "1.0.0"
164 | },
165 | // options
166 | {
167 | capabilities: {
168 | tools: true,
169 | resources: true,
170 | prompts: true,
171 | logging: true
172 | }
173 | }
174 | );
175 |
176 | // Connect client to the transport
177 | try {
178 | await this.client.connect(transport);
179 | console.log("Connected to MCP server successfully");
180 | } catch (error) {
181 | console.error("Failed to connect to MCP server:", error);
182 | throw error;
183 | }
184 |
185 | // Wait a bit to ensure everything is initialized
186 | await new Promise(resolve => setTimeout(resolve, 2000));
187 |
188 | return this;
189 | }
190 |
191 | stop() {
192 | if (this.serverProcess) {
193 | console.log("Sending SIGINT to MCP server");
194 | this.serverProcess.kill('SIGINT');
195 | this.serverProcess = null;
196 | }
197 | }
198 |
199 | // Helper method to run a test case and record result
200 | async runTest(name, func) {
201 | console.log(`\nTest: ${name}`);
202 | try {
203 | await func();
204 | console.log(`✅ Test passed: ${name}`);
205 | this.testResults.passed.push(name);
206 | return true;
207 | } catch (error) {
208 | console.error(`❌ Test failed: ${name}`);
209 | console.error(`Error: ${error.message}`);
210 | this.testResults.failed.push(name);
211 | return false;
212 | }
213 | }
214 |
215 | // Test listing the available prompts
216 | async testListPrompts() {
217 | console.log("Listing available prompts...");
218 |
219 | try {
220 | const response = await this.client.listPrompts();
221 |
222 | // Extract the prompts array
223 | let prompts = [];
224 | if (response && response.prompts && Array.isArray(response.prompts)) {
225 | prompts = response.prompts;
226 | } else if (Array.isArray(response)) {
227 | prompts = response;
228 | } else {
229 | console.log("Unexpected prompts response format:", response);
230 | prompts = []; // Ensure we have an array to work with
231 | }
232 |
233 | console.log(`Found ${prompts.length} prompts`);
234 | prompts.forEach(prompt => {
235 | if (prompt && prompt.name) {
236 | console.log(`- ${prompt.name}: ${prompt.description || 'No description'}`);
237 |
238 | if (prompt.arguments && prompt.arguments.length > 0) {
239 | console.log(` Arguments:`);
240 | prompt.arguments.forEach(arg => {
241 | console.log(` - ${arg.name}: ${arg.description} (${arg.required ? 'required' : 'optional'})`);
242 | });
243 | }
244 | }
245 | });
246 |
247 | // If we didn't get any prompts, we'll fail the test
248 | if (prompts.length === 0) {
249 | throw new Error("No prompts returned");
250 | }
251 |
252 | // Verify we have the expected prompts
253 | const requiredPrompts = ['lsp_guide'];
254 |
255 | const missingPrompts = requiredPrompts.filter(prompt =>
256 | !prompts.some(p => p.name === prompt)
257 | );
258 |
259 | if (missingPrompts.length > 0) {
260 | throw new Error(`Missing expected prompts: ${missingPrompts.join(', ')}`);
261 | }
262 |
263 | return prompts;
264 | } catch (error) {
265 | console.error(`Error listing prompts: ${error.message}`);
266 | throw error;
267 | }
268 | }
269 |
270 | // Test getting a prompt
271 | async testGetPrompt(name, args = {}) {
272 | console.log(`Getting prompt: ${name}`);
273 |
274 | try {
275 | const params = {
276 | name: name,
277 | arguments: args
278 | };
279 |
280 | const result = await this.client.getPrompt(params);
281 | console.log(`Prompt result:`, JSON.stringify(result, null, 2));
282 |
283 | // Basic validation
284 | assert(result && result.messages && Array.isArray(result.messages),
285 | 'Expected messages array in the result');
286 |
287 | assert(result.messages.length > 0,
288 | 'Expected at least one message in the result');
289 |
290 | // Check for user and assistant roles
291 | const hasUserMessage = result.messages.some(m => m.role === 'user');
292 | const hasAssistantMessage = result.messages.some(m => m.role === 'assistant');
293 |
294 | assert(hasUserMessage, 'Expected a user message in the result');
295 | assert(hasAssistantMessage, 'Expected an assistant message in the result');
296 |
297 | return result;
298 | } catch (error) {
299 | console.error(`Failed to get prompt ${name}:`, error);
300 | throw error;
301 | }
302 | }
303 |
304 | // Print a summary of the test results
305 | printResults() {
306 | console.log('\n=== Test Results ===');
307 | console.log(`Passed: ${this.testResults.passed.length}/${this.testResults.passed.length + this.testResults.failed.length}`);
308 |
309 | console.log('\nPassed Tests:');
310 | for (const test of this.testResults.passed) {
311 | console.log(` ✅ ${test}`);
312 | }
313 |
314 | console.log('\nFailed Tests:');
315 | for (const test of this.testResults.failed) {
316 | console.log(` ❌ ${test}`);
317 | }
318 |
319 | if (this.testResults.failed.length > 0) {
320 | console.log('\n❌ Some tests failed');
321 | return false;
322 | } else if (this.testResults.passed.length === 0) {
323 | console.log('\n❌ No tests passed');
324 | return false;
325 | } else {
326 | console.log('\n✅ All tests passed');
327 | return true;
328 | }
329 | }
330 | }
331 |
332 | // Run the tests
333 | async function runTests() {
334 | console.log('=== LSP MCP Prompts Feature Tests ===');
335 |
336 | const tester = await new PromptsTester().start();
337 |
338 | try {
339 | // Test listing prompts
340 | await tester.runTest('List prompts', async () => {
341 | await tester.testListPrompts();
342 | });
343 |
344 | // Test getting the LSP guide prompt
345 | await tester.runTest('Get LSP guide prompt', async () => {
346 | await tester.testGetPrompt('lsp_guide');
347 | });
348 |
349 | } catch (error) {
350 | console.error('ERROR in tests:', error);
351 | } finally {
352 | // Print results
353 | const allPassed = tester.printResults();
354 |
355 | // Clean up
356 | console.log('\nShutting down tester...');
357 | tester.stop();
358 |
359 | // Exit with appropriate status code
360 | process.exit(allPassed ? 0 : 1);
361 | }
362 | }
363 |
364 | // Execute the tests
365 | console.log('Starting LSP MCP Prompts tests');
366 | runTests().catch(error => {
367 | console.error('Unhandled error:', error);
368 | process.exit(1);
369 | });
370 |
```
--------------------------------------------------------------------------------
/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env node
2 |
3 | import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5 | import {
6 | CallToolRequestSchema,
7 | ListToolsRequestSchema,
8 | ReadResourceRequestSchema,
9 | ListResourcesRequestSchema,
10 | SubscribeRequestSchema,
11 | UnsubscribeRequestSchema,
12 | SetLevelRequestSchema,
13 | ListPromptsRequestSchema,
14 | GetPromptRequestSchema,
15 | } from "@modelcontextprotocol/sdk/types.js";
16 | import * as fsSync from "fs";
17 |
18 | import { LSPClient } from "./src/lspClient.js";
19 | import { debug, info, notice, warning, logError, critical, alert, emergency, setLogLevel, setServer } from "./src/logging/index.js";
20 | import { getToolHandlers, getToolDefinitions } from "./src/tools/index.js";
21 | import { getPromptHandlers, getPromptDefinitions } from "./src/prompts/index.js";
22 | import {
23 | getResourceHandlers,
24 | getSubscriptionHandlers,
25 | getUnsubscriptionHandlers,
26 | getResourceTemplates,
27 | generateResourcesList
28 | } from "./src/resources/index.js";
29 | import {
30 | getExtensionToolHandlers,
31 | getExtensionToolDefinitions,
32 | getExtensionResourceHandlers,
33 | getExtensionSubscriptionHandlers,
34 | getExtensionUnsubscriptionHandlers,
35 | getExtensionResourceTemplates,
36 | getExtensionPromptDefinitions,
37 | getExtensionPromptHandlers
38 | } from "./src/extensions/index.js";
39 |
40 | import { activateExtension } from "./src/extensions/index.js";
41 |
42 | // Get the language ID from the command line arguments
43 | const languageId = process.argv[2];
44 |
45 | // Add any language-specific extensions here
46 | await activateExtension(languageId);
47 |
48 | // Get LSP binary path and arguments from command line arguments
49 | const lspServerPath = process.argv[3];
50 | if (!lspServerPath) {
51 | console.error("Error: LSP server path is required as the first argument");
52 | console.error("Usage: node dist/index.js <language> <lsp-server-path> [lsp-server-args...]");
53 | process.exit(1);
54 | }
55 |
56 | // Get any additional arguments to pass to the LSP server
57 | const lspServerArgs = process.argv.slice(4);
58 |
59 | // Verify the LSP server binary exists
60 | try {
61 | const stats = fsSync.statSync(lspServerPath);
62 | if (!stats.isFile()) {
63 | console.error(`Error: The specified path '${lspServerPath}' is not a file`);
64 | process.exit(1);
65 | }
66 | } catch (error) {
67 | console.error(`Error: Could not access the LSP server at '${lspServerPath}'`);
68 | console.error(error instanceof Error ? error.message : String(error));
69 | process.exit(1);
70 | }
71 |
72 | // We'll create the LSP client but won't initialize it until start_lsp is called
73 | let lspClient: LSPClient | null = null;
74 | let rootDir = "."; // Default to current directory
75 |
76 | // Set the LSP client function
77 | const setLspClient = (client: LSPClient) => {
78 | lspClient = client;
79 | };
80 |
81 | // Set the root directory function
82 | const setRootDir = (dir: string) => {
83 | rootDir = dir;
84 | };
85 |
86 | // Server setup
87 | const server = new Server(
88 | {
89 | name: "lsp-mcp-server",
90 | version: "0.3.0",
91 | description: "MCP server for Language Server Protocol (LSP) integration, providing hover information, code completions, diagnostics, and code actions with resource-based access and extensibility"
92 | },
93 | {
94 | capabilities: {
95 | tools: {
96 | description: "A set of tools for interacting with the Language Server Protocol (LSP). These tools provide access to language-specific features like code completion, hover information, diagnostics, and code actions. Before using any LSP features, you must first call start_lsp with the project root directory, then open the files you wish to analyze."
97 | },
98 | resources: {
99 | description: "URI-based access to Language Server Protocol (LSP) features. These resources provide a way to access language-specific features like diagnostics, hover information, and completions through a URI pattern. Before using these resources, you must first call the start_lsp tool with the project root directory, then open the files you wish to analyze using the open_document tool. Additional resources may be available through language-specific extensions.",
100 | templates: getResourceTemplates()
101 | },
102 | prompts: {
103 | description: "Helpful prompts related to using the LSP MCP server. These prompts provide guidance on how to use the LSP features and tools available in this server. Additional prompts may be available through language-specific extensions."
104 | },
105 | logging: {
106 | description: "Logging capabilities for the LSP MCP server. Use the set_log_level tool to control logging verbosity. The server sends notifications about important events, errors, and diagnostic updates."
107 | }
108 | },
109 | },
110 | );
111 |
112 | // Set the server instance for logging and tools
113 | setServer(server);
114 |
115 | // Tool handlers
116 | server.setRequestHandler(ListToolsRequestSchema, async () => {
117 | debug("Handling ListTools request");
118 | // Combine core tools and extension tools
119 | const coreTools = getToolDefinitions();
120 | const extensionTools = getExtensionToolDefinitions();
121 | return {
122 | tools: [...coreTools, ...extensionTools],
123 | };
124 | });
125 |
126 | // Get the combined tool handlers from core and extensions
127 | const getToolsHandlers = () => {
128 | // Get core handlers, passing the server instance for notifications
129 | const coreHandlers = getToolHandlers(lspClient, lspServerPath, lspServerArgs, setLspClient, rootDir, setRootDir, server);
130 | // Get extension handlers
131 | const extensionHandlers = getExtensionToolHandlers();
132 | // Combine them (extensions take precedence in case of name conflicts)
133 | return { ...coreHandlers, ...extensionHandlers };
134 | };
135 |
136 | // Handle tool requests using the toolHandlers object
137 | server.setRequestHandler(CallToolRequestSchema, async (request) => {
138 | try {
139 | const { name, arguments: args } = request.params;
140 | debug(`Handling CallTool request for tool: ${name}`);
141 |
142 | // Get the latest tool handlers and look up the handler for this tool
143 | const toolHandlers = getToolsHandlers();
144 |
145 | // Check if it's a direct handler or an extension handler
146 | const toolHandler = toolHandlers[name as keyof typeof toolHandlers];
147 |
148 | if (!toolHandler) {
149 | throw new Error(`Unknown tool: ${name}`);
150 | }
151 |
152 | // Validate the arguments against the schema
153 | const parsed = toolHandler.schema.safeParse(args);
154 | if (!parsed.success) {
155 | throw new Error(`Invalid arguments for ${name}: ${parsed.error}`);
156 | }
157 |
158 | // Call the handler with the validated arguments
159 | return await toolHandler.handler(parsed.data);
160 |
161 | } catch (error) {
162 | const errorMessage = error instanceof Error ? error.message : String(error);
163 | logError(`Error handling tool request: ${errorMessage}`);
164 | return {
165 | content: [{ type: "text", text: `Error: ${errorMessage}` }],
166 | isError: true,
167 | };
168 | }
169 | });
170 |
171 | // Resource handler
172 | server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
173 | try {
174 | const uri = request.params.uri;
175 | debug(`Handling ReadResource request for URI: ${uri}`);
176 |
177 | // Get the core and extension resource handlers
178 | const coreHandlers = getResourceHandlers(lspClient);
179 | const extensionHandlers = getExtensionResourceHandlers();
180 |
181 | // Combine them (extensions take precedence in case of conflicts)
182 | const resourceHandlers = { ...coreHandlers, ...extensionHandlers };
183 |
184 | // Find the appropriate handler for this URI scheme
185 | const handlerKey = Object.keys(resourceHandlers).find(key => uri.startsWith(key));
186 | if (handlerKey) {
187 | return await resourceHandlers[handlerKey](uri);
188 | }
189 |
190 | throw new Error(`Unknown resource URI: ${uri}`);
191 | } catch (error) {
192 | const errorMessage = error instanceof Error ? error.message : String(error);
193 | logError(`Error handling resource request: ${errorMessage}`);
194 | return {
195 | contents: [{ type: "text", text: `Error: ${errorMessage}`, uri: request.params.uri }],
196 | isError: true,
197 | };
198 | }
199 | });
200 |
201 | // Resource subscription handler
202 | server.setRequestHandler(SubscribeRequestSchema, async (request) => {
203 | try {
204 | const { uri } = request.params;
205 | debug(`Handling SubscribeResource request for URI: ${uri}`);
206 |
207 | // Get the core and extension subscription handlers
208 | const coreHandlers = getSubscriptionHandlers(lspClient, server);
209 | const extensionHandlers = getExtensionSubscriptionHandlers();
210 |
211 | // Combine them (extensions take precedence in case of conflicts)
212 | const subscriptionHandlers = { ...coreHandlers, ...extensionHandlers };
213 |
214 | // Find the appropriate handler for this URI scheme
215 | const handlerKey = Object.keys(subscriptionHandlers).find(key => uri.startsWith(key));
216 | if (handlerKey) {
217 | return await subscriptionHandlers[handlerKey](uri);
218 | }
219 |
220 | throw new Error(`Unknown resource URI: ${uri}`);
221 | } catch (error) {
222 | const errorMessage = error instanceof Error ? error.message : String(error);
223 | logError(`Error handling subscription request: ${errorMessage}`);
224 | return {
225 | ok: false,
226 | error: errorMessage
227 | };
228 | }
229 | });
230 |
231 | // Resource unsubscription handler
232 | server.setRequestHandler(UnsubscribeRequestSchema, async (request) => {
233 | try {
234 | const { uri, context } = request.params;
235 | debug(`Handling UnsubscribeResource request for URI: ${uri}`);
236 |
237 | // Get the core and extension unsubscription handlers
238 | const coreHandlers = getUnsubscriptionHandlers(lspClient);
239 | const extensionHandlers = getExtensionUnsubscriptionHandlers();
240 |
241 | // Combine them (extensions take precedence in case of conflicts)
242 | const unsubscriptionHandlers = { ...coreHandlers, ...extensionHandlers };
243 |
244 | // Find the appropriate handler for this URI scheme
245 | const handlerKey = Object.keys(unsubscriptionHandlers).find(key => uri.startsWith(key));
246 | if (handlerKey) {
247 | return await unsubscriptionHandlers[handlerKey](uri, context);
248 | }
249 |
250 | throw new Error(`Unknown resource URI: ${uri}`);
251 | } catch (error) {
252 | const errorMessage = error instanceof Error ? error.message : String(error);
253 | logError(`Error handling unsubscription request: ${errorMessage}`);
254 | return {
255 | ok: false,
256 | error: errorMessage
257 | };
258 | }
259 | });
260 |
261 | // Handle log level changes from client
262 | server.setRequestHandler(SetLevelRequestSchema, async (request) => {
263 | try {
264 | const { level } = request.params;
265 | debug(`Received request to set log level to: ${level}`);
266 |
267 | // Set the log level
268 | setLogLevel(level);
269 |
270 | return {};
271 | } catch (error) {
272 | const errorMessage = error instanceof Error ? error.message : String(error);
273 | logError(`Error handling set level request: ${errorMessage}`);
274 | return {
275 | ok: false,
276 | error: errorMessage
277 | };
278 | }
279 | });
280 |
281 | // Resource listing handler
282 | server.setRequestHandler(ListResourcesRequestSchema, async () => {
283 | try {
284 | debug("Handling ListResource request");
285 |
286 | // Generate the core resources list
287 | const coreResources = generateResourcesList(lspClient);
288 |
289 | // Get extension resource templates
290 | const extensionTemplates = getExtensionResourceTemplates();
291 |
292 | // Combine core resources and extension templates
293 | const resources = [...coreResources, ...extensionTemplates];
294 |
295 | return { resources };
296 | } catch (error) {
297 | const errorMessage = error instanceof Error ? error.message : String(error);
298 | logError(`Error handling list resources request: ${errorMessage}`);
299 | return {
300 | resources: [],
301 | isError: true,
302 | error: errorMessage
303 | };
304 | }
305 | });
306 |
307 | // Prompt listing handler
308 | server.setRequestHandler(ListPromptsRequestSchema, async () => {
309 | try {
310 | debug("Handling ListPrompts request");
311 | // Combine core and extension prompts
312 | const corePrompts = getPromptDefinitions();
313 | const extensionPrompts = getExtensionPromptDefinitions();
314 | return {
315 | prompts: [...corePrompts, ...extensionPrompts],
316 | };
317 | } catch (error) {
318 | const errorMessage = error instanceof Error ? error.message : String(error);
319 | logError(`Error handling list prompts request: ${errorMessage}`);
320 | return {
321 | prompts: [],
322 | isError: true,
323 | error: errorMessage
324 | };
325 | }
326 | });
327 |
328 | // Get prompt handler
329 | server.setRequestHandler(GetPromptRequestSchema, async (request) => {
330 | try {
331 | const { name, arguments: args } = request.params;
332 | debug(`Handling GetPrompt request for prompt: ${name}`);
333 |
334 | // Get the core and extension prompt handlers
335 | const coreHandlers = getPromptHandlers();
336 | const extensionHandlers = getExtensionPromptHandlers();
337 |
338 | // Combine them (extensions take precedence in case of conflicts)
339 | const promptHandlers = { ...coreHandlers, ...extensionHandlers };
340 |
341 | const promptHandler = promptHandlers[name];
342 |
343 | if (!promptHandler) {
344 | throw new Error(`Unknown prompt: ${name}`);
345 | }
346 |
347 | // Call the handler with the arguments
348 | return await promptHandler(args);
349 | } catch (error) {
350 | const errorMessage = error instanceof Error ? error.message : String(error);
351 | logError(`Error handling get prompt request: ${errorMessage}`);
352 | throw new Error(`Error handling get prompt request: ${errorMessage}`);
353 | }
354 | });
355 |
356 | // Clean up on process exit
357 | process.on('exit', async () => {
358 | info("Shutting down MCP server...");
359 | try {
360 | // Only attempt shutdown if lspClient exists and is initialized
361 | if (lspClient) {
362 | await lspClient.shutdown();
363 | }
364 | } catch (error) {
365 | warning("Error during shutdown:", error);
366 | }
367 | });
368 |
369 | // Log uncaught exceptions
370 | process.on('uncaughtException', (error) => {
371 | const errorMessage = error instanceof Error ? error.message : String(error);
372 |
373 | // Don't exit for "Not connected" errors during startup
374 | if (errorMessage === 'Not connected') {
375 | warning(`Uncaught exception (non-fatal): ${errorMessage}`, error);
376 | return;
377 | }
378 |
379 | critical(`Uncaught exception: ${errorMessage}`, error);
380 | // Exit with status code 1 to indicate error
381 | process.exit(1);
382 | });
383 |
384 | // Start server
385 | async function runServer() {
386 | notice(`Starting LSP MCP Server`);
387 |
388 | const transport = new StdioServerTransport();
389 | await server.connect(transport);
390 | notice("LSP MCP Server running on stdio");
391 | info("Using LSP server:", lspServerPath);
392 | if (lspServerArgs.length > 0) {
393 | info("With arguments:", lspServerArgs.join(' '));
394 | }
395 |
396 | // Create LSP client instance but don't start the process or initialize yet
397 | // Both will happen when start_lsp is called
398 | lspClient = new LSPClient(lspServerPath, lspServerArgs);
399 | info("LSP client created. Use the start_lsp tool to start and initialize with a root directory.");
400 | }
401 |
402 | runServer().catch((error) => {
403 | emergency("Fatal error running server:", error);
404 | process.exit(1);
405 | });
406 |
```
--------------------------------------------------------------------------------
/src/resources/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | import * as fs from "fs/promises";
2 | import * as path from "path";
3 | import { DiagnosticUpdateCallback, ResourceHandler, SubscriptionContext, SubscriptionHandler, UnsubscriptionHandler } from "../types/index.js";
4 | import { LSPClient } from "../lspClient.js";
5 | import { createFileUri, checkLspClientInitialized } from "../tools/index.js";
6 | import { debug, logError } from "../logging/index.js";
7 |
8 | // Helper function to parse a URI path
9 | export const parseUriPath = (uri: URL): string => {
10 | // Ensure we handle paths correctly - URL parsing can remove the leading slash
11 | let decodedPath = decodeURIComponent(uri.pathname);
12 | // Normalize path to ensure it starts with a slash
13 | return path.posix.normalize(decodedPath.startsWith('/') ? decodedPath : '/' + decodedPath);
14 | };
15 |
16 | // Helper function to parse location parameters
17 | export const parseLocationParams = (uri: URL): { filePath: string, line: number, character: number, languageId: string } => {
18 | // Get the file path
19 | const filePath = parseUriPath(uri);
20 |
21 | // Get the query parameters
22 | const lineParam = uri.searchParams.get('line');
23 | const columnParam = uri.searchParams.get('column');
24 | const languageId = uri.searchParams.get('language_id');
25 |
26 | if (!languageId) {
27 | throw new Error("language_id parameter is required");
28 | }
29 |
30 | if (!filePath || !lineParam || !columnParam) {
31 | throw new Error("Required parameters: file_path, line, column");
32 | }
33 |
34 | // Parse line and column as numbers
35 | const line = parseInt(lineParam, 10);
36 | const character = parseInt(columnParam, 10);
37 |
38 | if (isNaN(line) || isNaN(character)) {
39 | throw new Error("Line and column must be valid numbers");
40 | }
41 |
42 | return { filePath, line, character, languageId };
43 | };
44 |
45 | // Get resource handlers
46 | export const getResourceHandlers = (lspClient: LSPClient | null): Record<string, ResourceHandler> => {
47 | return {
48 | // Handler for lsp-diagnostics://
49 | 'lsp-diagnostics://': async (uri: string) => {
50 | checkLspClientInitialized(lspClient);
51 |
52 | try {
53 | // Parse the URI to handle query parameters correctly
54 | const diagnosticsUri = new URL(uri);
55 |
56 | // Get the file path from the pathname
57 | let filePath = parseUriPath(diagnosticsUri);
58 |
59 | // Remove query parameters from the file path if needed
60 | const questionMarkIndex = filePath.indexOf('?');
61 | if (questionMarkIndex !== -1) {
62 | filePath = filePath.substring(0, questionMarkIndex);
63 | }
64 |
65 | let diagnosticsContent: string;
66 |
67 | if (filePath && filePath !== '/') {
68 | // For a specific file
69 | debug(`Getting diagnostics for file: ${filePath}`);
70 | const fileUri = createFileUri(filePath);
71 |
72 | // Verify the file is open
73 | if (!lspClient!.isDocumentOpen(fileUri)) {
74 | throw new Error(`File ${filePath} is not open. Please open the file with open_document before requesting diagnostics.`);
75 | }
76 |
77 | const diagnostics = lspClient!.getDiagnostics(fileUri);
78 | diagnosticsContent = JSON.stringify({ [fileUri]: diagnostics }, null, 2);
79 | } else {
80 | // For all files
81 | debug("Getting diagnostics for all files");
82 | const allDiagnostics = lspClient!.getAllDiagnostics();
83 |
84 | // Convert Map to object for JSON serialization
85 | const diagnosticsObject: Record<string, any[]> = {};
86 | allDiagnostics.forEach((value: any[], key: string) => {
87 | // Only include diagnostics for open files
88 | if (lspClient!.isDocumentOpen(key)) {
89 | diagnosticsObject[key] = value;
90 | }
91 | });
92 |
93 | diagnosticsContent = JSON.stringify(diagnosticsObject, null, 2);
94 | }
95 |
96 | return {
97 | contents: [{ type: "text", text: diagnosticsContent, uri }],
98 | };
99 | } catch (error) {
100 | const errorMessage = error instanceof Error ? error.message : String(error);
101 | logError(`Error parsing diagnostics URI or getting diagnostics: ${errorMessage}`);
102 | throw new Error(`Error processing diagnostics request: ${errorMessage}`);
103 | }
104 | },
105 |
106 | // Handler for lsp-hover://
107 | 'lsp-hover://': async (uri: string) => {
108 | checkLspClientInitialized(lspClient);
109 |
110 | try {
111 | // Extract parameters from URI
112 | // Format: lsp-hover://{file_path}?line={line}&character={character}&language_id={language_id}
113 | const hoverUri = new URL(uri);
114 | const { filePath, line, character, languageId } = parseLocationParams(hoverUri);
115 |
116 | debug(`Getting hover info for ${filePath} at line ${line}, character ${character}`);
117 |
118 | // Read the file content
119 | const fileContent = await fs.readFile(filePath, 'utf-8');
120 |
121 | // Create a file URI
122 | const fileUri = createFileUri(filePath);
123 |
124 | // Open the document in the LSP server (won't reopen if already open)
125 | await lspClient!.openDocument(fileUri, fileContent, languageId);
126 |
127 | // Get information at the location (LSP is 0-based)
128 | const hoverText = await lspClient!.getInfoOnLocation(fileUri, {
129 | line: line - 1,
130 | character: character - 1
131 | });
132 |
133 | debug(`Got hover information: ${hoverText.slice(0, 100)}${hoverText.length > 100 ? '...' : ''}`);
134 |
135 | return {
136 | contents: [{ type: "text", text: hoverText, uri }],
137 | };
138 | } catch (error) {
139 | const errorMessage = error instanceof Error ? error.message : String(error);
140 | logError(`Error parsing hover URI or getting hover information: ${errorMessage}`);
141 | throw new Error(`Error processing hover request: ${errorMessage}`);
142 | }
143 | },
144 |
145 | // Handler for lsp-completions://
146 | 'lsp-completions://': async (uri: string) => {
147 | checkLspClientInitialized(lspClient);
148 |
149 | try {
150 | // Extract parameters from URI
151 | // Format: lsp-completions://{file_path}?line={line}&character={character}&language_id={language_id}
152 | const completionsUri = new URL(uri);
153 | const { filePath, line, character, languageId } = parseLocationParams(completionsUri);
154 |
155 | debug(`Getting completions for ${filePath} at line ${line}, character ${character}`);
156 |
157 | // Read the file content
158 | const fileContent = await fs.readFile(filePath, 'utf-8');
159 |
160 | // Create a file URI
161 | const fileUri = createFileUri(filePath);
162 |
163 | // Open the document in the LSP server (won't reopen if already open)
164 | await lspClient!.openDocument(fileUri, fileContent, languageId);
165 |
166 | // Get completions at the location (LSP is 0-based)
167 | const completions = await lspClient!.getCompletion(fileUri, {
168 | line: line - 1,
169 | character: character - 1
170 | });
171 |
172 | debug(`Got ${completions.length} completions`);
173 |
174 | return {
175 | contents: [{ type: "text", text: JSON.stringify(completions, null, 2), uri }],
176 | };
177 | } catch (error) {
178 | const errorMessage = error instanceof Error ? error.message : String(error);
179 | logError(`Error parsing completions URI or getting completions: ${errorMessage}`);
180 | throw new Error(`Error processing completions request: ${errorMessage}`);
181 | }
182 | }
183 | };
184 | };
185 |
186 | // Get subscription handlers
187 | export const getSubscriptionHandlers = (lspClient: LSPClient | null, server: any): Record<string, SubscriptionHandler> => {
188 | return {
189 | // Handler for lsp-diagnostics://
190 | 'lsp-diagnostics://': async (uri: string) => {
191 | checkLspClientInitialized(lspClient);
192 |
193 | // Extract the file path parameter from the URI
194 | const filePath = uri.slice(18);
195 |
196 | if (filePath) {
197 | // Subscribe to a specific file
198 | const fileUri = createFileUri(filePath);
199 |
200 | // Verify the file is open
201 | if (!lspClient!.isDocumentOpen(fileUri)) {
202 | throw new Error(`File ${filePath} is not open. Please open the file with open_document before subscribing to diagnostics.`);
203 | }
204 |
205 | debug(`Subscribing to diagnostics for file: ${filePath}`);
206 |
207 | // Set up the subscription callback
208 | const callback: DiagnosticUpdateCallback = (diagUri: string, diagnostics: any[]) => {
209 | if (diagUri === fileUri) {
210 | // Send resource update to clients
211 | server.notification({
212 | method: "notifications/resources/update",
213 | params: {
214 | uri,
215 | content: [{ type: "text", text: JSON.stringify({ [diagUri]: diagnostics }, null, 2) }]
216 | }
217 | });
218 | }
219 | };
220 |
221 | // Store the callback in the subscription context for later use with unsubscribe
222 | const subscriptionContext: SubscriptionContext = { callback };
223 |
224 | // Subscribe to diagnostics
225 | lspClient!.subscribeToDiagnostics(callback);
226 |
227 | return {
228 | ok: true,
229 | context: subscriptionContext
230 | };
231 | } else {
232 | // Subscribe to all files
233 | debug("Subscribing to diagnostics for all files");
234 |
235 | // Set up the subscription callback for all files
236 | const callback: DiagnosticUpdateCallback = (diagUri: string, diagnostics: any[]) => {
237 | // Only send updates for open files
238 | if (lspClient!.isDocumentOpen(diagUri)) {
239 | // Get all open documents' diagnostics
240 | const allDiagnostics = lspClient!.getAllDiagnostics();
241 |
242 | // Convert Map to object for JSON serialization
243 | const diagnosticsObject: Record<string, any[]> = {};
244 | allDiagnostics.forEach((diagValue: any[], diagKey: string) => {
245 | // Only include diagnostics for open files
246 | if (lspClient!.isDocumentOpen(diagKey)) {
247 | diagnosticsObject[diagKey] = diagValue;
248 | }
249 | });
250 |
251 | // Send resource update to clients
252 | server.notification({
253 | method: "notifications/resources/update",
254 | params: {
255 | uri,
256 | content: [{ type: "text", text: JSON.stringify(diagnosticsObject, null, 2) }]
257 | }
258 | });
259 | }
260 | };
261 |
262 | // Store the callback in the subscription context for later use with unsubscribe
263 | const subscriptionContext: SubscriptionContext = { callback };
264 |
265 | // Subscribe to diagnostics
266 | lspClient!.subscribeToDiagnostics(callback);
267 |
268 | return {
269 | ok: true,
270 | context: subscriptionContext
271 | };
272 | }
273 | }
274 | };
275 | };
276 |
277 | // Get unsubscription handlers
278 | export const getUnsubscriptionHandlers = (lspClient: LSPClient | null): Record<string, UnsubscriptionHandler> => {
279 | return {
280 | // Handler for lsp-diagnostics://
281 | 'lsp-diagnostics://': async (uri: string, context: any) => {
282 | checkLspClientInitialized(lspClient);
283 |
284 | if (context && (context as SubscriptionContext).callback) {
285 | // Unsubscribe the callback
286 | lspClient!.unsubscribeFromDiagnostics((context as SubscriptionContext).callback);
287 | debug(`Unsubscribed from diagnostics for URI: ${uri}`);
288 |
289 | return { ok: true };
290 | }
291 |
292 | throw new Error(`Invalid subscription context for URI: ${uri}`);
293 | }
294 | };
295 | };
296 |
297 | // Get resource definitions for the server
298 | export const getResourceTemplates = () => {
299 | return [
300 | {
301 | name: "lsp-diagnostics",
302 | scheme: "lsp-diagnostics",
303 | pattern: "lsp-diagnostics://{file_path}",
304 | description: "Get diagnostic messages (errors, warnings) for a specific file or all files. Use this resource to identify problems in code files such as syntax errors, type mismatches, or other issues detected by the language server. When used without a file_path, returns diagnostics for all open files. Supports live updates through subscriptions.",
305 | subscribe: true,
306 | },
307 | {
308 | name: "lsp-hover",
309 | scheme: "lsp-hover",
310 | pattern: "lsp-hover://{file_path}?line={line}&column={column}&language_id={language_id}",
311 | description: "Get hover information for a specific location in a file. Use this resource to retrieve type information, documentation, and other contextual details about symbols in your code. Particularly useful for understanding variable types, function signatures, and module documentation at a specific cursor position.",
312 | subscribe: false,
313 | },
314 | {
315 | name: "lsp-completions",
316 | scheme: "lsp-completions",
317 | pattern: "lsp-completions://{file_path}?line={line}&column={column}&language_id={language_id}",
318 | description: "Get completion suggestions for a specific location in a file. Use this resource to obtain code completion options based on the current context, including variable names, function calls, object properties, and more. Helpful for code assistance and auto-completion features at a specific cursor position.",
319 | subscribe: false,
320 | }
321 | ];
322 | };
323 |
324 | // Generate resources list from open documents
325 | export const generateResourcesList = (lspClient: LSPClient | null) => {
326 | const resources: Array<{
327 | uri: string;
328 | name: string;
329 | description: string;
330 | subscribe: boolean;
331 | template?: boolean;
332 | }> = [];
333 |
334 | // Check if LSP client is initialized
335 | if (!lspClient) {
336 | return resources; // Return empty list if LSP is not initialized
337 | }
338 |
339 | // Add the "all diagnostics" resource
340 | resources.push({
341 | uri: "lsp-diagnostics://",
342 | name: "All diagnostics",
343 | description: "Diagnostics for all open files",
344 | subscribe: true,
345 | });
346 |
347 | // For each open document, add resources
348 | lspClient.getOpenDocuments().forEach((uri: string) => {
349 | if (uri.startsWith('file://')) {
350 | const filePath = uri.slice(7); // Remove 'file://' prefix
351 | const fileName = path.basename(filePath);
352 |
353 | // Add diagnostics resource
354 | resources.push({
355 | uri: `lsp-diagnostics://${filePath}`,
356 | name: `Diagnostics for ${fileName}`,
357 | description: `LSP diagnostics for ${filePath}`,
358 | subscribe: true,
359 | });
360 |
361 | // Add hover resource template
362 | // We don't add specific hover resources since they require line/column coordinates
363 | // which are not known until the client requests them
364 | resources.push({
365 | uri: `lsp-hover://${filePath}?line={line}&column={column}&language_id={language_id}`,
366 | name: `Hover for ${fileName}`,
367 | description: `LSP hover information template for ${fileName}`,
368 | subscribe: false,
369 | template: true,
370 | });
371 |
372 | // Add completions resource template
373 | resources.push({
374 | uri: `lsp-completions://${filePath}?line={line}&column={column}&language_id={language_id}`,
375 | name: `Completions for ${fileName}`,
376 | description: `LSP code completion suggestions template for ${fileName}`,
377 | subscribe: false,
378 | template: true,
379 | });
380 | }
381 | });
382 |
383 | return resources;
384 | };
```
--------------------------------------------------------------------------------
/src/tools/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | import * as fs from "fs/promises";
2 | import * as path from "path";
3 | import { z } from "zod";
4 | import { zodToJsonSchema } from "zod-to-json-schema";
5 | import {
6 | GetInfoOnLocationArgsSchema,
7 | GetCompletionsArgsSchema,
8 | GetCodeActionsArgsSchema,
9 | OpenDocumentArgsSchema,
10 | CloseDocumentArgsSchema,
11 | GetDiagnosticsArgsSchema,
12 | SetLogLevelArgsSchema,
13 | RestartLSPServerArgsSchema,
14 | StartLSPArgsSchema,
15 | ToolInput,
16 | ToolHandler
17 | } from "../types/index.js";
18 | import { LSPClient } from "../lspClient.js";
19 | import { debug, info, logError, notice, warning, setLogLevel } from "../logging/index.js";
20 | import { activateExtension, deactivateExtension, listActiveExtensions } from "../extensions/index.js";
21 |
22 | // Create a file URI from a file path
23 | export const createFileUri = (filePath: string): string => {
24 | return `file://${path.resolve(filePath)}`;
25 | };
26 |
27 | // Check if LSP client is initialized
28 | export const checkLspClientInitialized = (lspClient: LSPClient | null): void => {
29 | if (!lspClient) {
30 | throw new Error("LSP server not started. Call start_lsp first with a root directory.");
31 | }
32 | };
33 |
34 | // Define handlers for each tool
35 | export const getToolHandlers = (lspClient: LSPClient | null, lspServerPath: string, lspServerArgs: string[], setLspClient: (client: LSPClient) => void, rootDir: string, setRootDir: (dir: string) => void, server?: any) => {
36 | return {
37 | "get_info_on_location": {
38 | schema: GetInfoOnLocationArgsSchema,
39 | handler: async (args: any) => {
40 | debug(`Getting info on location in file: ${args.file_path} (${args.line}:${args.column})`);
41 |
42 | checkLspClientInitialized(lspClient);
43 |
44 | // Read the file content
45 | const fileContent = await fs.readFile(args.file_path, 'utf-8');
46 |
47 | // Create a file URI
48 | const fileUri = createFileUri(args.file_path);
49 |
50 | // Open the document in the LSP server (won't reopen if already open)
51 | await lspClient!.openDocument(fileUri, fileContent, args.language_id);
52 |
53 | // Get information at the location
54 | const text = await lspClient!.getInfoOnLocation(fileUri, {
55 | line: args.line - 1, // LSP is 0-based
56 | character: args.column - 1
57 | });
58 |
59 | debug(`Returned info on location: ${text.slice(0, 100)}${text.length > 100 ? '...' : ''}`);
60 |
61 | return {
62 | content: [{ type: "text", text }],
63 | };
64 | }
65 | },
66 |
67 | "get_completions": {
68 | schema: GetCompletionsArgsSchema,
69 | handler: async (args: any) => {
70 | debug(`Getting completions in file: ${args.file_path} (${args.line}:${args.column})`);
71 |
72 | checkLspClientInitialized(lspClient);
73 |
74 | // Read the file content
75 | const fileContent = await fs.readFile(args.file_path, 'utf-8');
76 |
77 | // Create a file URI
78 | const fileUri = createFileUri(args.file_path);
79 |
80 | // Open the document in the LSP server (won't reopen if already open)
81 | await lspClient!.openDocument(fileUri, fileContent, args.language_id);
82 |
83 | // Get completions at the location
84 | const completions = await lspClient!.getCompletion(fileUri, {
85 | line: args.line - 1, // LSP is 0-based
86 | character: args.column - 1
87 | });
88 |
89 | debug(`Returned ${completions.length} completions`);
90 |
91 | return {
92 | content: [{ type: "text", text: JSON.stringify(completions, null, 2) }],
93 | };
94 | }
95 | },
96 |
97 | "get_code_actions": {
98 | schema: GetCodeActionsArgsSchema,
99 | handler: async (args: any) => {
100 | debug(`Getting code actions in file: ${args.file_path} (${args.start_line}:${args.start_column} to ${args.end_line}:${args.end_column})`);
101 |
102 | checkLspClientInitialized(lspClient);
103 |
104 | // Read the file content
105 | const fileContent = await fs.readFile(args.file_path, 'utf-8');
106 |
107 | // Create a file URI
108 | const fileUri = createFileUri(args.file_path);
109 |
110 | // Open the document in the LSP server (won't reopen if already open)
111 | await lspClient!.openDocument(fileUri, fileContent, args.language_id);
112 |
113 | // Get code actions for the range
114 | const codeActions = await lspClient!.getCodeActions(fileUri, {
115 | start: {
116 | line: args.start_line - 1, // LSP is 0-based
117 | character: args.start_column - 1
118 | },
119 | end: {
120 | line: args.end_line - 1,
121 | character: args.end_column - 1
122 | }
123 | });
124 |
125 | debug(`Returned ${codeActions.length} code actions`);
126 |
127 | return {
128 | content: [{ type: "text", text: JSON.stringify(codeActions, null, 2) }],
129 | };
130 | }
131 | },
132 |
133 | "restart_lsp_server": {
134 | schema: RestartLSPServerArgsSchema,
135 | handler: async (args: any) => {
136 | checkLspClientInitialized(lspClient);
137 |
138 | // Get the root directory from args or use the stored one
139 | const restartRootDir = args.root_dir || rootDir;
140 |
141 | info(`Restarting LSP server${args.root_dir ? ` with root directory: ${args.root_dir}` : ''}...`);
142 |
143 | try {
144 | // If root_dir is provided, update the stored rootDir
145 | if (args.root_dir) {
146 | setRootDir(args.root_dir);
147 | }
148 |
149 | // Restart with the root directory
150 | await lspClient!.restart(restartRootDir);
151 |
152 | return {
153 | content: [{
154 | type: "text",
155 | text: args.root_dir
156 | ? `LSP server successfully restarted and initialized with root directory: ${args.root_dir}`
157 | : "LSP server successfully restarted"
158 | }],
159 | };
160 | } catch (error) {
161 | const errorMessage = error instanceof Error ? error.message : String(error);
162 | logError(`Error restarting LSP server: ${errorMessage}`);
163 | throw new Error(`Failed to restart LSP server: ${errorMessage}`);
164 | }
165 | }
166 | },
167 |
168 | "start_lsp": {
169 | schema: StartLSPArgsSchema,
170 | handler: async (args: any) => {
171 | const startRootDir = args.root_dir || rootDir;
172 | info(`Starting LSP server with root directory: ${startRootDir}`);
173 |
174 | try {
175 | setRootDir(startRootDir);
176 |
177 | // Create LSP client if it doesn't exist
178 | if (!lspClient) {
179 | const newClient = new LSPClient(lspServerPath, lspServerArgs);
180 | setLspClient(newClient);
181 | }
182 |
183 | // Initialize with the specified root directory
184 | await lspClient!.initialize(startRootDir);
185 |
186 | return {
187 | content: [{ type: "text", text: `LSP server successfully started with root directory: ${rootDir}` }],
188 | };
189 | } catch (error) {
190 | const errorMessage = error instanceof Error ? error.message : String(error);
191 | logError(`Error starting LSP server: ${errorMessage}`);
192 | throw new Error(`Failed to start LSP server: ${errorMessage}`);
193 | }
194 | }
195 | },
196 |
197 | "open_document": {
198 | schema: OpenDocumentArgsSchema,
199 | handler: async (args: any) => {
200 | debug(`Opening document: ${args.file_path}`);
201 |
202 | checkLspClientInitialized(lspClient);
203 |
204 | try {
205 | // Read the file content
206 | const fileContent = await fs.readFile(args.file_path, 'utf-8');
207 |
208 | // Create a file URI
209 | const fileUri = createFileUri(args.file_path);
210 |
211 | // Open the document in the LSP server
212 | await lspClient!.openDocument(fileUri, fileContent, args.language_id);
213 |
214 | return {
215 | content: [{ type: "text", text: `File successfully opened: ${args.file_path}` }],
216 | };
217 | } catch (error) {
218 | const errorMessage = error instanceof Error ? error.message : String(error);
219 | logError(`Error opening document: ${errorMessage}`);
220 | throw new Error(`Failed to open document: ${errorMessage}`);
221 | }
222 | }
223 | },
224 |
225 | "close_document": {
226 | schema: CloseDocumentArgsSchema,
227 | handler: async (args: any) => {
228 | debug(`Closing document: ${args.file_path}`);
229 |
230 | checkLspClientInitialized(lspClient);
231 |
232 | try {
233 | // Create a file URI
234 | const fileUri = createFileUri(args.file_path);
235 |
236 | // Use the closeDocument method
237 | await lspClient!.closeDocument(fileUri);
238 |
239 | return {
240 | content: [{ type: "text", text: `File successfully closed: ${args.file_path}` }],
241 | };
242 | } catch (error) {
243 | const errorMessage = error instanceof Error ? error.message : String(error);
244 | logError(`Error closing document: ${errorMessage}`);
245 | throw new Error(`Failed to close document: ${errorMessage}`);
246 | }
247 | }
248 | },
249 |
250 | "get_diagnostics": {
251 | schema: GetDiagnosticsArgsSchema,
252 | handler: async (args: any) => {
253 | checkLspClientInitialized(lspClient);
254 |
255 | try {
256 | // Get diagnostics for a specific file or all files
257 | if (args.file_path) {
258 | // For a specific file
259 | debug(`Getting diagnostics for file: ${args.file_path}`);
260 | const fileUri = createFileUri(args.file_path);
261 |
262 | // Verify the file is open
263 | if (!lspClient!.isDocumentOpen(fileUri)) {
264 | throw new Error(`File ${args.file_path} is not open. Please open the file with open_document before requesting diagnostics.`);
265 | }
266 |
267 | const diagnostics = lspClient!.getDiagnostics(fileUri);
268 |
269 | return {
270 | content: [{
271 | type: "text",
272 | text: JSON.stringify({ [fileUri]: diagnostics }, null, 2)
273 | }],
274 | };
275 | } else {
276 | // For all files
277 | debug("Getting diagnostics for all files");
278 | const allDiagnostics = lspClient!.getAllDiagnostics();
279 |
280 | // Convert Map to object for JSON serialization
281 | const diagnosticsObject: Record<string, any[]> = {};
282 | allDiagnostics.forEach((value: any[], key: string) => {
283 | // Only include diagnostics for open files
284 | if (lspClient!.isDocumentOpen(key)) {
285 | diagnosticsObject[key] = value;
286 | }
287 | });
288 |
289 | return {
290 | content: [{
291 | type: "text",
292 | text: JSON.stringify(diagnosticsObject, null, 2)
293 | }],
294 | };
295 | }
296 | } catch (error) {
297 | const errorMessage = error instanceof Error ? error.message : String(error);
298 | logError(`Error getting diagnostics: ${errorMessage}`);
299 | throw new Error(`Failed to get diagnostics: ${errorMessage}`);
300 | }
301 | }
302 | },
303 |
304 | "set_log_level": {
305 | schema: SetLogLevelArgsSchema,
306 | handler: async (args: any) => {
307 | // Set the log level
308 | const { level } = args;
309 | setLogLevel(level);
310 |
311 | return {
312 | content: [{ type: "text", text: `Log level set to: ${level}` }],
313 | };
314 | }
315 | },
316 | };
317 | };
318 |
319 | // Get tool definitions for the server
320 | export const getToolDefinitions = () => {
321 | return [
322 | {
323 | name: "get_info_on_location",
324 | description: "Get information on a specific location in a file via LSP hover. Use this tool to retrieve detailed type information, documentation, and other contextual details about symbols in your code. Particularly useful for understanding variable types, function signatures, and module documentation at a specific location in the code. Use this whenever you need to get a better idea on what a particular function is doing in that context. Requires the file to be opened first.",
325 | inputSchema: zodToJsonSchema(GetInfoOnLocationArgsSchema) as ToolInput,
326 | },
327 | {
328 | name: "get_completions",
329 | description: "Get completion suggestions at a specific location in a file. Use this tool to retrieve code completion options based on the current context, including variable names, function calls, object properties, and more. Helpful for code assistance and auto-completion at a particular location. Use this when determining which functions you have available in a given package, for example when changing libraries. Requires the file to be opened first.",
330 | inputSchema: zodToJsonSchema(GetCompletionsArgsSchema) as ToolInput,
331 | },
332 | {
333 | name: "get_code_actions",
334 | description: "Get code actions for a specific range in a file. Use this tool to obtain available refactorings, quick fixes, and other code modifications that can be applied to a selected code range. Examples include adding imports, fixing errors, or implementing interfaces. Requires the file to be opened first.",
335 | inputSchema: zodToJsonSchema(GetCodeActionsArgsSchema) as ToolInput,
336 | },
337 | {
338 | name: "restart_lsp_server",
339 | description: "Restart the LSP server process. Use this tool to reset the LSP server if it becomes unresponsive, has stale data, or when you need to apply configuration changes. Can optionally reinitialize with a new root directory. Useful for troubleshooting language server issues or when switching projects.",
340 | inputSchema: zodToJsonSchema(RestartLSPServerArgsSchema) as ToolInput,
341 | },
342 | {
343 | name: "start_lsp",
344 | description: "Start the LSP server with a specified root directory. IMPORTANT: This tool must be called before using any other LSP functionality. The root directory should point to the project's base folder, which typically contains configuration files like tsconfig.json, package.json, or other language-specific project files. All file paths in other tool calls will be resolved relative to this root.",
345 | inputSchema: zodToJsonSchema(StartLSPArgsSchema) as ToolInput,
346 | },
347 | {
348 | name: "open_document",
349 | description: "Open a file in the LSP server for analysis. Use this tool before performing operations like getting diagnostics, hover information, or completions for a file. The file remains open for continued analysis until explicitly closed. The language_id parameter tells the server which language service to use (e.g., 'typescript', 'javascript', 'haskell').",
350 | inputSchema: zodToJsonSchema(OpenDocumentArgsSchema) as ToolInput,
351 | },
352 | {
353 | name: "close_document",
354 | description: "Close a file in the LSP server. Use this tool when you're done with a file to free up resources and reduce memory usage. It's good practice to close files that are no longer being actively analyzed, especially in long-running sessions or when working with large codebases.",
355 | inputSchema: zodToJsonSchema(CloseDocumentArgsSchema) as ToolInput,
356 | },
357 | {
358 | name: "get_diagnostics",
359 | description: "Get diagnostic messages (errors, warnings) for files. Use this tool to identify problems in code files such as syntax errors, type mismatches, or other issues detected by the language server. When used without a file_path, returns diagnostics for all open files. Requires files to be opened first.",
360 | inputSchema: zodToJsonSchema(GetDiagnosticsArgsSchema) as ToolInput,
361 | },
362 | {
363 | name: "set_log_level",
364 | description: "Set the server logging level. Use this tool to control the verbosity of logs generated by the LSP MCP server. Available levels from least to most verbose: emergency, alert, critical, error, warning, notice, info, debug. Increasing verbosity can help troubleshoot issues but may generate large amounts of output.",
365 | inputSchema: zodToJsonSchema(SetLogLevelArgsSchema) as ToolInput,
366 | },
367 | ];
368 | };
369 |
```
--------------------------------------------------------------------------------
/test/typescript-lsp.test.js:
--------------------------------------------------------------------------------
```javascript
1 | #!/usr/bin/env node
2 | // TypeScript LSP integration test for MCP using the official SDK
3 |
4 | import { spawn } from 'child_process';
5 | import { fileURLToPath } from 'url';
6 | import path from 'path';
7 | import fs from 'fs/promises';
8 | import fsSync from 'fs';
9 | import assert from 'assert';
10 | import { Client } from '@modelcontextprotocol/sdk/client/index.js';
11 | import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/sdk/shared/stdio.js';
12 |
13 | // Get the current file's directory
14 | const __filename = fileURLToPath(import.meta.url);
15 | const __dirname = path.dirname(__filename);
16 |
17 | // Custom transport that works with an existing child process
18 | class CustomStdioTransport {
19 | constructor(childProcess) {
20 | this.childProcess = childProcess;
21 | this.readBuffer = new ReadBuffer();
22 | this.onmessage = null;
23 | this.onerror = null;
24 | this.onclose = null;
25 |
26 | this._setupListeners();
27 | }
28 |
29 | _setupListeners() {
30 | // Set up stdout handler for responses
31 | this.childProcess.stdout.on('data', (data) => {
32 | this.readBuffer.append(data);
33 | this._processReadBuffer();
34 | });
35 |
36 | // Set up error handler
37 | this.childProcess.on('error', (error) => {
38 | if (this.onerror) this.onerror(error);
39 | });
40 |
41 | // Set up close handler
42 | this.childProcess.on('close', (code) => {
43 | if (this.onclose) this.onclose();
44 | });
45 |
46 | // Handle errors on streams
47 | this.childProcess.stdout.on('error', (error) => {
48 | if (this.onerror) this.onerror(error);
49 | });
50 |
51 | this.childProcess.stdin.on('error', (error) => {
52 | if (this.onerror) this.onerror(error);
53 | });
54 | }
55 |
56 | _processReadBuffer() {
57 | while (true) {
58 | try {
59 | const message = this.readBuffer.readMessage();
60 | if (message === null) {
61 | break;
62 | }
63 | if (this.onmessage) this.onmessage(message);
64 | } catch (error) {
65 | if (this.onerror) this.onerror(error);
66 | }
67 | }
68 | }
69 |
70 | async start() {
71 | // No need to start since we're using an existing process
72 | return Promise.resolve();
73 | }
74 |
75 | async close() {
76 | // Don't actually kill the process here - we'll handle that separately
77 | this.readBuffer.clear();
78 | }
79 |
80 | send(message) {
81 | return new Promise((resolve) => {
82 | if (!this.childProcess.stdin) {
83 | throw new Error('Not connected');
84 | }
85 |
86 | const json = serializeMessage(message);
87 | console.log('>>> SENDING:', json.toString().trim());
88 |
89 | if (this.childProcess.stdin.write(json)) {
90 | resolve();
91 | } else {
92 | this.childProcess.stdin.once('drain', resolve);
93 | }
94 | });
95 | }
96 | }
97 |
98 | // Path to the TypeScript project for testing
99 | const TS_PROJECT_PATH = path.join(__dirname, 'ts-project');
100 | const EXAMPLE_TS_FILE = path.join(TS_PROJECT_PATH, 'src', 'example.ts');
101 |
102 | // Path to our compiled server script and the typescript-language-server binary
103 | const LSP_MCP_SERVER = path.join(__dirname, '..', 'dist', 'index.js');
104 | const TS_SERVER_BIN = path.join(__dirname, '..', 'node_modules', '.bin', 'typescript-language-server');
105 |
106 | // Check prerequisites
107 | try {
108 | const stats = fsSync.statSync(TS_SERVER_BIN);
109 | if (!stats.isFile()) {
110 | console.error(`Error: The typescript-language-server at '${TS_SERVER_BIN}' is not a file`);
111 | process.exit(1);
112 | }
113 | } catch (error) {
114 | console.error(`Error: Could not find typescript-language-server at '${TS_SERVER_BIN}'`);
115 | console.error('Make sure you have installed the typescript-language-server as a dev dependency');
116 | process.exit(1);
117 | }
118 |
119 | if (!fsSync.existsSync(LSP_MCP_SERVER)) {
120 | console.error(`ERROR: LSP MCP server not found at ${LSP_MCP_SERVER}`);
121 | console.error(`Make sure you've built the project with 'npm run build'`);
122 | process.exit(1);
123 | }
124 |
125 | class TypeScriptLspTester {
126 | constructor() {
127 | this.client = null;
128 | this.serverProcess = null;
129 | this.testResults = {
130 | passed: [],
131 | failed: []
132 | };
133 | }
134 |
135 | async start() {
136 | // Start the MCP server
137 | console.log(`Starting MCP server: node ${LSP_MCP_SERVER} typescript ${TS_SERVER_BIN} --stdio`);
138 |
139 | this.serverProcess = spawn('node', [LSP_MCP_SERVER, 'typescript', TS_SERVER_BIN, '--stdio'], {
140 | env: {
141 | ...process.env,
142 | DEBUG: 'true',
143 | LOG_LEVEL: 'debug'
144 | },
145 | stdio: ['pipe', 'pipe', 'pipe']
146 | });
147 |
148 | console.log(`MCP server started with PID: ${this.serverProcess.pid}`);
149 |
150 | // Set up stderr handler for logging
151 | this.serverProcess.stderr.on('data', (data) => {
152 | console.log(`SERVER STDERR: ${data.toString().trim()}`);
153 | });
154 |
155 | // Set up error handler
156 | this.serverProcess.on('error', (error) => {
157 | console.error(`SERVER ERROR: ${error.message}`);
158 | });
159 |
160 | // Create our custom transport with the existing server process
161 | const transport = new CustomStdioTransport(this.serverProcess);
162 |
163 | // Create the client with proper initialization
164 | this.client = new Client(
165 | // clientInfo
166 | {
167 | name: "typescript-lsp-test-client",
168 | version: "1.0.0"
169 | },
170 | // options
171 | {
172 | capabilities: {
173 | tools: true,
174 | resources: true,
175 | logging: true
176 | }
177 | }
178 | );
179 |
180 | // Connect client to the transport
181 | try {
182 | await this.client.connect(transport);
183 | console.log("Connected to MCP server successfully");
184 | } catch (error) {
185 | console.error("Failed to connect to MCP server:", error);
186 | throw error;
187 | }
188 |
189 | // Wait a bit to ensure everything is initialized
190 | await new Promise(resolve => setTimeout(resolve, 2000));
191 |
192 | return this;
193 | }
194 |
195 | stop() {
196 | if (this.serverProcess) {
197 | console.log("Sending SIGINT to MCP server");
198 | this.serverProcess.kill('SIGINT');
199 | this.serverProcess = null;
200 | }
201 | }
202 |
203 | // Helper method to run a test case and record result
204 | async runTest(name, func) {
205 | console.log(`\nTest: ${name}`);
206 | try {
207 | await func();
208 | console.log(`✅ Test passed: ${name}`);
209 | this.testResults.passed.push(name);
210 | return true;
211 | } catch (error) {
212 | console.error(`❌ Test failed: ${name}`);
213 | console.error(`Error: ${error.message}`);
214 | this.testResults.failed.push(name);
215 | return false;
216 | }
217 | }
218 |
219 | // Execute a tool and verify the result
220 | async executeTool(toolName, args, validateFn = null) {
221 | console.log(`Executing tool: ${toolName}`);
222 |
223 | try {
224 | // The callTool method expects a name and arguments parameter
225 | const params = {
226 | name: toolName,
227 | arguments: args
228 | };
229 |
230 | const result = await this.client.callTool(params);
231 | console.log(`Tool result:`, result);
232 |
233 | // If a validation function is provided, run it
234 | if (validateFn) {
235 | validateFn(result);
236 | }
237 |
238 | return result;
239 | } catch (error) {
240 | console.error(`Failed to execute tool ${toolName}:`, error);
241 | throw error;
242 | }
243 | }
244 |
245 | // Test listing the available tools
246 | async testListTools() {
247 | console.log("Listing available tools...");
248 |
249 | try {
250 | const response = await this.client.listTools();
251 |
252 | // Depending on the response format, extract the tools array
253 | let tools = [];
254 | if (response && response.tools && Array.isArray(response.tools)) {
255 | tools = response.tools;
256 | } else if (Array.isArray(response)) {
257 | tools = response;
258 | } else {
259 | console.log("Unexpected tools response format:", response);
260 | tools = []; // Ensure we have an array to work with
261 | }
262 |
263 | console.log(`Found ${tools.length} tools`);
264 | tools.forEach(tool => {
265 | if (tool && tool.name) {
266 | console.log(`- ${tool.name}: ${tool.description || 'No description'}`);
267 | }
268 | });
269 |
270 | // If we didn't get any tools, we'll run the other tests anyway
271 | if (tools.length === 0) {
272 | console.log("WARNING: No tools returned but continuing with tests");
273 | return tools;
274 | }
275 |
276 | // Verify we have the expected tools
277 | const requiredTools = ['get_info_on_location', 'get_completions', 'get_code_actions',
278 | 'restart_lsp_server', 'start_lsp', 'open_document',
279 | 'close_document', 'get_diagnostics'];
280 |
281 | const missingTools = requiredTools.filter(tool =>
282 | !tools.some(t => t.name === tool)
283 | );
284 |
285 | if (missingTools.length > 0) {
286 | console.warn(`WARNING: Missing some expected tools: ${missingTools.join(', ')}`);
287 | }
288 |
289 | return tools;
290 | } catch (error) {
291 | // Just log the error but don't fail the test - we'll continue with the rest
292 | console.warn(`WARNING: Error listing tools: ${error.message}`);
293 | return [];
294 | }
295 | }
296 |
297 | // Test listing resources
298 | async testListResources() {
299 | console.log("Listing available resources...");
300 |
301 | try {
302 | // Using the listResources method which is the correct SDK method
303 | const response = await this.client.listResources();
304 |
305 | // Extract the resources array
306 | let resources = [];
307 | if (response && response.resources && Array.isArray(response.resources)) {
308 | resources = response.resources;
309 | } else if (Array.isArray(response)) {
310 | resources = response;
311 | } else {
312 | console.log("Unexpected resources response format:", response);
313 | resources = []; // Ensure we have an array to work with
314 | }
315 |
316 | console.log(`Found ${resources.length} resources`);
317 | resources.forEach(resource => {
318 | if (resource && resource.name) {
319 | console.log(`- ${resource.name}: ${resource.description || 'No description'}`);
320 | }
321 | });
322 |
323 | // If we didn't get any resources, we'll run the other tests anyway
324 | if (resources.length === 0) {
325 | console.log("WARNING: No resources returned but continuing with tests");
326 | return resources;
327 | }
328 |
329 | return resources;
330 | } catch (error) {
331 | // Just log the error but don't fail the test - we'll continue with the rest
332 | console.warn(`WARNING: Error listing resources: ${error.message}`);
333 | return [];
334 | }
335 | }
336 |
337 | // Execute a resource request and verify the result
338 | async accessResource(params, validateFn = null) {
339 | console.log(`Accessing resource: ${params.uri}`);
340 |
341 | try {
342 | // Use readResource to access a resource with the params object directly
343 | const result = await this.client.readResource(params);
344 | console.log(`Resource result:`, result);
345 |
346 | // If a validation function is provided, run it
347 | if (validateFn) {
348 | validateFn(result);
349 | }
350 |
351 | return result;
352 | } catch (error) {
353 | console.error(`Failed to access resource ${params.uri}:`, error);
354 | throw error;
355 | }
356 | }
357 |
358 | // Print a summary of the test results
359 | printResults() {
360 | console.log('\n=== Test Results ===');
361 | console.log(`Passed: ${this.testResults.passed.length}/${this.testResults.passed.length + this.testResults.failed.length}`);
362 |
363 | console.log('\nPassed Tests:');
364 | for (const test of this.testResults.passed) {
365 | console.log(` ✅ ${test}`);
366 | }
367 |
368 | console.log('\nFailed Tests:');
369 | for (const test of this.testResults.failed) {
370 | console.log(` ❌ ${test}`);
371 | }
372 |
373 | if (this.testResults.failed.length > 0) {
374 | console.log('\n❌ Some tests failed');
375 | return false;
376 | } else if (this.testResults.passed.length === 0) {
377 | console.log('\n❌ No tests passed');
378 | return false;
379 | } else {
380 | console.log('\n✅ All tests passed');
381 | return true;
382 | }
383 | }
384 | }
385 |
386 | // Run the tests
387 | async function runTests() {
388 | console.log('=== TypeScript LSP MCP Integration Tests ===');
389 |
390 | const tester = await new TypeScriptLspTester().start();
391 |
392 | try {
393 | // Make sure the example file exists
394 | await fs.access(EXAMPLE_TS_FILE);
395 | const fileContent = await fs.readFile(EXAMPLE_TS_FILE, 'utf8');
396 | console.log(`Example file ${EXAMPLE_TS_FILE} exists and is ${fileContent.length} bytes`);
397 |
398 | // Test listing tools
399 | await tester.runTest('List tools', async () => {
400 | await tester.testListTools();
401 | });
402 |
403 | // Test starting the TypeScript LSP
404 | await tester.runTest('Start LSP', async () => {
405 | await tester.executeTool('start_lsp', {
406 | root_dir: TS_PROJECT_PATH
407 | }, (result) => {
408 | assert(result.content && result.content.length > 0,
409 | 'Expected content in the result');
410 | });
411 | });
412 |
413 | // Wait for LSP to fully initialize
414 | console.log('\nWaiting for LSP to fully initialize...');
415 | await new Promise(resolve => setTimeout(resolve, 3000));
416 |
417 | // Test opening document
418 | await tester.runTest('Open document', async () => {
419 | await tester.executeTool('open_document', {
420 | file_path: EXAMPLE_TS_FILE,
421 | language_id: 'typescript'
422 | }, (result) => {
423 | assert(result.content && result.content.length > 0,
424 | 'Expected content in the result');
425 | });
426 | });
427 |
428 | // Test getting hover information
429 | await tester.runTest('Hover information', async () => {
430 | await tester.executeTool('get_info_on_location', {
431 | file_path: EXAMPLE_TS_FILE,
432 | language_id: 'typescript',
433 | line: 4,
434 | column: 15
435 | }, (result) => {
436 | assert(result.content && result.content.length > 0,
437 | 'Expected content in the result');
438 | // In a real test, we would verify the content contains actual hover info
439 | });
440 | });
441 |
442 | // Test getting completions
443 | await tester.runTest('Completions', async () => {
444 | await tester.executeTool('get_completions', {
445 | file_path: EXAMPLE_TS_FILE,
446 | language_id: 'typescript',
447 | line: 5,
448 | column: 10
449 | }, (result) => {
450 | assert(result.content && result.content.length > 0,
451 | 'Expected content in the result');
452 | // In a real test, we would verify the content contains actual completions
453 | });
454 | });
455 |
456 | // Test getting diagnostics
457 | await tester.runTest('Diagnostics', async () => {
458 | await tester.executeTool('get_diagnostics', {
459 | file_path: EXAMPLE_TS_FILE
460 | }, (result) => {
461 | assert(result.content && result.content.length > 0,
462 | 'Expected content in the result');
463 | // In a real test, we would verify the content contains actual diagnostics
464 | });
465 | });
466 |
467 | // Test getting code actions
468 | await tester.runTest('Code actions', async () => {
469 | await tester.executeTool('get_code_actions', {
470 | file_path: EXAMPLE_TS_FILE,
471 | language_id: 'typescript',
472 | start_line: 40,
473 | start_column: 1,
474 | end_line: 40,
475 | end_column: 20
476 | }, (result) => {
477 | assert(result.content && result.content.length > 0,
478 | 'Expected content in the result');
479 | // In a real test, we would verify the content contains actual code actions
480 | });
481 | });
482 |
483 | // Test closing document
484 | await tester.runTest('Close document', async () => {
485 | await tester.executeTool('close_document', {
486 | file_path: EXAMPLE_TS_FILE
487 | }, (result) => {
488 | assert(result.content && result.content.length > 0,
489 | 'Expected content in the result');
490 | });
491 | });
492 |
493 | // Test restarting LSP server
494 | await tester.runTest('Restart LSP server', async () => {
495 | await tester.executeTool('restart_lsp_server', {}, (result) => {
496 | assert(result.content && result.content.length > 0,
497 | 'Expected content in the result');
498 | });
499 | });
500 |
501 | // Test listing resources
502 | await tester.runTest('List resources', async () => {
503 | const resources = await tester.testListResources();
504 | assert(Array.isArray(resources), 'Expected resources to be an array');
505 | });
506 |
507 | // Test accessing diagnostics resource
508 | await tester.runTest('Access diagnostics resource', async () => {
509 | // First make sure document is open again
510 | await tester.executeTool('open_document', {
511 | file_path: EXAMPLE_TS_FILE,
512 | language_id: 'typescript'
513 | });
514 |
515 | // Then try to access diagnostics resource using proper URI format
516 | const diagnosticsUri = `lsp-diagnostics://${EXAMPLE_TS_FILE}?language_id=typescript`;
517 | await tester.accessResource({
518 | uri: diagnosticsUri
519 | }, (result) => {
520 | assert(result && result.contents && result.contents.length > 0,
521 | 'Expected contents in the diagnostics result');
522 | });
523 | });
524 |
525 | // Test accessing hover resource
526 | await tester.runTest('Access hover resource', async () => {
527 | // Use proper URI format for hover resource
528 | const hoverUri = `lsp-hover://${EXAMPLE_TS_FILE}?line=4&column=15&language_id=typescript`;
529 | await tester.accessResource({
530 | uri: hoverUri
531 | }, (result) => {
532 | assert(result && result.contents && result.contents.length > 0,
533 | 'Expected contents in the hover result');
534 | });
535 | });
536 |
537 | // Test accessing completion resource
538 | await tester.runTest('Access completion resource', async () => {
539 | // Use proper URI format for completion resource
540 | const completionUri = `lsp-completions://${EXAMPLE_TS_FILE}?line=5&column=10&language_id=typescript`;
541 | await tester.accessResource({
542 | uri: completionUri
543 | }, (result) => {
544 | assert(result && result.contents && result.contents.length > 0,
545 | 'Expected contents in the completion result');
546 | });
547 | });
548 |
549 | } catch (error) {
550 | console.error('ERROR in tests:', error);
551 | } finally {
552 | // Print results
553 | const allPassed = tester.printResults();
554 |
555 | // Clean up
556 | console.log('\nShutting down tester...');
557 | tester.stop();
558 |
559 | // Exit with appropriate status code
560 | process.exit(allPassed ? 0 : 1);
561 | }
562 | }
563 |
564 | // Execute the tests
565 | console.log('Starting TypeScript LSP MCP integration tests');
566 | runTests().catch(error => {
567 | console.error('Unhandled error:', error);
568 | process.exit(1);
569 | });
570 |
```
--------------------------------------------------------------------------------
/src/lspClient.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { spawn } from "child_process";
2 | import path from "path";
3 | import { LSPMessage, DiagnosticUpdateCallback, LoggingLevel } from "./types/index.js";
4 | import { debug, info, notice, warning, log, logError } from "./logging/index.js";
5 |
6 | export class LSPClient {
7 | private process: any;
8 | private buffer: string = "";
9 | private messageQueue: LSPMessage[] = [];
10 | private nextId: number = 1;
11 | private responsePromises: Map<string | number, { resolve: Function; reject: Function }> = new Map();
12 | private initialized: boolean = false;
13 | private serverCapabilities: any = null;
14 | private lspServerPath: string;
15 | private lspServerArgs: string[];
16 | private openedDocuments: Set<string> = new Set();
17 | private documentVersions: Map<string, number> = new Map();
18 | private processingQueue: boolean = false;
19 | private documentDiagnostics: Map<string, any[]> = new Map();
20 | private diagnosticSubscribers: Set<DiagnosticUpdateCallback> = new Set();
21 |
22 | constructor(lspServerPath: string, lspServerArgs: string[] = []) {
23 | this.lspServerPath = lspServerPath;
24 | this.lspServerArgs = lspServerArgs;
25 | // Don't start the process automatically - it will be started when needed
26 | }
27 |
28 | private startProcess(): void {
29 | info(`Starting LSP client with binary: ${this.lspServerPath}`);
30 | info(`Using LSP server arguments: ${this.lspServerArgs.join(' ')}`);
31 | this.process = spawn(this.lspServerPath, this.lspServerArgs, {
32 | stdio: ["pipe", "pipe", "pipe"]
33 | });
34 |
35 | // Set up event listeners
36 | this.process.stdout.on("data", (data: Buffer) => this.handleData(data));
37 | this.process.stderr.on("data", (data: Buffer) => {
38 | debug(`LSP Server Message: ${data.toString()}`);
39 | });
40 |
41 | this.process.on("close", (code: number) => {
42 | notice(`LSP server process exited with code ${code}`);
43 | });
44 | }
45 |
46 | private handleData(data: Buffer): void {
47 | // Append new data to buffer
48 | this.buffer += data.toString();
49 |
50 | // Implement a safety limit to prevent excessive buffer growth
51 | const MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10MB limit
52 | if (this.buffer.length > MAX_BUFFER_SIZE) {
53 | logError(`Buffer size exceeded ${MAX_BUFFER_SIZE} bytes, clearing buffer to prevent memory issues`);
54 | this.buffer = this.buffer.substring(this.buffer.length - MAX_BUFFER_SIZE);
55 | }
56 |
57 | // Process complete messages
58 | while (true) {
59 | // Look for the standard LSP header format - this captures the entire header including the \r\n\r\n
60 | const headerMatch = this.buffer.match(/^Content-Length: (\d+)\r\n\r\n/);
61 | if (!headerMatch) break;
62 |
63 | const contentLength = parseInt(headerMatch[1], 10);
64 | const headerEnd = headerMatch[0].length;
65 |
66 | // Prevent processing unreasonably large messages
67 | if (contentLength > MAX_BUFFER_SIZE) {
68 | logError(`Received message with content length ${contentLength} exceeds maximum size, skipping`);
69 | this.buffer = this.buffer.substring(headerEnd + contentLength);
70 | continue;
71 | }
72 |
73 | // Check if we have the complete message (excluding the header)
74 | if (this.buffer.length < headerEnd + contentLength) break; // Message not complete yet
75 |
76 | // Extract the message content - using exact content length without including the header
77 | let content = this.buffer.substring(headerEnd, headerEnd + contentLength);
78 | // Make the parsing more robust by ensuring content ends with a closing brace
79 | if (content[content.length - 1] !== '}') {
80 | debug("Content doesn't end with '}', adjusting...");
81 | const lastBraceIndex = content.lastIndexOf('}');
82 | if (lastBraceIndex !== -1) {
83 | const actualContentLength = lastBraceIndex + 1;
84 | debug(`Adjusted content length from ${contentLength} to ${actualContentLength}`);
85 | content = content.substring(0, actualContentLength);
86 | // Update buffer position based on actual content length
87 | this.buffer = this.buffer.substring(headerEnd + actualContentLength);
88 | } else {
89 | debug("No closing brace found, using original content length");
90 | // No closing brace found, use original approach
91 | this.buffer = this.buffer.substring(headerEnd + contentLength);
92 | }
93 | } else {
94 | debug("Content ends with '}', no adjustment needed");
95 | // Content looks good, remove precisely this processed message from buffer
96 | this.buffer = this.buffer.substring(headerEnd + contentLength);
97 | }
98 |
99 |
100 | // Parse the message and add to queue
101 | try {
102 | const message = JSON.parse(content) as LSPMessage;
103 | this.messageQueue.push(message);
104 | this.processMessageQueue();
105 | } catch (error) {
106 | logError("Failed to parse LSP message:", error);
107 | }
108 | }
109 | }
110 |
111 | private async processMessageQueue(): Promise<void> {
112 | // If already processing, return to avoid concurrent processing
113 | if (this.processingQueue) return;
114 |
115 | this.processingQueue = true;
116 |
117 | try {
118 | while (this.messageQueue.length > 0) {
119 | const message = this.messageQueue.shift()!;
120 | await this.handleMessage(message);
121 | }
122 | } finally {
123 | this.processingQueue = false;
124 | }
125 | }
126 |
127 | private async handleMessage(message: LSPMessage): Promise<void> {
128 | // Log the message with appropriate level
129 | try {
130 | const direction = 'RECEIVED';
131 | const messageStr = JSON.stringify(message, null, 2);
132 | // Use method to determine log level if available, otherwise use debug
133 | const method = message.method || '';
134 | const logLevel = this.getLSPMethodLogLevel(method);
135 | log(logLevel, `LSP ${direction} (${method}): ${messageStr}`);
136 | } catch (error) {
137 | warning("Error logging LSP message:", error);
138 | }
139 |
140 | // Handle response messages
141 | if ('id' in message && (message.result !== undefined || message.error !== undefined)) {
142 | const promise = this.responsePromises.get(message.id!);
143 | if (promise) {
144 | if (message.error) {
145 | promise.reject(message.error);
146 | } else {
147 | promise.resolve(message.result);
148 | }
149 | this.responsePromises.delete(message.id!);
150 | }
151 | }
152 |
153 | // Store server capabilities from initialize response
154 | if ('id' in message && message.result?.capabilities) {
155 | this.serverCapabilities = message.result.capabilities;
156 | }
157 |
158 | // Handle notification messages
159 | if ('method' in message && message.id === undefined) {
160 | // Handle diagnostic notifications
161 | if (message.method === 'textDocument/publishDiagnostics' && message.params) {
162 | const { uri, diagnostics } = message.params;
163 |
164 | if (uri && Array.isArray(diagnostics)) {
165 | const severity = diagnostics.length > 0 ?
166 | Math.min(...diagnostics.map(d => d.severity || 4)) : 4;
167 |
168 | // Map LSP severity to our log levels
169 | const severityToLevel: Record<number, string> = {
170 | 1: 'error', // Error
171 | 2: 'warning', // Warning
172 | 3: 'info', // Information
173 | 4: 'debug' // Hint
174 | };
175 |
176 | const level = severityToLevel[severity] || 'debug';
177 |
178 | log(level as any, `Received ${diagnostics.length} diagnostics for ${uri}`);
179 |
180 | // Store diagnostics, replacing any previous ones for this URI
181 | this.documentDiagnostics.set(uri, diagnostics);
182 |
183 | // Notify all subscribers about this update
184 | this.notifyDiagnosticUpdate(uri, diagnostics);
185 | }
186 | }
187 | }
188 | }
189 |
190 | private getLSPMethodLogLevel(method: string): LoggingLevel {
191 | // Define appropriate log levels for different LSP methods
192 | if (method.startsWith('textDocument/did')) {
193 | return 'debug'; // Document changes are usually debug level
194 | }
195 |
196 | if (method.includes('diagnostic') || method.includes('publishDiagnostics')) {
197 | return 'info'; // Diagnostics depend on their severity, but base level is info
198 | }
199 |
200 | if (method === 'initialize' || method === 'initialized' ||
201 | method === 'shutdown' || method === 'exit') {
202 | return 'notice'; // Important lifecycle events are notice level
203 | }
204 |
205 | // Default to debug level for most LSP operations
206 | return 'debug';
207 | }
208 |
209 | private sendRequest<T>(method: string, params?: any): Promise<T> {
210 | // Check if the process is started
211 | if (!this.process) {
212 | return Promise.reject(new Error("LSP process not started. Please call start_lsp first."));
213 | }
214 |
215 | const id = this.nextId++;
216 | const request: LSPMessage = {
217 | jsonrpc: "2.0",
218 | id,
219 | method,
220 | params
221 | };
222 |
223 | // Log the request with appropriate level
224 | try {
225 | const direction = 'SENT';
226 | const requestStr = JSON.stringify(request, null, 2);
227 | const logLevel = this.getLSPMethodLogLevel(method);
228 | log(logLevel as any, `LSP ${direction} (${method}): ${requestStr}`);
229 | } catch (error) {
230 | warning("Error logging LSP request:", error);
231 | }
232 |
233 | const promise = new Promise<T>((resolve, reject) => {
234 | // Set timeout for request
235 | const timeoutId = setTimeout(() => {
236 | if (this.responsePromises.has(id)) {
237 | this.responsePromises.delete(id);
238 | reject(new Error(`Timeout waiting for response to ${method} request`));
239 | }
240 | }, 10000); // 10 second timeout
241 |
242 | // Store promise with cleanup for timeout
243 | this.responsePromises.set(id, {
244 | resolve: (result: T) => {
245 | clearTimeout(timeoutId);
246 | resolve(result);
247 | },
248 | reject: (error: any) => {
249 | clearTimeout(timeoutId);
250 | reject(error);
251 | }
252 | });
253 | });
254 |
255 | const content = JSON.stringify(request);
256 | // Content-Length header should only include the length of the JSON content
257 | const header = `Content-Length: ${Buffer.byteLength(content)}\r\n\r\n`;
258 | this.process.stdin.write(header + content);
259 |
260 | return promise;
261 | }
262 |
263 | private sendNotification(method: string, params?: any): void {
264 | // Check if the process is started
265 | if (!this.process) {
266 | console.error("LSP process not started. Please call start_lsp first.");
267 | return;
268 | }
269 |
270 | const notification: LSPMessage = {
271 | jsonrpc: "2.0",
272 | method,
273 | params
274 | };
275 |
276 | // Log the notification with appropriate level
277 | try {
278 | const direction = 'SENT';
279 | const notificationStr = JSON.stringify(notification, null, 2);
280 | const logLevel = this.getLSPMethodLogLevel(method);
281 | log(logLevel as any, `LSP ${direction} (${method}): ${notificationStr}`);
282 | } catch (error) {
283 | warning("Error logging LSP notification:", error);
284 | }
285 |
286 | const content = JSON.stringify(notification);
287 | // Content-Length header should only include the length of the JSON content
288 | const header = `Content-Length: ${Buffer.byteLength(content)}\r\n\r\n`;
289 | this.process.stdin.write(header + content);
290 | }
291 |
292 | async initialize(rootDirectory: string = "."): Promise<void> {
293 | if (this.initialized) return;
294 |
295 | try {
296 | // Start the process if it hasn't been started yet
297 | if (!this.process) {
298 | this.startProcess();
299 | }
300 |
301 | info("Initializing LSP connection...");
302 | await this.sendRequest("initialize", {
303 | processId: process.pid,
304 | clientInfo: {
305 | name: "lsp-mcp-server"
306 | },
307 | rootUri: "file://" + path.resolve(rootDirectory),
308 | capabilities: {
309 | textDocument: {
310 | hover: {
311 | contentFormat: ["markdown", "plaintext"]
312 | },
313 | completion: {
314 | completionItem: {
315 | snippetSupport: false
316 | }
317 | },
318 | codeAction: {
319 | dynamicRegistration: true
320 | },
321 | diagnostic: {
322 | dynamicRegistration: false
323 | },
324 | publishDiagnostics: {
325 | relatedInformation: true,
326 | versionSupport: false,
327 | tagSupport: {},
328 | codeDescriptionSupport: true,
329 | dataSupport: true
330 | }
331 | }
332 | }
333 | });
334 |
335 | this.sendNotification("initialized", {});
336 | this.initialized = true;
337 | notice("LSP connection initialized successfully");
338 | } catch (error) {
339 | logError("Failed to initialize LSP connection:", error);
340 | throw error;
341 | }
342 | }
343 |
344 | async openDocument(uri: string, text: string, languageId: string): Promise<void> {
345 | // Check if initialized, but don't auto-initialize
346 | if (!this.initialized) {
347 | throw new Error("LSP client not initialized. Please call start_lsp first.");
348 | }
349 |
350 | // If document is already open, update it instead of reopening
351 | if (this.openedDocuments.has(uri)) {
352 | // Get current version and increment
353 | const currentVersion = this.documentVersions.get(uri) || 1;
354 | const newVersion = currentVersion + 1;
355 |
356 | debug(`Document already open, updating content: ${uri} (version ${newVersion})`);
357 | this.sendNotification("textDocument/didChange", {
358 | textDocument: {
359 | uri,
360 | version: newVersion
361 | },
362 | contentChanges: [
363 | {
364 | text // Full document update
365 | }
366 | ]
367 | });
368 |
369 | // Update version
370 | this.documentVersions.set(uri, newVersion);
371 | return;
372 | }
373 |
374 | debug(`Opening document: ${uri}`);
375 | this.sendNotification("textDocument/didOpen", {
376 | textDocument: {
377 | uri,
378 | languageId,
379 | version: 1,
380 | text
381 | }
382 | });
383 |
384 | // Mark document as open and initialize version
385 | this.openedDocuments.add(uri);
386 | this.documentVersions.set(uri, 1);
387 | }
388 |
389 | // Check if a document is open
390 | isDocumentOpen(uri: string): boolean {
391 | return this.openedDocuments.has(uri);
392 | }
393 |
394 | // Get a list of all open documents
395 | getOpenDocuments(): string[] {
396 | return Array.from(this.openedDocuments);
397 | }
398 |
399 | // Close a document
400 | async closeDocument(uri: string): Promise<void> {
401 | // Check if initialized
402 | if (!this.initialized) {
403 | throw new Error("LSP client not initialized. Please call start_lsp first.");
404 | }
405 |
406 | // Only close if document is open
407 | if (this.openedDocuments.has(uri)) {
408 | debug(`Closing document: ${uri}`);
409 | this.sendNotification("textDocument/didClose", {
410 | textDocument: { uri }
411 | });
412 |
413 | // Remove from tracking
414 | this.openedDocuments.delete(uri);
415 | this.documentVersions.delete(uri);
416 | } else {
417 | debug(`Document not open: ${uri}`);
418 | }
419 | }
420 |
421 | // Get diagnostics for a file
422 | getDiagnostics(uri: string): any[] {
423 | return this.documentDiagnostics.get(uri) || [];
424 | }
425 |
426 | // Get all diagnostics
427 | getAllDiagnostics(): Map<string, any[]> {
428 | return new Map(this.documentDiagnostics);
429 | }
430 |
431 | // Subscribe to diagnostic updates
432 | subscribeToDiagnostics(callback: DiagnosticUpdateCallback): void {
433 | this.diagnosticSubscribers.add(callback);
434 |
435 | // Send initial diagnostics for all open documents
436 | this.documentDiagnostics.forEach((diagnostics, uri) => {
437 | callback(uri, diagnostics);
438 | });
439 | }
440 |
441 | // Unsubscribe from diagnostic updates
442 | unsubscribeFromDiagnostics(callback: DiagnosticUpdateCallback): void {
443 | this.diagnosticSubscribers.delete(callback);
444 | }
445 |
446 | // Notify all subscribers about diagnostic updates
447 | private notifyDiagnosticUpdate(uri: string, diagnostics: any[]): void {
448 | this.diagnosticSubscribers.forEach(callback => {
449 | try {
450 | callback(uri, diagnostics);
451 | } catch (error) {
452 | warning("Error in diagnostic subscriber callback:", error);
453 | }
454 | });
455 | }
456 |
457 | // Clear all diagnostic subscribers
458 | clearDiagnosticSubscribers(): void {
459 | this.diagnosticSubscribers.clear();
460 | }
461 |
462 | async getInfoOnLocation(uri: string, position: { line: number, character: number }): Promise<string> {
463 | // Check if initialized, but don't auto-initialize
464 | if (!this.initialized) {
465 | throw new Error("LSP client not initialized. Please call start_lsp first.");
466 | }
467 |
468 | debug(`Getting info on location: ${uri} (${position.line}:${position.character})`);
469 |
470 | try {
471 | // Use hover request to get information at the position
472 | const response = await this.sendRequest<any>("textDocument/hover", {
473 | textDocument: { uri },
474 | position
475 | });
476 |
477 | if (response?.contents) {
478 | if (typeof response.contents === 'string') {
479 | return response.contents;
480 | } else if (response.contents.value) {
481 | return response.contents.value;
482 | } else if (Array.isArray(response.contents)) {
483 | return response.contents.map((item: any) =>
484 | typeof item === 'string' ? item : item.value || ''
485 | ).join('\n');
486 | }
487 | }
488 | } catch (error) {
489 | warning(`Error getting hover information: ${error instanceof Error ? error.message : String(error)}`);
490 | }
491 |
492 | return '';
493 | }
494 |
495 | async getCompletion(uri: string, position: { line: number, character: number }): Promise<any[]> {
496 | // Check if initialized, but don't auto-initialize
497 | if (!this.initialized) {
498 | throw new Error("LSP client not initialized. Please call start_lsp first.");
499 | }
500 |
501 | debug(`Getting completions at location: ${uri} (${position.line}:${position.character})`);
502 |
503 | try {
504 | const response = await this.sendRequest<any>("textDocument/completion", {
505 | textDocument: { uri },
506 | position
507 | });
508 |
509 | if (Array.isArray(response)) {
510 | return response;
511 | } else if (response?.items && Array.isArray(response.items)) {
512 | return response.items;
513 | }
514 | } catch (error) {
515 | warning(`Error getting completions: ${error instanceof Error ? error.message : String(error)}`);
516 | }
517 |
518 | return [];
519 | }
520 |
521 | async getCodeActions(uri: string, range: { start: { line: number, character: number }, end: { line: number, character: number } }): Promise<any[]> {
522 | // Check if initialized, but don't auto-initialize
523 | if (!this.initialized) {
524 | throw new Error("LSP client not initialized. Please call start_lsp first.");
525 | }
526 |
527 | debug(`Getting code actions for range: ${uri} (${range.start.line}:${range.start.character} to ${range.end.line}:${range.end.character})`);
528 |
529 | try {
530 | const response = await this.sendRequest<any>("textDocument/codeAction", {
531 | textDocument: { uri },
532 | range,
533 | context: {
534 | diagnostics: []
535 | }
536 | });
537 |
538 | if (Array.isArray(response)) {
539 | return response;
540 | }
541 | } catch (error) {
542 | warning(`Error getting code actions: ${error instanceof Error ? error.message : String(error)}`);
543 | }
544 |
545 | return [];
546 | }
547 |
548 | async shutdown(): Promise<void> {
549 | if (!this.initialized) return;
550 |
551 | try {
552 | info("Shutting down LSP connection...");
553 |
554 | // Clear all diagnostic subscribers
555 | this.clearDiagnosticSubscribers();
556 |
557 | // Close all open documents before shutting down
558 | for (const uri of this.openedDocuments) {
559 | try {
560 | this.sendNotification("textDocument/didClose", {
561 | textDocument: { uri }
562 | });
563 | } catch (error) {
564 | warning(`Error closing document ${uri}:`, error);
565 | }
566 | }
567 |
568 | await this.sendRequest("shutdown");
569 | this.sendNotification("exit");
570 | this.initialized = false;
571 | this.openedDocuments.clear();
572 | notice("LSP connection shut down successfully");
573 | } catch (error) {
574 | logError("Error shutting down LSP connection:", error);
575 | }
576 | }
577 |
578 | async restart(rootDirectory?: string): Promise<void> {
579 | info("Restarting LSP server...");
580 |
581 | // If initialized, try to shut down cleanly first
582 | if (this.initialized) {
583 | try {
584 | await this.shutdown();
585 | } catch (error) {
586 | warning("Error shutting down LSP server during restart:", error);
587 | }
588 | }
589 |
590 | // Kill the process if it's still running
591 | if (this.process && !this.process.killed) {
592 | try {
593 | this.process.kill();
594 | notice("Killed existing LSP process");
595 | } catch (error) {
596 | logError("Error killing LSP process:", error);
597 | }
598 | }
599 |
600 | // Reset state
601 | this.buffer = "";
602 | this.messageQueue = [];
603 | this.nextId = 1;
604 | this.responsePromises.clear();
605 | this.initialized = false;
606 | this.serverCapabilities = null;
607 | this.openedDocuments.clear();
608 | this.documentVersions.clear();
609 | this.processingQueue = false;
610 | this.documentDiagnostics.clear();
611 | this.clearDiagnosticSubscribers();
612 |
613 | // Start a new process
614 | this.startProcess();
615 |
616 | // Initialize with the provided root directory or use the stored one
617 | if (rootDirectory) {
618 | await this.initialize(rootDirectory);
619 | notice(`LSP server restarted and initialized with root directory: ${rootDirectory}`);
620 | } else {
621 | info("LSP server restarted but not initialized. Call start_lsp to initialize.");
622 | }
623 | }
624 | }
625 |
```