Skip to main content

Stdio LSP proxy in front of csharp-ls that works around a Claude Code LSP client bug.

Project description

csharp-ls-proxy

A small stdio proxy that sits between Claude Code and csharp-ls to work around a Claude Code LSP client bug. Single Python script, zero runtime dependencies. Works identically on Linux (Debian-tested) and macOS.

The problem

Claude Code's LSP client does not register handlers for three server→client LSP requests that csharp-ls sends during initialization:

  • client/registerCapability
  • workspace/configuration
  • window/workDoneProgress/create

Claude Code answers all three with JSON-RPC -32601 "Unhandled method". The last one causes csharp-ls to abort solution loading entirely, so no C# code intelligence ever becomes available.

See csharp-lsp-bug-report.md for the full trace and root-cause analysis.

The fix

csharp_ls_proxy.py forwards every LSP message unchanged in both directions, except that it answers the known-problematic server requests locally with spec-valid default responses and never forwards them to Claude Code. csharp-ls then sees a compliant client and loads the solution normally.

The proxy also:

  • fails loudly (logged FrameError) on malformed LSP frames; caps header (64 KiB) and body (512 MiB) sizes so a peer can't force unbounded memory growth;
  • emits a stderr warning when it declines an action-bearing server request (workspace/applyEdit, window/showMessageRequest, window/showDocument), so a dropped edit/dialog is diagnosable instead of silent;
  • drains a final buffered server message on shutdown and tears down cleanly (no SIGPIPE death, no hung child; escalates terminate()kill() if the child ignores SIGTERM).

Requirements

Debian / Linux macOS
Python 3.11+ on PATH (the shebang resolves python3 via env) sudo apt install python3 brew install python (or use Apple's python3 from Xcode CLT)
csharp-ls installed as a .NET global tool dotnet tool install -g csharp-ls dotnet tool install -g csharp-ls
Shell for the optional rc snippet below zsh (~/.zshrc); bash works too (substitute ~/.bashrc) zsh (~/.zshrc, default since Catalina)

Both platforms install csharp-ls to ~/.dotnet/tools/csharp-ls, so the install commands below are identical.

Install

Quickest — pip install (Debian and macOS)

⚠️ PEP 668 heads-up: if your python3 is system-managed (apt-installed on modern Debian/Ubuntu, or brew install python3 on macOS), pip install --user will refuse with externally-managed-environment. Skip directly to the pipx fallback below — it's the same flow with pipx instead of pip. The snippet here works on dev boxes with a user-managed Python (pyenv, asdf, conda, or a manual python3 -m venv activated globally).

set -eu  # fail-fast: pass-26 review correctness — never proceed past a
         # failed pip install into the symlink-swap (or we'd relink to
         # whatever stale csharp-ls-proxy was already on PATH).

# 1) Install and RESOLVE the entry point first. Do NOT touch ~/.dotnet/tools/
#    until we know we have a working proxy to point at — otherwise a failed
#    pip install can leave the box with no csharp-ls at all.
python3 -m pip install --user csharp-ls-proxy
# Pass-27 review correctness #3: probe the sysconfig posix_user scripts dir
# FIRST (where `pip install --user` just wrote the entry point), THEN fall
# back to `command -v`. The opposite order would prefer a stale pipx /
# system-pip install that's earlier on PATH over the fresh user install
# we just performed.
entry="$(python3 -c 'import sysconfig,os; print(os.path.join(sysconfig.get_path("scripts", scheme="posix_user"), "csharp-ls-proxy"))')"
[ -x "$entry" ] || entry="$(command -v csharp-ls-proxy 2>/dev/null || true)"
[ -n "$entry" ] && [ -x "$entry" ] || { echo "csharp-ls-proxy entry point not found or not executable: $entry" >&2; exit 1; }

# 2) Move the real csharp-ls aside ONLY IF .real doesn't already hold a
#    backup. Re-running this block must not overwrite that backup with
#    what's now a symlink to the proxy.
if [ ! -e ~/.dotnet/tools/csharp-ls.real ]; then
    mv  ~/.dotnet/tools/csharp-ls  ~/.dotnet/tools/csharp-ls.real
else
    # Backup already exists -> we're re-running. Verify the CURRENT
    # ~/.dotnet/tools/csharp-ls is the proxy we (or a previous run of
    # this snippet) installed before removing it -- never blow away a
    # binary or symlink the user may have intentionally swapped in.
    # Safe to drop iff it's either (a) absent, (b) a broken symlink,
    # or (c) a symlink whose final target resolves to a csharp-ls-proxy
    # entry point (a pip/pipx-installed `csharp-ls-proxy` console script).
    current="$HOME/.dotnet/tools/csharp-ls"
    if [ ! -e "$current" ] && [ ! -L "$current" ]; then
        : # nothing there, nothing to verify
    else
        # Use python3 realpath — BSD readlink on macOS does not support -f
        # (mirrors install.sh's pattern at install.sh:35-38).
        resolved="$(python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$current" 2>/dev/null || true)"
        case "$(basename "${resolved:-$current}")" in
            # Accept any of our proxy basenames so users migrating
            # from a previous clone install (post- or pre-rename
            # variants of the underscored / hyphenated module file)
            # can swap cleanly to the pip console-script entry without
            # manual intervention. Pass-18 review correctness #2 wired
            # this; pass-27 review correctness #1 reworded so Step 2's
            # grep doesn't spuriously flag this comment.
            csharp-ls-proxy|csharp_ls_proxy.py|csharp-ls-proxy.py) : ;;  # the proxy we expect — safe to replace
            *)
                echo "csharp-ls-proxy install: ~/.dotnet/tools/csharp-ls" >&2
                echo "  resolves to '$resolved' which is NOT our proxy entry point." >&2
                echo "  Refusing to overwrite. Move/delete it manually if you're" >&2
                echo "  sure, then re-run." >&2
                exit 1
                ;;
        esac
    fi
    rm -f "$current"
fi
ln -s "$entry"  ~/.dotnet/tools/csharp-ls

Then add the trace-log block to ~/.zshrc from the Tracing & log rotation section. Verify the deploy with csharp-ls-proxy --version (works as long as ~/.local/bin is on PATH; otherwise call it via ~/.dotnet/tools/csharp-ls --version).

pipx fallback (required for system-managed Python: macOS Homebrew + modern Debian/Ubuntu): Both brew install python3 on macOS AND apt-managed Python on recent Debian/Ubuntu mark the system Python as "externally managed" (PEP 668), so python3 -m pip install --user will refuse with an externally-managed-environment error. Use pipx instead (pass-16 review correctness #2: same issue on both platforms, not just macOS):

set -eu  # fail-fast: same rationale as the pip snippet above (pass-26 review).

# Install pipx — runs the right command for your OS automatically (pass-20
# review correctness: a copy-paste-friendly snippet, not a "pick a line"
# instruction that left brew install pipx as the executable line for Debian).
if   command -v brew    >/dev/null 2>&1; then brew install pipx
elif command -v apt-get >/dev/null 2>&1; then sudo apt-get install -y pipx
else
    echo "Install pipx for your OS, then re-run this snippet." >&2
    echo "  https://pipx.pypa.io/stable/installation/" >&2
    exit 1
fi
pipx install csharp-ls-proxy
entry="$(pipx environment --value PIPX_BIN_DIR)/csharp-ls-proxy"
[ -x "$entry" ] || { echo "pipx install did not produce: $entry" >&2; exit 1; }
if [ ! -e ~/.dotnet/tools/csharp-ls.real ]; then
    mv  ~/.dotnet/tools/csharp-ls  ~/.dotnet/tools/csharp-ls.real
else
    # Same safety check as the pip-install snippet above: only drop the
    # current entry if it's our proxy (or absent / broken symlink).
    current="$HOME/.dotnet/tools/csharp-ls"
    if [ ! -e "$current" ] && [ ! -L "$current" ]; then
        :
    else
        # python3 realpath — BSD readlink on macOS lacks -f (install.sh:35-38).
        resolved="$(python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$current" 2>/dev/null || true)"
        case "$(basename "${resolved:-$current}")" in
            # Pass-18 review correctness #2: accept clone-install basenames too.
            csharp-ls-proxy|csharp_ls_proxy.py|csharp-ls-proxy.py) : ;;
            *)
                echo "csharp-ls-proxy install: refusing to overwrite $current (resolves to '$resolved')" >&2
                exit 1
                ;;
        esac
    fi
    rm -f "$current"
fi
ln -s "$entry"  ~/.dotnet/tools/csharp-ls

Or, if you'd rather skip the Python packaging dance entirely, use the clone-and-run path below.

Quick — clone-and-run, Debian and macOS

This repo is private, so the install authenticates via the GitHub CLI (gh). One-time, on a fresh machine:

# Debian
sudo apt install gh && gh auth login

# macOS
brew install gh && gh auth login

Then clone + run the installer (one command, idempotent — safe to re-run):

gh repo clone GabrielFarfan/csharp-ls-proxy ~/csharp-ls-proxy && ~/csharp-ls-proxy/install.sh

The installer swaps the proxy in front of csharp-ls, appends the per-shell trace-log + rotation block to ~/.zshrc, and follows the full symlink chain — a pre-existing multi-hop install is recognized and left alone. Override the clone destination with CSHARP_LS_PROXY_REPO=/path before running it.

Manual

git clone https://github.com/GabrielFarfan/csharp-ls-proxy ~/csharp-ls-proxy
chmod +x ~/csharp-ls-proxy/csharp_ls_proxy.py    # belt-and-suspenders; git tracks +x

mv  ~/.dotnet/tools/csharp-ls  ~/.dotnet/tools/csharp-ls.real
ln -s ~/csharp-ls-proxy/csharp_ls_proxy.py  ~/.dotnet/tools/csharp-ls

The proxy looks up the real binary via CSHARP_LS_REAL_BIN, defaulting to ~/.dotnet/tools/csharp-ls.real; set the env var if your csharp-ls lives elsewhere.

If you'd rather expose the proxy under ~/.local/bin/ too (e.g. to share it with other tooling), use an extra hop:

ln -s ~/csharp-ls-proxy/csharp_ls_proxy.py  ~/.local/bin/csharp_ls_proxy.py
ln -s ~/.local/bin/csharp_ls_proxy.py       ~/.dotnet/tools/csharp-ls

Tracing & log rotation

Set CSHARP_LS_PROXY_LOG=/path/to/trace.log to record a compact trace of every LSP message that crosses the proxy. The proxy auto-suffixes its own PID before the file extension, so concurrent csharp-ls instances under one shell land in distinct files ($$.<proxy-pid>.log rather than collapsing to $$.log). For per-LSP-instance logs with automatic age-based rotation, append this block to ~/.zshrc (or ~/.bashrc on bash). It works identically on Debian and macOS — find -maxdepth, -mtime, and -delete are supported by both GNU find (Debian) and BSD find (macOS):

# csharp-ls proxy: $$ is the shell PID; the proxy auto-suffixes its own
# PID before .log, so multi-workspace shells get distinct per-LSP files
# (e.g. /home/u/.cache/csharp-ls-proxy/12345.67890.log).
# Rotation: prune *.log files untouched for 14+ days at shell start. The active
# log's mtime updates with each write, so age-based pruning never deletes a
# live trace -- only stale logs from already-closed shells age out.
mkdir -p "$HOME/.cache/csharp-ls-proxy" 2>/dev/null
find "$HOME/.cache/csharp-ls-proxy" -maxdepth 1 -name '*.log' -mtime +14 -delete 2>/dev/null
export CSHARP_LS_PROXY_LOG="$HOME/.cache/csharp-ls-proxy/$$.log"

Takes effect on new shells only — restart claude from a fresh terminal so its csharp-ls child inherits the env var. Tune the retention window by changing the +14 in the find line.

Concurrent use (multiple sessions / projects)

Each invocation of the proxy is fully independent: a separate Python process with its own csharp-ls.real child and its own stdio pipes. Multiple Claude Code sessions in different directories, against different C# projects, all coexist without sharing anything beyond the binary on disk. The cost is one Roslyn solution load per session (a csharp-ls cost, not a proxy cost).

Subagents (Claude Code caveat, not a proxy limitation)

The proxy itself is consumer-agnostic — any process that runs csharp-ls goes through it. However, as of 2026-05-28 Claude Code's built-in LSP tool is gated on enabledPlugins in user settings and is not propagated to spawned subagents (in-process Agent calls, TeamCreate members, etc.). The parent session sees the tool; subagents get No matching deferred tools found when they try to look it up. This is upstream issue anthropics/claude-code#61210 and the architecture is the same for every *-lsp@claude-plugins-official plugin (csharp-lsp, jdtls-lsp, rust-analyzer-lsp, etc.) — they're bare markers with no .mcp.json/plugin.json, so the harness never includes them in subagent tool environments.

Practical implication: today a subagent can't reach the proxy via the LSP tool — not because the proxy is wrong, but because the tool itself isn't available there. The validated workaround (per the issue's comments) is to wrap the LSP in an MCP server (e.g. lsp-mcp-server on npm) and register it as a normal MCP server; MCP-tool propagation does work across subagents. This proxy keeps working unchanged in front of that wrapped LSP.

When (if) the upstream fix lands — re-reading enabledPlugins when constructing subagent tool environments — subagents will get the LSP tool and route through this proxy automatically. Nothing in this repo needs to change.

Tests

python3 -m unittest discover -s tests -v

Stdlib unittest only, no third-party deps. Covers read_frame malformed- input handling, the dispatch guard and local-response shapes, the decline warnings, and integration tests against fake servers (broken-pipe, final- message drain, SIGTERM-ignoring child, malformed frame).

Known limitations

Cold-start: first C# LSP call in a session may return empty

When Claude Code opens its first .cs file in a session, it fires textDocument/documentSymbol to csharp-ls immediately — before Roslyn has loaded the solution (~10s on a fresh project). The proxy correctly forwards the call; csharp-ls correctly answers null for the not-yet-loaded request; Claude Code's LSP client does not retry after indexing finishes. The user sees:

No symbols found in document. This may occur if the file is empty, not supported by the LSP server, or if the server has not fully indexed the file.

The same call a few seconds later returns full results. Workaround today: wait a few seconds and call again. This is not a proxy bug — the solution-load abort (the original Phase 0 fix) IS gone, the proxy behaves correctly, the Roslyn load completes normally. The remaining gap is in Claude Code's client retry behavior.

Planned fix: issue #2 tracks adding a proxy-side warmup buffer that holds the first pre-load textDocument/* request and replays it once csharp-ls emits the solution-loaded window/showMessage. That's Phase 2 of this project, deliberately deferred from Phase 1.

csharp-ls --help through the proxy exits silently

If you invoke csharp-ls --help through the proxy, the underlying csharp-ls.real prints its help text to stdout and exits. The proxy's server-to-client pump only forwards successfully-framed LSP messages, so the plaintext help output is not forwarded — you'll see an empty stdout and a clean rc=0. For interactive help, run csharp-ls.real --help directly (the proxy is only meant to sit between an LSP client and csharp-ls).

Note that csharp-ls-proxy --version is not affected — the proxy owns and serves --version itself (printing its own version + diagnostic config block, see csharp-ls-proxy --version), so it never reaches the backend at all.

Prior art

Other solutions that fix or work around the same Claude Code ↔ csharp-ls bug — pick what fits your stack:

Project Lang Architecture Notes
Agasper/CSharpLspAdapter C# (.NET tool, NuGet) stdio proxy Closest twin. Intercepts the same three core requests. Installs via dotnet tool install -g CSharpLspAdapter.
fawques/csharp-lsp-claude C# (.NET 10) stdio proxy + warmup race Races first real request against a 3-second deadline to avoid cold-start timeouts. The angle this repo's Phase 2 plans to tackle.
Tritlo/lsp-mcp TypeScript LSP-as-MCP server Different architecture: wraps any LSP server as an MCP server. The upstream-blessed workaround for anthropics/claude-code#61210 (LSP-tool propagation to subagents).

Compared to those, this proxy is stdlib-only Python with zero runtime dependencies, intercepts seven server-initiated requests rather than three (adds workspace/applyEdit, window/showDocument, window/showMessageRequest, workspace/workspaceFolders), and adds robustness hardening (FrameError, header + body caps, daemon pump + bounded-join drain, terminate()kill() teardown escalation).

Uninstall

rm  ~/.dotnet/tools/csharp-ls
mv  ~/.dotnet/tools/csharp-ls.real  ~/.dotnet/tools/csharp-ls
# also remove ~/.local/bin/csharp_ls_proxy.py if you used the two-hop install
# and drop the trace-log block from ~/.zshrc / ~/.bashrc if you added it

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

csharp_ls_proxy-0.2.0.tar.gz (29.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

csharp_ls_proxy-0.2.0-py3-none-any.whl (19.5 kB view details)

Uploaded Python 3

File details

Details for the file csharp_ls_proxy-0.2.0.tar.gz.

File metadata

  • Download URL: csharp_ls_proxy-0.2.0.tar.gz
  • Upload date:
  • Size: 29.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.11.2

File hashes

Hashes for csharp_ls_proxy-0.2.0.tar.gz
Algorithm Hash digest
SHA256 148235de6abaab2da263e3a762b4eb02dcd31a0f28a8e934db742c7c2923db44
MD5 ef487bc3f2acc8bbb660fc58d02754f9
BLAKE2b-256 00cbdf71006fb4e419dc910a3e829cab72a7781556ef683ab43dbf4a941341cd

See more details on using hashes here.

File details

Details for the file csharp_ls_proxy-0.2.0-py3-none-any.whl.

File metadata

File hashes

Hashes for csharp_ls_proxy-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 160c980eeaff1947780538974b7ebf724ae38da079c31d148a73363a7f6a97b1
MD5 fa3b78084748badd1ef64199eeba8749
BLAKE2b-256 519fc5cd0d3fc2dad076fe4c374765eb9a5241a470825b748c2d21474e9c18e1

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page