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
§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>
258 lines
16 KiB
Markdown
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.
|