Skip to main content
Glama
README.md
<h1 align="center">lsp2mcp · Convert a local LSP into an MCP</h1>

<p align="center">
  <a href="README.md">English</a>
  |
  <a href="README-zh.md">简体中文</a>
</p>

<p align="center">
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-2ea44f" alt="Apache License 2.0" /></a>
</p>

`lsp2mcp` starts the language servers already installed on your machine and exposes them as read-only MCP tools. It answers questions an agent cannot settle by searching text: where a symbol is defined, who references it, what type it has, what the compiler would complain about

## Why

Plain text search cannot resolve types. `interface` implementations, overloads, re-exports and shadowed names all look identical to grep. Language servers do this resolution properly, but editor integrations are the only common way to reach them

AI coding agents typically run without a language server attached. `lsp2mcp` gives an agent the same queries an IDE has, over stdio MCP, reusing the servers you already trust

## Install

Requires Node.js 20 or newer, and the language servers you want to use already on `PATH`

```bash
npm install -g @sglwsjxh/lsp2mcp
```

Create a config file next to the package or pass its path. A starting template ships as `mcp.json.example`

```bash
cp mcp.json.example mcp.json
```

The program reads `mcp.json` from its working directory. If the file is absent it fails loudly and prints the path it looked at, rather than silently falling back to the template

## Register with a client

```jsonc
{
  "mcp": {
    "servers": {
      "lsp2mcp": {
        "type": "local",
        "command": ["node", "/absolute/path/to/lsp2mcp/dist/index.js"],
        "environment": {
          "LSP2MCP_CONFIG": "/absolute/path/to/lsp2mcp/mcp.json"
        }
      }
    }
  }
}
```

Absolute paths in the client entry are deliberate: the process working directory is owned by the client, not by this package

## Configuration

Each entry maps a language to one command. The command is spawned directly when it resolves to an executable, and through `cmd.exe` when it resolves to a `.cmd` or `.bat` wrapper. This distinction matters on Windows only

```jsonc
{
  "servers": {
    "typescript": {
      "extensions": [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"],
      "languageIds": { ".tsx": "typescriptreact", ".jsx": "javascriptreact" },
      "command": ["typescript-language-server", "--stdio"]
    },
    "java": {
      "extensions": [".java"],
      "command": ["jdtls", "-data", "{root}/.jdtls-workspace"]
    }
  }
}
```

| Field | Required | Meaning |
|---|---|---|
| `extensions` | yes | File suffixes routed to this server, matched case-insensitively |
| `command` | yes | Executable plus arguments |
| `languageIds` | no | Per-extension override of the LSP `languageId`, used when a suffix is not what the server expects |

`{root}` inside any argument is replaced with the project root passed to `lsp_init`

## Servers in the template

| Language | Command | Extra requirement |
|---|---|---|
| TypeScript / JavaScript | `typescript-language-server` | none |
| Vue | `vue-language-server` | project needs a local `typescript`; missing it disables the entry instead of falling back to a global copy. Script-region queries additionally need the plugin shown in Vue single-file components |
| Python | `pyright-langserver` | none |
| C# | `csharp-ls` | none |
| Rust | `rust-analyzer` | none |
| Go | `gopls` | none |
| C / C++ | `clangd` | a compilation database gives materially better results |
| Dart / Flutter | `dart language-server` | Flutter SDK provides this command |
| JSON | `vscode-json-language-server` | syntax checking only, no schema store lookup |
| HTML | `vscode-html-language-server` | standalone `.html` files |
| CSS | `vscode-css-language-server` | standalone `.css` files |
| Java | `jdtls` | needs `jdtls` on `PATH` and `python` available; the wrapper script resolves the Eclipse launcher itself |

Add or edit entries freely. Nothing about the language set is compiled in

## Tools

All tools are read-only. There is no rename, format or code action, because those mutate your working tree and belong to an editing tool

| Tool | Input | Returns |
|---|---|---|
| `lsp_init` | `root`, optional `languages` | binds the project root, reports detected languages, starts nothing |
| `lsp_health` | none | state per language: ready, failed, crashed, missing command, plus the reason |
| `lsp_shutdown` | optional `language` | stops one or all servers and reaps their process tree |
| `lsp_definition` | `file`, `line`, `character` | where the symbol is declared |
| `lsp_references` | `file`, `line`, `character`, optional `include_declaration`, `limit`, `offset` | all usages, paginated |
| `lsp_implementation` | `file`, `line`, `character` | implementations of an interface or abstract member |
| `lsp_hover` | `file`, `line`, `character` | resolved type signature and documentation |
| `lsp_document_symbols` | `file` | symbols in one file with line numbers |
| `lsp_diagnostics` | `file`, or `scope` set to `workspace` | errors and warnings with severity, code and range |

`line` and `character` are **1-based**, matching editor display and file readers. They are converted internally for the 0-based LSP wire format

Servers start on demand. Calling `lsp_definition` on a `.py` file for a project whose Python server is not running yet starts it, waits for `initialize`, then serves the request

## Behavior notes

- A server that never declared a capability returns an explicit "not supported by this language server" instead of an empty list, so absence never reads as success
- Crashed servers are restarted on the next call and reported through `lsp_health`
- Diagnostics arrive asynchronously. A query waits within a bounded timeout rather than returning immediately empty
- On Windows, stopping a server kills the whole process tree. `jdtls` spawns `cmd` then `python` then `java`, and killing only the direct child leaves orphans holding hundreds of megabytes
- Before the Java server starts, `<root>/.jdtls-workspace/` is created and a `.gitignore` containing `*` is written into it, so the workspace never shows up in `git status`. The same rule covers any argument that points at a hidden directory inside the project. Other servers need no such handling because they do not write into your tree

## Vue single-file components

`.vue` needs two servers, and they do different halves of the job. `@vue/language-server` 3 and later dropped its own pipe to the TypeScript server and **made the client responsible for relaying tsserver traffic**: the server emits a `tsserver/request` notification and blocks until a matching `tsserver/response` arrives. Without that relay every query on a `.vue` file hangs until timeout

`lsp2mcp` implements the relay. Starting a `vue` server always brings up the `typescript` server for the same root first and routes the requests through it

The remaining split is real, not a bug:

| Query on a `.vue` file | Answered by |
|---|---|
| `lsp_document_symbols` | `vue` — the SFC structure plus the whole template node tree |
| `lsp_hover`, `lsp_definition`, `lsp_references`, `lsp_implementation` | `typescript` — tried on `vue` first, retried there when it comes back empty |
| `lsp_diagnostics` | both, merged and deduplicated by file, line, column and message |

Every response names the server that answered, so a fallback never looks like a native result

Script-region semantics also need the TypeScript plugin loaded in the `typescript` entry:

```jsonc
"typescript": {
  "extensions": [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"],
  "command": ["typescript-language-server", "--stdio"],
  "initialization": {
    "plugins": [
      {
        "name": "@vue/typescript-plugin",
        "location": "PATH/TO/node_modules/@vue/language-server",
        "languages": ["vue"]
      }
    ]
  }
}
```

`location` is a directory that contains the plugin under its `node_modules`; the plugin must be the same version as the running `@vue/language-server`. Without that block, template structure and syntax diagnostics still work, but `hover`, `definition` and `references` inside `<script>` return nothing

## Known limits

- `jdtls` does not advertise `diagnosticProvider`, so Java diagnostics come from `publishDiagnostics` pushes and a fresh file may need a moment to settle. It does advertise `implementationProvider`
- Markdown servers are not included. `vscode-markdown-language-server` requires a client that answers its private `markdown/parse` request, which generic LSP clients do not implement
- Position-based tools need the token to be under the cursor position you pass. Selecting a whole identifier range is ambiguous; point at its start
- No automatic project discovery. The agent passes `root` explicitly, because scanning a directory tree is slow and does not prove which project a file belongs to

## Development

```bash
npm ci
npm run typecheck
npm run build
node dist/index.js
```

`dist/` is gitignored and is what gets published

## License

Apache License 2.0. See [LICENSE](LICENSE)