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.
Release files for pylspmux 0.0.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| pylspmux-0.0.1.tar.gz | 25.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pylspmux-0.0.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 42.3 kB
Release files / pylspmux-0.0.1.tar.gz
| Download URL | pylspmux-0.0.1.tar.gz |
|---|---|
| Size | 25.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
07141005d064df953d78e51d801993e9532a0e0b7b3c8b633eb23b7928b627e4
|
|
BLAKE2b-256 checksum How to use checksums |
fbb695b17d0cdb0b6d87a92405a68e9af874129359a6e713325dad7638715144
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 27, 2026.
Transparency logRelease files / pylspmux-0.0.1-py3-none-any.whl
| Download URL | pylspmux-0.0.1-py3-none-any.whl |
|---|---|
| Size | 16.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
37868bec5ef9ff394657305e1c09d93c184515ea7903ec30fed4f6eb628f41ee
|
|
BLAKE2b-256 checksum How to use checksums |
fe6db333751f6e9b7b2be1095f2c5c9828c5d39eba1bee83665c1a804f695171
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 27, 2026.
Transparency log