|
| 1 | +# claude-lsp-direct |
| 2 | + |
| 3 | +Per-workspace LSP proxies over HTTP for **Python**, **TypeScript / JavaScript**, **C#**, **Vue**, **Scala**. Sub-100ms steady-state; sidesteps the per-tool-call round-trip that agent harnesses pay. Per-workspace isolation fixes servers that bind `rootUri` at init (e.g. `csharp-ls`). |
| 4 | + |
| 5 | +Extends the pattern Angel Blanco ([@NovaMage](https://github.com/NovaMage)) first demonstrated for Scala in [agents-metals-direct-lsp](https://github.com/NovaMage/agents-metals-direct-lsp) across the full set of language servers common in multi-stack monorepos. |
| 6 | + |
| 7 | +## Why |
| 8 | + |
| 9 | +Native `LSP(operation=...)` calls in Claude Code cost ~8-9s per invocation in my measurements — this is harness round-trip, not server speed. Angel's independent measurement on a 347k-LOC Scala monorepo put MCP-wrapped LSP calls at **~230× slower than direct HTTP** against the same `metals-mcp` backend ([claude-code#45132 comment](https://github.com/anthropics/claude-code/issues/45132#issuecomment-3492812921)). At that cost, any workflow doing dozens of lookups (call-hierarchy walks, rename-impact analysis, cross-package API surveys) is unusable in practice. |
| 10 | + |
| 11 | +This repo batches: one shell call to the bash wrapper, many LSP calls over HTTP to a persistent per-workspace server. Amortizes the agent turn. Also fixes `csharp-ls`: its `rootUri`-at-init binding means tool-call clients can't switch .NET projects mid-session — per-workspace spawn here makes that free. |
| 12 | + |
| 13 | +## Benchmarks |
| 14 | + |
| 15 | +Direct-wrapper numbers measured 2026-04-21 on macOS 26.4.1 arm64 (see *Tested versions* below) against real workspace files. "Before" for py/ts/cs is native `LSP()` in Claude Code (per-tool-call harness round-trip included). "Before" for Scala is Angel's published benchmark on a Scala 3 / Play Framework 3 monorepo (16 build targets, ~5,600 files, 347k LOC); query = `get-usages` on a case-class field with 107 references. |
| 16 | + |
| 17 | +| language | source of "before" | before cold | before warm | after cold | after warm | warm speedup | |
| 18 | +|---|---|---|---|---|---|---| |
| 19 | +| python | my measurement, `LSP()` tool | 14.4s | 9.4s | 0.14s | 0.07s | **~130×** | |
| 20 | +| typescript | my measurement, `LSP()` tool | 9.0s | 9.6s | 0.26s | 0.07s | **~130×** | |
| 21 | +| csharp | my measurement, `LSP()` tool * | ~9s (empty) | ~10s (empty) | 30-120s † | 0.07s | rootUri fix + **~130× warm** | |
| 22 | +| vue | unsupported ‡ | — | — | 6.6s | 0.09s | enables capability | |
| 23 | +| scala | [Angel Blanco benchmark](https://github.com/anthropics/claude-code/issues/45132#issuecomment-3492812921), Claude MCP/stdio | 10.7s | ~6.3s avg | 0.14s § | 0.08s | **~80×** (his direct-HTTP baseline: 0.038s, essentially the same) | |
| 24 | + |
| 25 | +\* `csharp-ls` bound to wrong `rootUri` (cwd outside `.sln` ancestor) returns empty in ~9-10s. |
| 26 | +† cs-direct cold = MSBuild solution load + NuGet restore. Amortized across the session. |
| 27 | +‡ Vue LS v3 is hybrid-mandatory (needs paired tsserver + `@vue/typescript-plugin`); Claude Code's plugin loader can't host the paired setup, so native `LSP()` on `.vue` isn't available. |
| 28 | +§ metals-direct cold of 0.14s is the server-adoption path (reuses an existing `metals-mcp` via `<workspace>/.metals/mcp.json`); fresh cold with Bloop re-import is 30-120s. |
| 29 | + |
| 30 | +The point isn't the specific numbers — it's the order-of-magnitude gap between a persistent HTTP proxy and the minimum cost of a per-call tool turn. |
| 31 | + |
| 32 | +## Architecture |
| 33 | + |
| 34 | +``` |
| 35 | +CLI → <lang>-direct (bash) |
| 36 | + │ HTTP POST /lsp { method, params } |
| 37 | + ▼ |
| 38 | + Node coordinator (persistent, per-workspace) |
| 39 | + │ stdio JSON-RPC (LSP or custom-framed) |
| 40 | + ▼ |
| 41 | + Language server (pyright / ts-ls / csharp-ls / Vue LS / metals-mcp) |
| 42 | +``` |
| 43 | + |
| 44 | +- Thin bash wrapper per language — workspace walk-up, state dir, curl client |
| 45 | +- Shared generic coordinator (`lsp-stdio-proxy.js`) for py/ts/cs; dedicated hybrid coordinator (`vue-direct-coordinator.js`) for Vue v3 |
| 46 | +- One process per workspace (hashed state dir at `~/.cache/<name>-direct/<hash>/`) |
| 47 | +- HTTP `/health` for liveness (sandboxed environments deny `kill -0` and `/dev/tcp`) |
| 48 | +- Raw LSP method names, unmodified — except `metals-direct` which exposes `metals-mcp`'s 17-tool MCP surface |
| 49 | + |
| 50 | +Full spec: [`docs/convention.md`](docs/convention.md) · [`docs/architecture.md`](docs/architecture.md) · [`docs/troubleshooting.md`](docs/troubleshooting.md) |
| 51 | + |
| 52 | +Per-language: [Python](docs/per-language/python.md) · [TypeScript](docs/per-language/typescript.md) · [C#](docs/per-language/csharp.md) · [Vue](docs/per-language/vue.md) · [Scala](docs/per-language/scala.md) |
| 53 | + |
| 54 | +## Quickstart |
| 55 | + |
| 56 | +```bash |
| 57 | +git clone https://github.com/<your-user>/claude-lsp-direct.git ~/projects/claude-lsp-direct |
| 58 | +cd ~/projects/claude-lsp-direct |
| 59 | +./scripts/install.sh # symlinks to ~/.claude/ + merges settings.json |
| 60 | +./scripts/verify.sh # functional probe on bundled fixtures |
| 61 | +``` |
| 62 | + |
| 63 | +Install only the language server(s) you need (see per-language docs for version pinning). |
| 64 | + |
| 65 | +```bash |
| 66 | +py-direct call textDocument/documentSymbol \ |
| 67 | + '{"textDocument":{"uri":"file:///path/to/your.py"}}' |
| 68 | +``` |
| 69 | + |
| 70 | +Manual install (non-Claude-Code users): `ln -s ~/projects/claude-lsp-direct/bin/* ~/.local/bin/`. Any editor or agent that can shell + curl can use this. |
| 71 | + |
| 72 | +## Tested versions |
| 73 | + |
| 74 | +Exact versions this was developed and benchmarked against. Other versions likely work; these are what's verified. |
| 75 | + |
| 76 | +| component | version | |
| 77 | +|---|---| |
| 78 | +| macOS | 26.4.1 arm64 (Darwin 25.4.0) | |
| 79 | +| Node.js | 24.14.1 (via nvm) | |
| 80 | +| Python | 3.9.6 | |
| 81 | +| bash | 3.2.57 / GNU bash on Linux | |
| 82 | +| pyright | 1.1.409 | |
| 83 | +| typescript-language-server | 5.1.3 | |
| 84 | +| typescript | 5.9.3 | |
| 85 | +| @vue/language-server | 3.2.6 | |
| 86 | +| @vue/typescript-plugin | 3.2.6 | |
| 87 | +| csharp-ls | 0.24.0.0 | |
| 88 | +| metals-mcp | 1.6.7 (Angel's benchmark); `brew install metals` (latest) otherwise | |
| 89 | +| .NET SDK | 9.x recommended (10.x has an MSBuild BuildHost pipe issue with csharp-ls on macOS — see [csharp docs](docs/per-language/csharp.md)) | |
| 90 | + |
| 91 | +If you're running different versions, `scripts/verify.sh` is the quickest way to confirm the wrapper still works end-to-end on your stack. |
| 92 | + |
| 93 | +## A note to the Claude platform team |
| 94 | + |
| 95 | +The gap this repo fills exists because every `LSP()` call is a separate agent turn, and each turn has a fixed floor that's an order of magnitude larger than the underlying LSP op. From a developer trying to build agentic workflows on Claude Code, that floor — not model speed, not server speed — is the bottleneck. A few upstream changes would collapse the need for this repo: |
| 96 | + |
| 97 | +1. **A batched `LSP()` tool** accepting an array of operations. Most semantic-navigation workflows naturally batch; today each step is its own tool turn. |
| 98 | +2. **Persistent LSP sessions + initializationOptions in the plugin-loader schema.** `plugin.json` currently supports only `command | args | extensionToLanguage | startupTimeout` — no init options, no env, no cross-server bridging. Hybrid servers (Vue v3) are structurally unsupported; `csharp-ls`'s rootUri-at-init is un-fixable from a client that can only spawn one instance per session. |
| 99 | +3. **Per-workspace pooling** — the natural next step once sessions are persistent. |
| 100 | +4. **Lower the per-tool-call floor.** Even a 9s → 1s improvement would erase most of the perceived-latency advantage of direct wrappers. |
| 101 | + |
| 102 | +Happy to chat if any of this is under consideration or if the patterns here would be useful as reference. |
| 103 | + |
| 104 | +## Acknowledgments |
| 105 | + |
| 106 | +- **Angel Blanco (Mago)** — [@NovaMage](https://github.com/NovaMage) — published the original [agents-metals-direct-lsp](https://github.com/NovaMage/agents-metals-direct-lsp) pattern for Scala, ran the benchmarks in [claude-code#45132](https://github.com/anthropics/claude-code/issues/45132) showing ~230× MCP overhead vs direct HTTP, and opened the [Scala contributors thread](https://contributors.scala-lang.org/t/rallying-scala-metals-lsp-native-support-in-claude-code/7437) rallying support. This repo is a generalization of his approach across more languages. |
| 107 | +- **[Tomasz Godzik](https://github.com/tgodzik)** (Scalameta / Metals maintainer) — for `metals-mcp` and for documenting why Metals + generic LSP clients don't compose cleanly. |
| 108 | +- **Volar.js / Vue Language Tools** — for publishing the hybrid architecture clearly enough that a bridge from outside was possible. |
| 109 | +- **pyright**, **typescript-language-server**, **csharp-language-server** maintainers — for clean standalone stdio implementations this repo is a thin layer over. |
| 110 | + |
| 111 | +## Contributing |
| 112 | + |
| 113 | +See [CONTRIBUTING.md](CONTRIBUTING.md). Adding a language is usually a ~100 LOC bash wrapper + fixture + doc page + CI entry. PRs welcome for Go, Rust, Ruby, Java, Kotlin, Swift, Elixir, … |
| 114 | + |
| 115 | +## License |
| 116 | + |
| 117 | +MIT — see [LICENSE](LICENSE). |
0 commit comments