Skip to content

Commit d36c361

Browse files
committed
claude-lsp-direct v1.0.0
Per-workspace LSP proxies over HTTP for Python, TypeScript, C#, Vue, Scala. ~100x faster perceived latency than tool-call LSP clients; per-workspace isolation fixes rootUri-at-init servers. Includes: - 5 language wrappers (bin/<lang>-direct) - Shared generic coordinator (bin/lsp-stdio-proxy.js) - Vue hybrid bridge (bin/vue-direct-coordinator.js) - Optional Claude Code hooks (hooks/enforce-lsp-*) - install.sh / uninstall.sh / verify.sh - Minimal fixtures for CI + local verification - GitHub Actions CI matrix (macOS + Ubuntu) - Full docs: convention, architecture, troubleshooting, per-language x5 - MIT license Extends the Scala-only pattern Angel Blanco (@NovaMage) first demonstrated in agents-metals-direct-lsp across the full common-language set.
0 parents  commit d36c361

40 files changed

Lines changed: 3680 additions & 0 deletions

.github/workflows/ci.yml

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
name: ci
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
hook-tests:
11+
name: hook tests (${{ matrix.os }})
12+
runs-on: ${{ matrix.os }}
13+
strategy:
14+
fail-fast: false
15+
matrix:
16+
os: [ubuntu-latest, macos-latest]
17+
steps:
18+
- uses: actions/checkout@v4
19+
20+
- name: setup python
21+
uses: actions/setup-python@v5
22+
with:
23+
python-version: '3.11'
24+
25+
- name: install pytest
26+
run: pip install pytest
27+
28+
- name: install jq (linux)
29+
if: matrix.os == 'ubuntu-latest'
30+
run: sudo apt-get update && sudo apt-get install -y jq
31+
32+
- name: install jq (macos)
33+
if: matrix.os == 'macos-latest'
34+
run: brew install jq
35+
36+
- name: run hook tests
37+
run: bash hooks/tests/run.sh
38+
39+
fixture-probe:
40+
name: fixture probe (${{ matrix.os }})
41+
runs-on: ${{ matrix.os }}
42+
strategy:
43+
fail-fast: false
44+
matrix:
45+
os: [ubuntu-latest, macos-latest]
46+
steps:
47+
- uses: actions/checkout@v4
48+
49+
- name: setup node
50+
uses: actions/setup-node@v4
51+
with:
52+
node-version: '20'
53+
54+
- name: setup python
55+
uses: actions/setup-python@v5
56+
with:
57+
python-version: '3.11'
58+
59+
- name: install language servers
60+
run: |
61+
npm i -g pyright typescript-language-server typescript
62+
63+
- name: install jq (linux)
64+
if: matrix.os == 'ubuntu-latest'
65+
run: sudo apt-get update && sudo apt-get install -y jq
66+
67+
- name: install jq (macos)
68+
if: matrix.os == 'macos-latest'
69+
run: brew install jq
70+
71+
- name: probe python + typescript fixtures
72+
run: bash scripts/verify.sh

.gitignore

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# OS
2+
.DS_Store
3+
Thumbs.db
4+
5+
# editors
6+
.vscode/
7+
.idea/
8+
*.swp
9+
*~
10+
11+
# language servers / caches
12+
node_modules/
13+
.venv/
14+
venv/
15+
__pycache__/
16+
*.pyc
17+
.pytest_cache/
18+
.mypy_cache/
19+
20+
# fixture build artifacts
21+
fixtures/**/obj/
22+
fixtures/**/bin/
23+
fixtures/**/dist/
24+
fixtures/**/.bloop/
25+
fixtures/**/target/
26+
27+
# local state (user-owned, not repo data)
28+
.claude/

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented here.
4+
5+
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [SemVer](https://semver.org/).
6+
7+
## [1.0.0] — 2026-04-21
8+
9+
### Added
10+
- `bin/metals-direct` — Scala via metals-mcp over HTTP (17 semantic tools)
11+
- `bin/vue-direct` + `bin/vue-direct-coordinator.js` — Vue LS v3 hybrid bridge (Vue LS + tsserver + `@vue/typescript-plugin`)
12+
- `bin/py-direct` — pyright-langserver proxy
13+
- `bin/ts-direct` — typescript-language-server proxy
14+
- `bin/cs-direct` — csharp-ls proxy; fixes rootUri-at-init binding via per-workspace spawn
15+
- `bin/lsp-stdio-proxy.js` — shared generic coordinator for standalone stdio LSPs
16+
- `hooks/enforce-lsp-over-grep.py` — Claude Code hook redirecting source-code grep to direct wrappers
17+
- `hooks/enforce-lsp-workspace-root.py` — C# workspace root enforcement; bypasses when cs-direct is installed
18+
- `scripts/install.sh`, `scripts/uninstall.sh`, `scripts/verify.sh`
19+
- `docs/convention.md`, `docs/architecture.md`, `docs/troubleshooting.md`, and per-language pages for all 5 supported languages
20+
- `fixtures/` — minimal sample projects for CI + local verification
21+
- GitHub Actions CI on macOS + Ubuntu
22+
23+
[1.0.0]: https://github.com/CHANGE-ME/claude-lsp-direct/releases/tag/v1.0.0

CONTRIBUTING.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Contributing
2+
3+
## Adding a new language
4+
5+
Minimal steps for a standard stdio LSP (server speaks LSP over `--stdio`, no hybrid coordination):
6+
7+
### 1. Copy the template
8+
```bash
9+
cp bin/py-direct bin/<lang>-direct
10+
```
11+
12+
### 2. Update the template vars at the top of the bash file
13+
```bash
14+
STATE_ROOT="${<LANG>_DIRECT_STATE:-$HOME/.cache/<lang>-direct}"
15+
PROXY="$HOME/.claude/bin/lsp-stdio-proxy.js" # leave as-is for standalone LSPs
16+
LSP_BIN="<language-server-binary>" # e.g. gopls, rust-analyzer
17+
LSP_ARGS=(--stdio) # or () if the server has no args
18+
LANG_ID="<lsp-language-id>" # go / rust / ruby / etc.
19+
WORKSPACE_MARKERS=(<markers in walk-up order>) # e.g. go.mod, Cargo.toml
20+
```
21+
22+
### 3. Update help banner + install-prereq message
23+
Search `bin/<lang>-direct` for the old binary name and replace.
24+
25+
### 4. Add a fixture
26+
```bash
27+
mkdir -p fixtures/<lang>
28+
# create a minimal 1-file sample + whatever manifest the language needs
29+
# (e.g. fixtures/go/main.go + fixtures/go/go.mod)
30+
```
31+
32+
### 5. Add a doc page
33+
```bash
34+
cp docs/per-language/python.md docs/per-language/<lang>.md
35+
# rewrite: install prereq, workspace markers, invocation examples, quirks
36+
```
37+
38+
### 6. Extend hook integration (optional)
39+
`hooks/enforce-lsp-over-grep.py``LANG_DIRECT_WRAPPER` dict:
40+
```python
41+
LANG_DIRECT_WRAPPER = {
42+
...
43+
"<lang>": ("<lang>-direct", "<binary-name>"),
44+
}
45+
```
46+
Also extend `EXT_LANG`, `RG_TYPE_LANG`, and `POS_CODE_FILE_RE` to include the new file extensions.
47+
48+
### 7. Add CI matrix entry
49+
`.github/workflows/ci.yml` — add a step that installs `<binary>` and runs `scripts/verify.sh` on the fixture.
50+
51+
### 8. Update README.md + docs/convention.md
52+
Add the new language to the primary-path table.
53+
54+
## Hybrid servers (require paired processes)
55+
56+
If the target LSP requires a paired companion process (like Vue LS v3 + tsserver + `@vue/typescript-plugin`), the generic `lsp-stdio-proxy.js` isn't enough. Write a dedicated coordinator modeled on `bin/vue-direct-coordinator.js`:
57+
- Spawn both children
58+
- Bridge any custom cross-server notifications
59+
- Expose the same HTTP surface (`POST /lsp`, `GET /health`)
60+
- Wire the bash wrapper to point at your new coordinator instead of `lsp-stdio-proxy.js`
61+
62+
## Invariants
63+
64+
Every wrapper MUST:
65+
- Live in `bin/<name>-direct`
66+
- Expose `start | call | stop | status | tools [workspace]` and `call <method> '<json>' [workspace]`
67+
- Use raw LSP method names (or the underlying tool's native command names)
68+
- Use `curl -fsS GET /health` for liveness (never `kill -0` or `/dev/tcp`)
69+
- Store per-workspace state in `~/.cache/<name>-direct/<hash>/{pid,port,workspace,log}`
70+
- Work on macOS + Linux
71+
- Not require any binary beyond `bash`, `node`, `python3`, `curl`, `jq`, standard POSIX utils, plus the language server itself
72+
73+
See [docs/convention.md](docs/convention.md) for the full list.
74+
75+
## PR checklist
76+
- [ ] Wrapper follows the CLI contract above
77+
- [ ] Fixture added with a minimal sample
78+
- [ ] Doc page added under `docs/per-language/<lang>.md`
79+
- [ ] CI job added for the new language
80+
- [ ] README.md primary-path table updated
81+
- [ ] `hooks/enforce-lsp-over-grep.py` extended (if the hook integration is in scope)
82+
- [ ] `scripts/verify.sh` runs the new fixture successfully
83+
- [ ] No personal paths, usernames, or project names in any file you touched — `grep -r "/Users/<user>\|/home/<user>\|<any real name>"` should return zero hits on your diff
84+
85+
## Reporting bugs
86+
Please include:
87+
- OS + version
88+
- Language server name + version (`<binary> --version`)
89+
- `~/.cache/<lang>-direct/<hash>/log` contents
90+
- Exact command that failed
91+
- Expected vs actual behavior

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 claude-lsp-direct contributors
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
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

Comments
 (0)