LSP multiplexer letting several language servers share one file extension
Project description
pylspmux
An LSP multiplexer: presents several language servers to one client as if they were a single server.
It exists because Claude Code maps a file extension to exactly one language server. Registration keeps every matching server in an array, but routing always takes index 0 and warns about the rest:
LSP: extension .py already handled by "pyright"; "ruff" will not be used for .py files
Registering pylspmux for .py instead lets pyright, ruff and pyrefly all
see the same files, with their diagnostics merged into one stream.
Nothing about the design is Python-specific — the same config runs
typescript-language-server alongside eslint for .ts.
Install
pip install pylspmux
No runtime dependencies. The client spawns this process at every session
start, and each dependency is another way for it to fail to start; the whole
thing runs on a bare /usr/bin/python3.
Use it
Once installed, pylspmux is just a command on PATH. Point your client's
LSP config at it instead of at the real server, and give it a servers.json
(see Configuration) telling it which real servers to run.
For Claude Code, drop both in the project root — it reads a .lsp.json there
directly, no plugin needed:
.lsp.json:
{
"pylspmux": {
"command": "pylspmux",
"args": ["servers.json"],
"extensionToLanguage": {
".py": "python",
".pyi": "python"
}
}
}
servers.json:
{
"servers": [
{
"name": "pyright",
"command": "pyright-langserver",
"args": [
"--stdio"
]
},
{
"name": "ruff",
"command": "ruff",
"args": [
"server"
]
},
{
"name": "pyrefly",
"command": "pyrefly",
"args": [
"lsp"
],
"suppressCodes": [
"unused-import",
"unused-variable"
]
}
]
}
Restart the client, then check that all three servers came up:
PYLSPMUX_LOG=/tmp/mux.log claude
grep 'live servers' /tmp/mux.log
For any other LSP client the mechanism is the same: pylspmux replaces the
single server as the spawned command, with the path to servers.json as its
first argument. The key names in the client's own config format will differ.
Configuration
{
"servers": [
{
"name": "pyright",
"command": "pyright-langserver",
"args": ["--stdio"],
"enabled": true,
"settings": {
"python": { "analysis": { "typeCheckingMode": "standard" } }
},
"suppressCodes": []
}
]
}
| Field | Meaning |
|---|---|
name |
identifies the server, and becomes the diagnostic source if it sets none |
command, args, env |
how to spawn it |
settings |
answers the server's workspace/configuration requests, and is pushed via workspace/didChangeConfiguration after initialized |
initializationOptions |
passed through in initialize |
suppressCodes |
diagnostic codes to drop from this server |
enabled |
keep an entry without running it |
An invalid entry fails the whole load rather than being skipped: a silently dropped server looks exactly like a server that found no problems, which is the worst possible failure mode for a diagnostics tool. A server that fails to spawn is different — that degrades gracefully, and the rest keep working.
pylspmux takes the path to this file as its one required argument.
How it works
Notifications (didOpen, didChange, didSave, didClose) broadcast to
every child. Requests go only to children advertising the matching capability,
and the answers are merged:
| Request | Merge |
|---|---|
hover |
every server's answer, each labelled with its name |
definition, typeDefinition, implementation, references |
concatenated, de-duplicated by target |
documentSymbol, workspace/symbol |
concatenated, de-duplicated by name/kind/range |
prepareCallHierarchy |
concatenated; each item remembers its owner so incomingCalls/outgoingCalls route back to the right server |
Requests coming from a child (workspace/configuration,
client/registerCapability, window/workDoneProgress/create) are answered
here rather than forwarded, so the client's connection looks like an ordinary
single language server. workspace/applyEdit is refused — the client edits
files itself.
A child that fails to spawn, or dies later, is dropped: its diagnostics are cleared and the remaining servers carry on. A child that stops answering hits a 20-second timeout and is left out of that one merge.
Two client quirks worth knowing
Both were read out of the Claude Code 2.1.215 bundle, and both shape the design:
- A
publishDiagnosticspayload whoseversiontrails the document the client holds is dropped. A merged payload is only as fresh as its slowest contributor, so republished diagnostics carry noversionfield at all — that skips the staleness check entirely. - A payload with an empty
diagnosticsarray is ignored, so "all problems cleared" never propagates. That is the client's own behaviour and applies equally without the multiplexer.
pyrefly needs per-project config
At its default basic preset pyrefly reports almost nothing — it missed a
plain result: str = add(1, 2). Add a pyrefly.toml to each project:
project-includes = ["**/*.py"]
preset = "strict"
Valid presets: off, basic, legacy, default, strict, all. Without
one, pyrefly is dead weight in the union.
Because pyrefly and ruff both flag unused imports, the shipped config
suppresses pyrefly's unused-import and unused-variable.
Tests
.venv/bin/pytest # unit + fake-server end-to-end
.venv/bin/pytest -m integration # spawns the real pyright/ruff/pyrefly
tests/fake_server.py is a scriptable language server driven by environment
variables, so the full pipeline can be tested deterministically without
waiting on a real type checker.
Debugging
PYLSPMUX_LOG=/tmp/mux.log claude
Logs each child's stderr, its advertised capabilities, timeouts, and any unhandled message. Nothing may go to stdout — that is the LSP stream — and stderr is swallowed by the client, hence the file.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pylspmux-0.0.1.tar.gz.
File metadata
- Download URL: pylspmux-0.0.1.tar.gz
- Upload date:
- Size: 25.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
07141005d064df953d78e51d801993e9532a0e0b7b3c8b633eb23b7928b627e4
|
|
| MD5 |
d8e3a580adc1bc791d68607b550c8763
|
|
| BLAKE2b-256 |
fbb695b17d0cdb0b6d87a92405a68e9af874129359a6e713325dad7638715144
|
Provenance
The following attestation bundles were made for pylspmux-0.0.1.tar.gz:
Publisher:
publish.yml on MyrikLD/pylspmux
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pylspmux-0.0.1.tar.gz -
Subject digest:
07141005d064df953d78e51d801993e9532a0e0b7b3c8b633eb23b7928b627e4 - Sigstore transparency entry: 2257330983
- Sigstore integration time:
-
Permalink:
MyrikLD/pylspmux@0d8604d7d1517b491a79255a1f2fdea887250c03 -
Branch / Tag:
refs/tags/v0.0.1 - Owner: https://github.com/MyrikLD
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@0d8604d7d1517b491a79255a1f2fdea887250c03 -
Trigger Event:
release
-
Statement type:
File details
Details for the file pylspmux-0.0.1-py3-none-any.whl.
File metadata
- Download URL: pylspmux-0.0.1-py3-none-any.whl
- Upload date:
- Size: 16.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
37868bec5ef9ff394657305e1c09d93c184515ea7903ec30fed4f6eb628f41ee
|
|
| MD5 |
1ac27280b16a7381ca837cbdd0d70806
|
|
| BLAKE2b-256 |
fe6db333751f6e9b7b2be1095f2c5c9828c5d39eba1bee83665c1a804f695171
|
Provenance
The following attestation bundles were made for pylspmux-0.0.1-py3-none-any.whl:
Publisher:
publish.yml on MyrikLD/pylspmux
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pylspmux-0.0.1-py3-none-any.whl -
Subject digest:
37868bec5ef9ff394657305e1c09d93c184515ea7903ec30fed4f6eb628f41ee - Sigstore transparency entry: 2257330987
- Sigstore integration time:
-
Permalink:
MyrikLD/pylspmux@0d8604d7d1517b491a79255a1f2fdea887250c03 -
Branch / Tag:
refs/tags/v0.0.1 - Owner: https://github.com/MyrikLD
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@0d8604d7d1517b491a79255a1f2fdea887250c03 -
Trigger Event:
release
-
Statement type: