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>
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/fxrdoes not exist- the real root is
/opt/homebrew/opt/dotnet/libexec, which does ownhost/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 | fails — Could 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,
findReferenceson the same symbol, cold vs settled:Server Symbol Cold answer Settled answer typescript-language-server5.1.3 (workspace TS 6.0.3)canLeaveCurrentScreen(web/src/navigationGuard.ts:23)1 location — the declaration alone 20 across 7 files csharp-ls0.22.0 (ErsatzTV.sln)ChannelPlaylist.ToM3U()empty [], repeatedly, while the solution loaded6 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-lsreports2 solution(s) found: [ErsatzTV.sln, .mcp/csharp-lsp-mcp/…/CSharpLspMcp.sln]and loadsErsatzTV.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.slntakes minutes, and adotnet buildrunning concurrently makes it worse. This cost is per session, not per query. -
pyright-lspneeds no configuration. It answered correctly on 2026-08-13 (§5.1) and again on 2026-08-14 (documentSymbolonscripts/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.