# 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//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.