Files
ersatztv/docs/local-lsp-tooling.md
T
timothyandtimothy b16ec15d6c
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 23s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m36s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m11s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m10s
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 4m13s
docs(781,799): re-measure the tooling audit from a derived population, and adopt serena (#862)
§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>
2026-08-28 14:37:34 +00:00

258 lines
16 KiB
Markdown

# 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/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`**:
```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.
```bash
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:
```json
"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](https://github.com/HYMMA/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:
```bash
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:
```json
"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.yml` and 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.dll` under
`~/.serena/language_servers/`, independent of `csharp-ls`. That is a *second* C# language server
competing for the same cores as a `dotnet build` or a `csharp-lsp` solution load. Its warm-up cost
has **not** been characterised: the one cold run on record (2026-08-28) activated in 1.7 s and
answered `find_symbol` in 6.8 s and `find_referencing_symbols` in 13.1 s, which is far short of
`csharp-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_symbols` with `max_answer_chars: 4000` on `ChannelPlaylist` returned *"The answer
is too long (5758 characters)"* followed by a "References without surrounding lines" list. Re-run
at `max_answer_chars: 60000`, the full answer names the **same 7 files and the same 17 reference
entries across 16 distinct symbols** (`GetChannelPlaylistHandler/Handle` is referenced twice) — so
on this query the degraded form lost only the `content_around_reference` snippets, 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, `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.