Files
ersatztv/docs/local-lsp-tooling.md
T
4bead26326
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 26s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m34s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m35s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m22s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m12s
fix(777): repair both broken LSPs, and name the surface a subagent can actually reach (#793)
Both C#/TS language servers and the csharp-lsp MCP server were dead; all three are
fixed and each demonstrated with a real find-all-references call in this repo.

Root causes were one shape — a config naming a path this machine does not have,
with nothing checking. None returned a wrong answer; each refused to start:
- csharp-ls: MSBuildLocator needs a dotnet root owning host/fxr; Homebrew's bin
  has none, libexec does.
- typescript-language-server: the LSP workspace root is the repo root but
  `typescript` lives in web/node_modules, and the plugin cannot pass a tsserver
  path (v5 dropped --tsserver-path; lspServers cannot set initializationOptions).
- the csharp-lsp MCP server: .mcp.json named a dotnet install that no longer
  existed, while ~/.codex/config.toml's copy of the same server had been migrated.
  Both files are gitignored, so nothing could compare them.

Corrects defect-shapes-773.md §5.1: the "workflow agents must use csharp-lsp" note
names the MCP server's tools, which subagents DO reach — it was dead because the
server could not start, not because agents cannot call it. The LSP tool is the one
no subagent has been observed to resolve.

Six cold review rounds. Five false greens were found in this PR's own verification
code, each introduced by the fix for the previous one — extracted as #796.

fixes #777

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-14 15:55:20 +00:00

12 KiB

Local code-intelligence tooling (LSP + the csharp-lsp MCP server)

What is available for "find every site that references this symbol", how each surface is configured, and how to verify it rather than assume it. Run scripts/check-local-lsp.sh to check all of it at once.

This matters beyond convenience. docs/defect-shapes-773.md measures that 39% of recorded process failures are a fix or a guard applied to a sample instead of the population. The set-equality rule (testing.guard-derives-population-from-source) covers populations of values; it deliberately does not cover the residue where the population is sites in code — #403 (5 of 6 dispatch sites) and #671 (a by-id handler covering 4 of 10 media types). Find-all-references is the tool for that residue, and it is overwhelmingly a C# problem here.

The two surfaces, and which one a subagent can reach

This distinction is the whole reason both halves are documented together.

Surface Servers Who can call it
The LSP tool (Claude Code plugins) csharp-ls, typescript-language-server, pyright-langserver No dispatched subagent has been observed to reach it. ToolSearch returns "No matching deferred tools found" for select:LSP in a subagent while the same query resolves in the main session — Claude Code 2.1.232, agent types general-purpose and Explore, 2026-08-14, plus the independent observation in docs/defect-shapes-773.md §5.1. Three observations on one harness version: treat it as measured behaviour to design around, not an architectural guarantee.
The csharp-lsp MCP server (.mcp.json) wraps csharp-ls; serves csharp_references, csharp_diagnostics, csharp_hover, csharp_definition, csharp_symbols, csharp_completions, csharp_set_workspace, … (16 tools) Main session and subagents. Subagents demonstrably call MCP tools here: 326 MCP calls from inside subagent turns across the transcript corpus, spanning four servers (gitea 288, playwright 26, ssh-mcp 6, mempalace 6). Not yet demonstrated for csharp-lsp specifically — that server only became startable on 2026-08-14 — so this is the general MCP boundary, evidenced, rather than a per-server measurement.

So an instruction telling delegated agents to use C# code intelligence must point them at the MCP tools, never at the LSP tool. Briefing harder does not help: an agent that cannot resolve the tool falls back to Grep, and does not announce that it did.

Configuration, and the failure each piece prevents

Both #777 root causes were the same shape — a config naming a path that this machine does not have, with nothing checking. Neither produced a wrong answer; each produced a server that could not start.

csharp-ls needs a dotnet root that owns host/fxr

MSBuildLocator resolves the SDK next to the dotnet host it finds and requires a sibling host/fxr/*/libhostfxr.dylib. A Homebrew install does not satisfy that from bin:

  • /opt/homebrew/bin/dotnet…/Cellar/dotnet/<v>/bin/dotnet, and …/bin/host/fxr does not exist
  • the real root is /opt/homebrew/opt/dotnet/libexec, which does own host/fxr

Unset, the server dies at initialize with ".NET SDK cannot be resolved, because libhostfxr.dylib cannot be found inside …/bin/host/fxr". Builds are unaffected — dotnet --version works — so this is invisible until a language-server query is actually made.

Set env.DOTNET_ROOT to the root that owns host/fxr in .claude/settings.local.json:

{ "env": { "DOTNET_ROOT": "/opt/homebrew/opt/dotnet/libexec" } }

Measured 2026-08-14 against csharp-ls 0.22.0, workspace /Users/timothy/ersatztv, five variants of initialize — each of these is sufficient on its own, and the control is the only failure:

Variant Value initialize
control inherit the plain shell environment fails (libhostfxr.dylib not found)
DOTNET_ROOT /opt/homebrew/opt/dotnet/libexec OK
DOTNET_HOST_PATH /opt/homebrew/opt/dotnet/libexec/dotnet — the direct libexec host binary, not /opt/homebrew/bin/dotnet OK
PATH first entry /opt/homebrew/opt/dotnet/libexec OK
DOTNET_ROOT + PATH (the ~/.codex/config.toml shape) both of the above OK
DOTNET_ROOT is chosen because it is also what the MCP server entry and
~/.codex/config.toml use, so the three agree.

Settings env is read at session start and is inherited by the spawned server: the csharp-ls process a session starts carries DOTNET_ROOT=/opt/homebrew/opt/dotnet/libexec in its environment (ps eww, verified 2026-08-14). A session already running when the setting changed keeps the old environment — restart it rather than concluding the fix failed.

It goes in the untracked local settings, not the tracked .claude/settings.json, deliberately. The value is one machine's Homebrew prefix. Committing it would export that path to every checkout, and on a host with a working dotnet elsewhere it would point a working server at a directory that does not exist — turning a shared config into the same environment-divergence failure this issue fixed. What is committed is the knowledge (this doc, the decision record) and the check (scripts/check-local-lsp.sh); the machine-specific value stays machine-local, exactly as .mcp.json already is.

typescript-language-server must resolve typescript from the repo root

The workspace root is the repo root, but the package lives in web/node_modules, so resolution fails and the server exits with "Could not find a valid TypeScript installation".

The plugin cannot be configured around it: typescript-language-server v5.1.3 exposes only --stdio and --log-level (--tsserver-path was removed), and a tsserver path can otherwise only arrive via initializationOptions.tsserver.path, which a plugin lspServers entry (command/args/extensionToLanguage) cannot set.

Two remedies exist, and they are not equivalent — measured 2026-08-14. The server resolves TypeScript by walking up from the workspace root and then falling back to require.resolve relative to its own install (lib/cli.mjs), so a global typescript is also found. That is what the plugin's own README prescribes (npm install -g typescript-language-server typescript), and it is the obvious fix — but here it produces a worse failure than the one it cures:

Remedy initialize findReferences on canLeaveCurrentScreen
neither failsCould not find a valid TypeScript installation n/a
global typescript only (7.0.2, no root link) succeeds empty — 6 polls over ~5 min, always []
root node_modules/typescript link → workspace 6.0.3 succeeds 20 references across 7 files

The global-only row is the dangerous one: the server starts, answers, and answers nothing, with no error to notice. A loud refusal is better than a silent empty population, so the root link is the remedy in use here. Only global typescript@7.0.2 was tested — another global version may behave differently — but that is the point: a global install makes the startup error disappear without proving anything about the answers, so any global-only setup needs its own behavioural check before it is called fixed.

mkdir -p node_modules
ln -sfn "$PWD/web/node_modules/typescript" node_modules/typescript

/node_modules/ is gitignored, so this is a per-checkout step — scripts/check-local-lsp.sh reports it when missing. Rooted this way the server reports Using Typescript version (workspace) 6.0.3 from path ".../web/node_modules/typescript/lib/tsserver.js" and answers cross-file queries over web/ exactly as it does when rooted at web/ directly.

The csharp-lsp MCP server

.mcp.json is gitignored, so its content is not recoverable from this repo — which is precisely how its command came to name a dotnet install that no longer exists while ~/.codex/config.toml's copy of the same server was migrated. The working entry:

"csharp-lsp": {
  "command": "/opt/homebrew/opt/dotnet/libexec/dotnet",
  "args": ["run", "--project",
           "/Users/timothy/ersatztv/.mcp/csharp-lsp-mcp/csharp-lsp-mcp/src/CSharpLspMcp",
           "-c", "Release"],
  "env": {
    "DOTNET_ROOT": "/opt/homebrew/opt/dotnet/libexec",
    "PATH": "/opt/homebrew/opt/dotnet/libexec:/opt/homebrew/bin:/Users/timothy/.dotnet/tools:/usr/bin:/bin:/usr/sbin:/sbin"
  }
}

The server is a vendored clone of csharp-lsp-mcp under .mcp/ (also gitignored). Drive it with csharp_set_workspace on ErsatzTV.sln once per session before other calls.

The clone does not build as upstream ships it, and that is the part most easily lost. Upstream targets net8.0 and its global.json pins SDK 8.0.0; this machine has only SDK 10.0.302, so a fresh clone fails to build and the server never starts — the same end state as the wrong command, reached a different way. The working tree is upstream 64185bc plus a local retarget, which is not committed anywhere upstream or here. To reconstruct:

git clone https://github.com/HYMMA/csharp-lsp-mcp .mcp/csharp-lsp-mcp
git -C .mcp/csharp-lsp-mcp checkout 64185bc
# retarget for an SDK-10-only host: global.json sdk.version 8.0.0 -> 10.0.0,
# CSharpLspMcp.csproj TargetFramework net8.0 -> net10.0, and
# Microsoft.Extensions.Hosting / .Logging.Console 8.0.0 -> 10.0.0

The alternative is to install the .NET 8 SDK and build upstream unmodified. Either is fine; what is not fine is leaving it undocumented, because scripts/check-local-lsp.sh now starts the server, so a wrong pin or a missing patch surfaces as a failed smoke test rather than a false pass.

Traps

  • A query issued before the project graph is loaded is answered anyway, and answered wrongly. Measured 2026-08-14, findReferences on the same symbol, cold vs settled:

    Server Symbol Cold answer Settled answer
    typescript-language-server 5.1.3 (workspace TS 6.0.3) canLeaveCurrentScreen (web/src/navigationGuard.ts:23) 1 location — the declaration alone 20 across 7 files
    csharp-ls 0.22.0 (ErsatzTV.sln) ChannelPlaylist.ToM3U() empty [], repeatedly, while the solution loaded 6 locations

    The TypeScript case is the dangerous one: a non-empty answer with nothing marking it incomplete. The C# case is loud by comparison — an empty list at least looks unfinished. Re-issue the query and confirm the count is stable before treating a reference list as a population. A sample that looks like a population is the exact failure class this tooling exists to prevent.

  • Solution discovery finds more than one solution. csharp-ls reports 2 solution(s) found: [ErsatzTV.sln, .mcp/csharp-lsp-mcp/…/CSharpLspMcp.sln] and loads ErsatzTV.sln. If a query ever returns nothing for a symbol that plainly exists, confirm which solution was loaded before concluding anything about the symbol.

  • Loading ErsatzTV.sln takes minutes, and a dotnet build running concurrently makes it worse. This cost is per session, not per query.

  • pyright-lsp needs no configuration. It answered correctly on 2026-08-13 (§5.1) and again on 2026-08-14 (documentSymbol on scripts/decisions_lib.py, full symbol tree). Two dated successes, not a longitudinal claim — it is the control showing a broken C#/TS server is a configuration fault rather than a harness fault.

Verifying

scripts/check-local-lsp.sh checks the five preconditions above and prints an actionable remedy per failure. It is operator-run and wired to no CI job — every dependency is a developer-machine install, so there is no runner on which a red would mean anything.

It checks preconditions, not behaviour. The end-to-end confirmation is a real query in a main session — e.g. findReferences on ChannelPlaylist.ToM3U() (ErsatzTV.Core/Iptv/ChannelPlaylist.cs), which returns the declaration plus 5 call sites and, unlike grep, excludes the mention of the name in a comment.