Gortex uses the Language Server Protocol (LSP) for two things:
- Compiler-grade resolution during enrichment. When the resolver
leaves an edge as
ast_inferredortext_matched, an LSP server (textDocument/definition+textDocument/implementation) upgrades it tolsp_resolved/lsp_dispatch. This raises precision onfind_usages,get_callers,find_implementations, and the contract pipeline's binding resolver. - On-demand actions via the four MCP tools that wrap the LSP
action surface:
get_diagnostics,get_code_actions,apply_code_action,fix_all_in_file.
Both paths route through the same per-daemon lsp.Router — one
subprocess per language server, lazy-spawned on first request, idle
reaper at ten minutes, LRU eviction at six concurrent.
Sixteen servers ship in the registry today
(internal/semantic/lsp/registry.go):
| Spec name | Command | Languages | Default priority |
|---|---|---|---|
gopls |
gopls |
go | 3 |
typescript-language-server |
typescript-language-server |
typescript, javascript | 5 |
pyright |
pyright-langserver |
python | 5 |
rust-analyzer |
rust-analyzer |
rust | 5 |
clangd |
clangd --background-index |
c, c++, objc, objc++ | 5 |
jdtls |
jdtls |
java | 6 |
kotlin-language-server |
kotlin-language-server |
kotlin | 6 |
omnisharp |
omnisharp -lsp |
csharp | 5 |
ruby-lsp |
ruby-lsp |
ruby | 5 |
phpactor |
phpactor language-server |
php | 5 |
lua-language-server |
lua-language-server |
lua | 5 |
sourcekit-lsp |
sourcekit-lsp |
swift | 5 |
haskell-language-server |
haskell-language-server-wrapper |
haskell | 5 |
elixir-ls |
elixir-ls |
elixir | 5 |
ocamllsp |
ocamllsp |
ocaml | 5 |
zls |
zls |
zig | 5 |
Several specs declare AlternativeCommands — Gortex picks the first
binary on PATH:
pyright→ falls back tojedi-language-serverorpylsp.ruby-lsp→ falls back tosolargraph stdio.phpactor→ falls back tointelephense --stdio.
Lower priority numbers win when more than one provider serves the same
language. gopls is 3 so it beats SCIP-based providers (5) for Go;
jdtls is 6 so it's lower-priority than the SCIP-java path that
ships separately.
Add it to .gortex.yaml:
semantic:
enabled: true
mode: typecheck # or "callgraph"
providers:
- name: gopls
enabled: true
- name: rust-analyzer
enabled: true
- name: pyright
enabled: trueNames match the Spec name column above. The router pre-registers every enabled spec at boot but does not spawn anything yet — subprocesses start the first time a tool calls into them.
Gortex does not ship the LSP binaries. Install the ones you want to
use; the router falls back gracefully when a binary is missing
(SpecAvailable(name) returns false → tool returns a structured
no_lsp_for error instead of hanging).
# Go
go install golang.org/x/tools/gopls@latest
# Rust
rustup component add rust-analyzer
# Python (pick one)
npm install -g pyright # recommended
pip install jedi-language-server # alt
pip install python-lsp-server # alt (pylsp)
# TypeScript / JavaScript
npm install -g typescript typescript-language-server
# C / C++ / Objective-C
brew install llvm # ships clangd
# or apt install clangd
# Java
brew install jdtls
# Kotlin
brew install kotlin-language-server
# C#
dotnet tool install --global Microsoft.OmniSharp
# Ruby (pick one)
gem install ruby-lsp # recommended
gem install solargraph # alt
# PHP (pick one)
composer global require phpactor/phpactor
npm install -g intelephense # alt
# Lua
brew install lua-language-server
# Swift
# Bundled with Xcode toolchain on macOS; no separate install.
# Haskell
ghcup install hls
# Elixir
brew install elixir-ls
# OCaml
opam install ocaml-lsp-server
# Zig
brew install zlsVerify with gortex daemon status — the LSP-router section lists
alive, last_used, and the resolved command for each running
server. Newly enabled specs show up only after the first request that
needs them.
The router applies these defaults in cmd/gortex/server.go and
cmd/gortex/mcp.go:
| Knob | Default | What it does |
|---|---|---|
WithIdleTimeout |
10 minutes | Subprocess closes if no For() / ForSpec() call lands in this window. |
WithReaperInterval |
1 minute | Background tick invokes Reap() to enforce the idle timeout. |
WithMaxAlive |
6 servers | LRU eviction kicks in when a seventh distinct server would spawn — the least-recently-used one closes. |
These defaults suit a polyglot workspace where most languages are
touched only intermittently. Override them by editing the
lsp.NewRouter(...).With... chain in your build if you need a longer
warm pool or a tighter memory bound.
Two surfaces:
get_diagnostics returns the most recent publishDiagnostics payload
the LSP server produced for a file. Use it for one-shot reads, batch
checks, or contexts where the agent doesn't maintain a long-lived
session.
subscribe_diagnostics opts the calling MCP session into
notifications/diagnostics push events. After subscribing, every LSP
publishDiagnostics for any router-managed server is forwarded to the
session as an MCP notification with this shape:
{
"method": "notifications/diagnostics",
"params": {
"uri": "file:///abs/path/to/main.go",
"path": "/abs/path/to/main.go",
"server": "gopls",
"diagnostics": [
{ "range": {"start": {"line": 41, "character": 4}, "end": {...}},
"severity": 1, "message": "missing return", "source": "gopls" }
]
}
}Push semantics:
- Opt-in per session. Sessions that never call
subscribe_diagnosticsreceive nothing — no broadcast spam. - Delta-only. Identical re-publishes (which some servers emit on every save even when nothing changed) are suppressed at the broadcaster — your subscribers only see real changes.
- All-router-managed servers. One subscription covers every spec
the user enabled in config. The
serverfield on each notification identifies which LSP produced the payload. - Non-blocking. Notifications use
SendNotificationToAllClientswhich drops to an error hook when a session's notification channel is full — slow consumers don't block the LSP message-pump.
Call unsubscribe_diagnostics to opt back out (idempotent).
Pair with get_code_actions + apply_code_action + fix_all_in_file
for the full edit-time diagnostic loop without polling.
no_lsp_forerror: the file extension didn't match any registered spec. Either the spec isn't enabled in.gortex.yaml, or the binary isn't onPATH. Run the spec's--versiondirectly to confirm install.router spawn <name>: ...error: the binary was onPATHat boot but the subprocess failed to initialise (commonly a missing dependency such asnodeforpyright, or a workspace-config mismatch). The error surfaces the LSP server's stderr.- Server keeps restarting: the idle reaper closed it, then the
next request re-spawned. Increase
WithIdleTimeoutif this hurts warm-cache benchmarks. - High memory under polyglot load: lower
WithMaxAlivefrom 6 to 3-4. The LRU evicts the least-recent server transparently.
- The router lives at
internal/semantic/lsp/router.go. It satisfies thesemantic.LSPRouterinterface sosemantic.Managercan drive batch enrichment without taking a hard import dependency on the lsp package (which would create a cycle — lsp already imports semantic for the Provider interface). tools_lsp.go::lspProviderForPathqueries the router first; if no router is wired (legacy boot paths, tests), it falls back to a scan throughManager.AllProviders()so user-defined daemons (specs not in the registry) still work.- One
*lsp.Providerper spec, regardless of how many MCP sessions hit it. Concurrency is bounded byServerSpec.MaxParallel(6-10 inflight requests per server depending on the spec).