One LSP client. Any number of language servers.
lspx is a Go library and CLI tool that acts as an LSP client multiplexer. Instead of writing one client per language server, you connect a single lspx client to as many language servers as you need. lspx transparently fans out requests, aggregates responses, and routes notifications — your code (or your editor) sees exactly one unified LSP interface.
┌─────────────────────────────────────────────┐
│ lspx │
┌──────────────┐ │ ┌─────────┐ ┌──────────────────────┐ │
│ Your code │ │ │ │───►│ gopls │ │
│ (or editor) │◄─────►│ │ Mux │ └──────────────────────┘ │
└──────────────┘ │ │ │───►│ rust-analyzer │ │
one client │ │ │ └──────────────────────┘ │
│ │ │───►│ typescript-language- │ │
│ └─────────┘ │ server │ │
│ └──────────────────────┘ │
└─────────────────────────────────────────────┘
many servers
The LSP ecosystem is rich but fragmented. In a real project you might need:
goplsfor Go filesrust-analyzerfor Rust filestypescript-language-serverfor TypeScript/JavaScripteslint-lspfor linting on top of TypeScript- A custom internal server for your proprietary language
Without a multiplexer, every client (editor plugin, build tool, CI script) must manage connections to each of those servers independently. That means duplicated lifecycle code, no result merging, and no shared sessions.
lspx moves that complexity into a single, reusable layer:
| Without lspx | With lspx |
|---|---|
| Client manages N server connections | Client manages 1 lspx connection |
| Results per-server, no aggregation | Unified results merged across servers |
| Each editor session starts fresh servers | Servers are long-lived and shared |
| Raw JSON-RPC is opaque | Full traffic logging built-in |
| Feature | Description |
|---|---|
| 🔀 Client multiplexing | One client interface, any number of language servers |
| 🗂️ Smart routing | Requests routed to the right server(s) based on file type or language ID |
| 🔗 Result aggregation | Diagnostics, completions, and more merged transparently across servers |
| 🤝 Session sharing | Multiple clients share a single long-lived server instance |
| 🪵 Traffic logging | Full JSON-RPC stream logging (NDJSON) for debugging and testing |
| ⚙️ Flexible config | Declarative TOML config file with optional --auto detection mode |
| 🚀 stdio transport | Standard LSP stdio transport — works as a drop-in with any LSP-aware editor |
| 📦 Go library | Embed the multiplexer directly in your Go programs via a clean, idiomatic API |
The lspx package is the primary interface. The CLI is just a thin wrapper around it.
go get github.com/masterkeysrd/lspxConfigure a single client backed by multiple language servers. lspx handles all routing and aggregation transparently.
package main
import (
"context"
"fmt"
"log"
"github.com/masterkeysrd/lspx"
)
func main() {
ctx := context.Background()
// One client — three servers underneath
client, err := lspx.NewClient(ctx, lspx.ClientOptions{
Servers: []lspx.ServerConfig{
{
Name: "gopls",
Command: []string{"gopls", "serve"},
FileTypes: []string{"go"},
},
{
Name: "rust-analyzer",
Command: []string{"rust-analyzer"},
FileTypes: []string{"rust"},
},
{
Name: "tsserver",
Command: []string{"typescript-language-server", "--stdio"},
FileTypes: []string{"typescript", "javascript"},
},
},
RootURI: "file:///path/to/your/project",
})
if err != nil {
log.Fatal(err)
}
defer client.Close()
// Open a Go file — lspx routes to gopls automatically
client.DidOpen(ctx, &lspx.DidOpenTextDocumentParams{
TextDocument: lspx.TextDocumentItem{
URI: "file:///path/to/your/project/main.go",
LanguageID: "go",
Version: 1,
},
})
// Hover — lspx sends to gopls and returns its response
hover, err := client.Hover(ctx, &lspx.HoverParams{
TextDocumentPositionParams: lspx.TextDocumentPositionParams{
TextDocument: lspx.TextDocumentIdentifier{
URI: "file:///path/to/your/project/main.go",
},
Position: lspx.Position{Line: 10, Character: 5},
},
})
if err != nil {
log.Fatal(err)
}
fmt.Println(hover.Contents.Value)
}When more than one server handles a file type, lspx merges their responses.
// Two servers handle TypeScript: tsserver for completions, eslint-lsp for linting
client, _ := lspx.NewClient(ctx, lspx.ClientOptions{
Servers: []lspx.ServerConfig{
{
Name: "tsserver",
Command: []string{"typescript-language-server", "--stdio"},
FileTypes: []string{"typescript"},
},
{
Name: "eslint",
Command: []string{"vscode-eslint-language-server", "--stdio"},
FileTypes: []string{"typescript"},
// Only provide diagnostics; skip completions
Capabilities: lspx.CapabilityDiagnosticProvider,
},
},
Aggregate: lspx.AggregateOptions{
Diagnostics: true, // merge from both servers
Completions: false, // only from first matching server
},
RootURI: "file:///path/to/your/project",
})All servers' notifications are funneled through a single handler:
// Diagnostics from ALL servers arrive here, already merged
client.OnNotification(lspx.MethodTextDocumentPublishDiagnostics, func(params *lspx.PublishDiagnosticsParams) {
for _, diag := range params.Diagnostics {
fmt.Printf("[%s] %s line %d: %s\n",
diag.Source, diag.Severity, diag.Range.Start.Line, diag.Message)
}
})| Method | LSP Method | Routing |
|---|---|---|
client.Initialize |
initialize |
Sent to all servers |
client.DidOpen |
textDocument/didOpen |
Sent to servers matching the file's language ID |
client.DidChange |
textDocument/didChange |
Sent to servers matching the file's language ID |
client.DidClose |
textDocument/didClose |
Sent to servers matching the file's language ID |
client.Hover |
textDocument/hover |
First matching server |
client.Completion |
textDocument/completion |
Aggregated or first, configurable |
client.Definition |
textDocument/definition |
First matching server |
client.Declaration |
textDocument/declaration |
First matching server |
client.TypeDefinition |
textDocument/typeDefinition |
First matching server |
client.Implementation |
textDocument/implementation |
First matching server |
client.PrepareCallHierarchy |
textDocument/prepareCallHierarchy |
First matching server |
client.IncomingCalls |
callHierarchy/incomingCalls |
First matching server (routes using item URI) |
client.OutgoingCalls |
callHierarchy/outgoingCalls |
First matching server (routes using item URI) |
client.PrepareTypeHierarchy |
textDocument/prepareTypeHierarchy |
First matching server |
client.Supertypes |
typeHierarchy/supertypes |
First matching server (routes using item URI) |
client.Subtypes |
typeHierarchy/subtypes |
First matching server (routes using item URI) |
client.DocumentHighlight |
textDocument/documentHighlight |
First matching server |
client.DocumentLink |
textDocument/documentLink |
First matching server |
client.ResolveDocumentLink |
documentLink/resolve |
First matching server (routes using wrapped server metadata) |
client.CodeLens |
textDocument/codeLens |
First matching server |
client.ResolveCodeLens |
codeLens/resolve |
First matching server (routes using wrapped server metadata) |
client.FoldingRange |
textDocument/foldingRange |
First matching server |
client.References |
textDocument/references |
Aggregated across matching servers |
client.Diagnostics |
textDocument/diagnostic |
Aggregated across matching servers |
client.Formatting |
textDocument/formatting |
First matching server |
client.OnNotification |
— | Receives notifications from all servers |
Full API reference:
go doc github.com/masterkeysrd/lspxor pkg.go.dev
The lspx CLI exposes the same multiplexer over stdio, making it a drop-in LSP server for any editor. The editor connects to lspx and gets unified results from all configured servers.
From source (requires Go 1.21+):
git clone https://github.com/masterkeysrd/lspx.git
cd lspx
go build -o lspx ./cmd/lspxVia go install:
go install github.com/masterkeysrd/lspx/cmd/lspx@latestlspx looks for .lspx.toml in the project root, then ~/.config/lspx/config.toml.
# .lspx.toml
[lspx]
log_file = "~/.local/share/lspx/lspx.log"
log_level = "info"
[aggregate]
diagnostics = true
completions = false
[[server]]
name = "gopls"
command = ["gopls", "serve"]
filetypes = ["go"]
[[server]]
name = "rust-analyzer"
command = ["rust-analyzer"]
filetypes = ["rust"]
[[server]]
name = "tsserver"
command = ["typescript-language-server", "--stdio"]
filetypes = ["typescript", "javascript", "typescriptreact", "javascriptreact"]
[[server]]
name = "eslint"
command = ["vscode-eslint-language-server", "--stdio"]
filetypes = ["typescript", "javascript"]
capabilities = ["diagnostics"]Neovim (via nvim-lspconfig):
require('lspconfig').lspx.setup({
cmd = { 'lspx' },
-- lspx handles all languages; list every filetype you need here
filetypes = { 'go', 'rust', 'typescript', 'javascript' },
root_dir = require('lspconfig.util').root_pattern('.lspx.toml', '.git'),
})VS Code (settings.json):
{
"lspx.server.command": ["lspx"]
}Skip the config file entirely — lspx detects installed language servers automatically:
lspx --autoExplicit config always takes precedence.
--autois a fast way to get started.
Usage:
lspx [flags]
Flags:
--auto Auto-detect installed language servers
--config PATH Config file path (default: .lspx.toml, ~/.config/lspx/config.toml)
--log PATH Override log file path
--log-level Log verbosity: debug, info, warn, error (default: info)
--version Print version and exit
-h, --help Show this help
| Field | Type | Description |
|---|---|---|
name |
string |
Friendly name used in logs |
command |
[]string |
Command to launch the server |
filetypes |
[]string |
Language IDs this server handles |
env |
{string: string} |
Extra environment variables |
capabilities |
[]string |
Limit which capabilities are used from this server (diagnostics, completions, hover, declaration, definition, typeDefinition, implementation, references, callHierarchy, typeHierarchy, documentHighlight, documentLink, codeLens, foldingRange, …). Omit to use all. |
share_sessions |
bool |
Share this server across multiple editor clients (default: true) |
| Field | Type | Default | Description |
|---|---|---|---|
diagnostics |
bool |
true |
Merge diagnostics from all matching servers |
completions |
bool |
false |
Merge completions (disable to avoid duplicates) |
references |
bool |
true |
Merge textDocument/references results |
When log_file is set, lspx records the full JSON-RPC stream in both directions for every client↔server pair. Log entries are NDJSON with:
| Field | Description |
|---|---|
ts |
ISO-8601 timestamp |
server |
Server name |
direction |
client→server or server→client |
message |
Raw LSP JSON-RPC message |
Useful for debugging misbehaving servers, understanding editor traffic, and building test fixtures from real sessions.
Go Library
- Project scaffold
- JSON-RPC client over stdio
-
initialize/shutdownlifecycle -
textDocument/*request methods - Server-push notification handlers
- Multi-server multiplexer core
- File-type based routing
- Result aggregation engine
- Per-server capability filtering
- Workspace requests (
workspace/symbol, etc.) - Type-safe LSP types (generated from the LSP spec)
-
pkg.go.devdocumentation
CLI
- Config file parsing (TOML)
- stdio server mode (editor integration)
- Session sharing across clients
- Traffic logging
-
--autodetection mode - VS Code extension
- Homebrew formula
Contributions are welcome! Please open an issue first to discuss significant changes.
- Fork the repository
- Create your feature branch:
git checkout -b feat/my-feature - Commit your changes:
git commit -m 'feat: add my feature' - Push and open a Pull Request
Please follow the Conventional Commits specification.
This project is licensed under the MIT License.
Made with ☕ and Go