§5.3's verdicts rested on a single surface, which manufactured four false zeros: codex is driven through `codex exec` inside Bash, security-guidance and ralph-loop expose no tool at all and run as hooks (1,086 executions each), and feature-dev is used through its agents. The audit also compared current enablement against historical usage — six of the eight plugins it called "genuinely unused" were disabled for 16 of the 30 corpus days. The retirement half of #781 is answered *no* on evidence: the zeros split six ways and only one is grounds for removal. Eight plugins are kept by operator decision. #799's observation was correct and its cause is now established. serena was `false` in settings.json until 2026-08-14T12:31Z, when a concurrent session enabled it; its tools appear in no transcript before 12:42:54Z. #799's session started at 12:01Z and never reloaded, so its probe correctly found nothing while the settings file already said `true`. serena is adopted and documented as the third code-intelligence surface. Four review rounds, two independent cold reviewers (one cross-family); rounds 1-3 BLOCKED. fixes #781 fixes #799 Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
16 KiB
Local code-intelligence tooling (LSP, the csharp-lsp MCP server, serena)
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 three 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. |
The serena MCP server (plugin serena@claude-plugins-official) |
its own bundled Roslyn C# server; its generated config lists 73 selectable language-server ids, one or more chosen per project (first match wins per file) | Main session demonstrated. Subagent use is expected (it is MCP, so the boundary above applies) but not yet demonstrated — every serena call on record is from a main session. Requires an explicit activate_project before the first query — see below. |
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.
The serena MCP server
serena is an enabled plugin (serena@claude-plugins-official) declaring one MCP server:
"serena": {
"command": "uvx",
"args": ["--from", "git+https://github.com/oraios/serena", "serena", "start-mcp-server"]
}
Nothing about it needs configuring in this repo. It answers the same find-all-references question as
csharp-lsp, and being MCP it is expected to be reachable from dispatched subagents — the gap the
LSP tool leaves — though that has not yet been demonstrated for serena specifically.
Once it is available, it still requires an explicit project activation. This was not the cause
of #799 — that session never had serena's tools at all (see docs/defect-shapes-773.md §5.3) — it is
a second prerequisite you meet only after the tools resolve. A fresh server has no active project,
and the first real call fails with a message naming the projects it does know:
No active project. Ask the user to provide the project path or to select a project
from this list of known projects: ['adversarial-reviewer', 'server-management', ...]
That error is the tell, and it is the system reporting this prerequisite rather than the caller inferring it from an empty result. Activate by absolute path, once per directory:
mcp__plugin_serena_serena__activate_project project=/Users/timothy/ersatztv
Demonstrated on 2026-08-28 against a worktree of this repo (/Users/timothy/ersatztv-wt/781-tooling
— the path above is the canonical checkout, which is the one to activate for ordinary work) —
find_symbol on ChannelPlaylist
(ErsatzTV.Core/Iptv/ChannelPlaylist.cs) returns the class and its constructor, and
find_referencing_symbols on ErsatzTV.Core.Iptv/ChannelPlaylist returns references across 7
files spanning ErsatzTV.Core.Tests, ErsatzTV/Controllers, ErsatzTV/Formatters and
ErsatzTV.Application — the cross-project spread that makes it useful for the sites-in-code
residue.
Traps specific to it:
- Activation is per directory, not per repository, and it is a registration — not a session setting. A git worktree is a different path, so it registers as a separate serena project named after the worktree directory. Activate each directory you work in the first time; a running server also has one active project at a time, so switching directories means activating again even if both are already registered.
- It writes
.serena/into the project root —project.yml,project.local.ymland a language server cache. Gitignored here since #799; activating in a checkout that predates that entry leaves an untracked directory. - It starts its own Roslyn server,
Microsoft.CodeAnalysis.LanguageServer.dllunder~/.serena/language_servers/, independent ofcsharp-ls. That is a second C# language server competing for the same cores as adotnet buildor acsharp-lspsolution load. Its warm-up cost has not been characterised: the one cold run on record (2026-08-28) activated in 1.7 s and answeredfind_symbolin 6.8 s andfind_referencing_symbolsin 13.1 s, which is far short ofcsharp-ls's multi-minute solution load. One observation is not a warm-up profile — treat the cold-query trap below as applying until someone measures it. - A truncated answer degrades into a summary, and says so only in its first line.
find_referencing_symbolswithmax_answer_chars: 4000onChannelPlaylistreturned "The answer is too long (5758 characters)" followed by a "References without surrounding lines" list. Re-run atmax_answer_chars: 60000, the full answer names the same 7 files and the same 17 reference entries across 16 distinct symbols (GetChannelPlaylistHandler/Handleis referenced twice) — so on this query the degraded form lost only thecontent_around_referencesnippets, not coverage. One comparison is not a guarantee for every query: raise the limit and diff the two before treating a truncated result as exhaustive.
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.