fix(777): repair both broken LSPs, and name the surface a subagent can actually reach (#793)
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 26s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m34s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m35s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m22s
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 5m12s

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>
This commit was merged in pull request #793.
This commit is contained in:
2026-08-14 15:55:20 +00:00
committed by timothy
co-authored by Claude Opus 5
parent b552e569bb
commit 4bead26326
8 changed files with 702 additions and 1 deletions
+9
View File
@@ -10,6 +10,10 @@ project.lock.json
# Claude Code
.mcp/
.mcp.json
# Machine-local settings (DOTNET_ROOT and friends — see docs/local-lsp-tooling.md).
# Ignored here rather than relying on a personal ~/.config/git/ignore, so a second
# contributor following that doc cannot accidentally commit their own Homebrew paths.
/.claude/settings.local.json
.agents/
plugins/
nupkg/
@@ -70,6 +74,11 @@ ErsatzTV/wwwroot/app/
web/dist/
web/node_modules
# Root-level link that makes `typescript` resolvable from the repo root, which is
# the LSP workspace root — without it typescript-language-server refuses to start
# (ersatztv#777). See docs/local-lsp-tooling.md.
/node_modules/
# E2E / screenshot scratch (from Playwright/live-E2E runs) — never committed
/*.png
.playwright-mcp/
+6
View File
@@ -26,6 +26,7 @@ doc below, or that changes which sections a task signal points to.**
| CI / release pipeline work | `docs/ci-cd.md` + `docs/decisions/release-ci-governance.md` |
| Proposing a new guard / CI check / regression test convention | `docs/defect-shapes-773.md` §4 (detector menu + the classes where no detector is plausible), then the two rules every guard must satisfy: `docs/decisions/records/testing/guard-derives-population-from-source.md` and `…/guard-ships-with-mutation-proof.md` |
| Adding / changing / deleting a guard file | `docs/guard-inventory.md` — every guard's row is machine-checked by `scripts/tests/test_guard_inventory.py`, so a new guard must acquire a row before the suite goes green |
| Finding every site that references a symbol (multi-site fix/sweep) | `docs/local-lsp-tooling.md` — which surface answers, and why a delegated agent must be pointed at the `csharp-lsp` MCP tools rather than the `LSP` tool |
| Live local run / Playwright-MCP verification | `docs/e2e-local.md` + `scripts/e2e-local.sh` |
| Adding/changing a UI-E2E browser flow | `docs/e2e-local.md` → "UI-E2E harness" + `scripts/e2e-ui.sh` |
| What does a test suite cover | `docs/testing.md` |
@@ -68,6 +69,11 @@ bounds, what's mined per issue): `docs/handoffs/chicorytv-issue-queue.md` → "K
- **`docs/spa-conventions.md`** — playbook for adding a screen to the ChicoryTV React SPA.
- **`docs/e2e-local.md`** (+ `scripts/e2e-local.sh`) — how to run a live local instance for manual
or Playwright-MCP verification.
- **`docs/local-lsp-tooling.md`** — the code-intelligence surfaces (the `LSP` tool's three servers and
the `csharp-lsp` MCP server): how each is configured, which one a **subagent** can actually reach,
the traps (a cold server answers the first query with a confidently partial result), and
`scripts/check-local-lsp.sh` to verify the preconditions. Read before briefing an agent to find
every site referencing a symbol.
- **`docs/testing.md`** — testing map: what each `*.Tests` project / `web` suite covers,
golden-file nets, the timezone-independence rule, how to run subsets, the per-PR verification
gate.
+1
View File
@@ -165,6 +165,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `security.iptv-browser-token` | Under a JWT-enabled deployment (`JWT:IssuerSigningKey` set), the browser SPA obtains a short-lived, globally-scoped `/iptv/*` access token from an authenticated `GET /api/v1/auth/iptv-token` and appends it as `?access_token=`; the endpoint answers 204 when JWT is disabled (nothing to mint). Lifetime defaults to 60 min, configurable via `JWT:BrowserTokenLifetimeMinutes`. | 2026-07-22 | [link](records/security/iptv-browser-token.md) |
| `security.session-auth-dual-credential` | `ApiAuthorizationFilter` accepts a request when a valid `X-Api-Key` matches OR the principal is an authenticated session (cookie `ctv-session`, `HttpOnly`/`SameSite=Lax`); session-authenticated mutations require the presence-only `X-CSRF` header or are rejected 403. This narrows the OIDC-inert sub-claim of `security.blazor-removal-auth-posture` (#206) — the rest of that record's auth-surface enumeration still holds. | 2026-07-12 | [link](records/security/session-auth-dual-credential.md) |
| `security.session-cutover-postify` | The browser SPA authenticates cookie-only (no more `X-Api-Key` from `web/`); the machine key is repurposed to external/MCP-only via `GET /api/auth/machine-key`; every side-effecting GET/HEAD under `/api` is converted to POST so the existing CSRF gate covers it (standing rule: never add a side-effecting GET/HEAD under `/api`). | 2026-07-12 | [link](records/security/session-cutover-postify.md) |
| `session.local-code-intelligence` | C# and TypeScript find-all-references are available again; brief delegated agents to the `csharp-lsp` MCP tools (`csharp_references`, `csharp_diagnostics`, …) rather than the `LSP` tool, which no dispatched subagent has been observed to resolve (Claude Code 2.1.232, agent types `general-purpose` and `Explore`, 2026-08-14). Preconditions are machine-local — `env.DOTNET_ROOT` in `.claude/settings.local.json` and a root `node_modules/typescript` link — and checkable with `scripts/check-local-lsp.sh`. | 2026-08-14 | [link](records/session/local-code-intelligence.md) |
| `session.shared-checkout-refresh` | Session end runs `scripts/refresh-shared-checkout.sh`, which fast-forwards `/Users/timothy/ersatztv` to `origin/main` (and reinstalls `web/node_modules` when the lockfile moved), refusing to touch anything unless that tree is on a clean, non-ahead `main`. | 2026-07-21 | [link](records/session/shared-checkout-refresh.md) |
| `spa.add-to-layer` | All add-to-collection/playlist/schedule affordances share one component layer at `web/src/media/addTo/`; multi-select is an explicit screen-level toggle, and the per-card menu offers schedule only for the server-validated kinds. | 2026-07-10 | [link](records/spa/add-to-layer.md) |
| `spa.app-shell-extraction` | `App.tsx` is only the composition root over `web/src/app/routes.tsx` (stable route-object identity), `app/AppShell.tsx` (shell chrome), and `app/ScreenContent.tsx` (exhaustive screen dispatch); primary actions are one explicit `PrimaryActionProvider` registration per screen, replacing the old global `ctv:primary-action` window event. | 2026-07-15 | [link](records/spa/app-shell-extraction.md) |
@@ -0,0 +1,55 @@
---
key: session.local-code-intelligence
title: '2026-08-14 — Brief subagents at the csharp-lsp MCP tools; no subagent has been observed to reach the LSP tool (#777)'
status: active
since: '2026-08-14'
supersedes: none
superseded-by: none
rule: 'C# and TypeScript find-all-references are available again; brief delegated agents to the `csharp-lsp` MCP tools (`csharp_references`, `csharp_diagnostics`, …) rather than the `LSP` tool, which no dispatched subagent has been observed to resolve (Claude Code 2.1.232, agent types `general-purpose` and `Explore`, 2026-08-14). Preconditions are machine-local — `env.DOTNET_ROOT` in `.claude/settings.local.json` and a root `node_modules/typescript` link — and checkable with `scripts/check-local-lsp.sh`.'
signals: 'find all references, findReferences, csharp-ls, typescript-language-server, LSP tool, csharp-lsp MCP, libhostfxr, MSBuildLocator, DOTNET_ROOT, subagent tool availability · paths: `docs/local-lsp-tooling.md`, `scripts/check-local-lsp.sh`, `.claude/settings.local.json`, `.mcp.json` · issues: #777, #773, #403, #671'
mechanics: '`docs/local-lsp-tooling.md` — surfaces, configuration, traps, verification'
---
**The capability that would address the residue of Family A was configured, enabled, and dead.** The
population that `testing.guard-derives-population-from-source` cannot reach is the one whose members
are *sites in code* rather than values — #403 (5 of 6 dispatch sites) and #671 (4 of 10 media types).
Find-all-references answers exactly that, and across 811 session transcripts the `LSP` tool was
invoked **zero** times, against 23,661 `Bash` calls over the same corpus. Both C# surfaces failed to
start; nothing reported it, because a language server that cannot initialize is indistinguishable
from one nobody asked.
**Two surfaces, and only one of them has been observed to cross the subagent boundary.** A dispatched
subagent's `ToolSearch` returns "No matching deferred tools found" for `select:LSP` while the same
query resolves in the main session — measured on Claude Code 2.1.232, agent types `general-purpose`
and `Explore`, 2026-08-14, plus the independent §5.1 observation. Three observations on one client
version: design around it, but it is measured behaviour, not a guaranteed contract. The MCP side is
positively evidenced — 326 MCP calls from inside subagent turns across four servers in the transcript
corpus — though not yet for `csharp-lsp` specifically, which only became startable on 2026-08-14. So
the standing note telling *workflow agents* to use C# code intelligence was unsatisfiable as written
against the `LSP` tool, and is satisfiable once pointed at the MCP tools. Naming the surface is the
load-bearing part: an agent that cannot resolve the tool substitutes Grep and does not say so.
**Both failures were a config naming a path the machine does not have.** `MSBuildLocator` needs a
dotnet root owning `host/fxr`, which a Homebrew `bin/dotnet` does not have (`libexec` does); the MCP
entry named a dotnet install that no longer existed, while `~/.codex/config.toml`'s copy of the same
server had been migrated. Neither returned a wrong answer — each refused to start, which is the
benign half of environment divergence and the reason it survived so long.
**`.mcp.json` and `.mcp/` are gitignored, so the working configuration is not recoverable from the
repo.** That is what let one client's copy drift from the other's with nothing to compare against.
`docs/local-lsp-tooling.md` carries the entry verbatim so a second machine can reconstruct it, and
`scripts/check-local-lsp.sh` turns every precondition into a check. The script is deliberately wired
to **no** CI job: every dependency it tests is a developer-machine install, so a red on a runner
would carry no information.
**Rejected: making find-all-references a mandatory step before multi-site fixes.** Not for
availability reasons — C# is *expected* to reach delegated agents through the MCP server, so a
mandate would likely be enforceable there (an inference from the MCP boundary generally, not a
measurement of `csharp-lsp` from a subagent); only the TypeScript route is main-session-bound. The reason is readiness, and it bites each server
separately: `csharp-ls` answers `[]` while `ErsatzTV.sln` loads (minutes), and
`typescript-language-server` answers with the declaration alone while its own project graph loads —
20 references reported as 1, with nothing marking it incomplete. Mandating the step without a way to prove the server had settled would license treating
that answer as a population — the precise failure the step exists to prevent, now carrying the
authority of a rule. It is documented as available and recommended for the multi-site case, with the
re-issue-and-confirm discipline attached, and the zero-invocation baseline above is what a future
measurement should be compared against.
+21 -1
View File
@@ -405,6 +405,26 @@ in front of you). It would help — **and it is not available.** Measured in thi
it addresses cannot invoke it even when it works. This is a live instance of overclaim/stale
claim (§2 rank 7) sitting in our own guidance.
**Resolved 2026-08-14 (#777), and one claim above needs correcting.** Both servers now work and both
answer a real cross-file query in this repo; the full setup, traps and verification are in
`docs/local-lsp-tooling.md`, and the rule is `session.local-code-intelligence`.
| Row above | Resolution |
|---|---|
| `csharp-lsp` | `MSBuildLocator` needs a dotnet root that owns `host/fxr`, which Homebrew's `bin` does not and its `libexec` does. Fixed by `env.DOTNET_ROOT` in `.claude/settings.local.json`. `findReferences` on `ChannelPlaylist.ToM3U()` returns the declaration plus its 5 call sites, excluding the mention of the name in a comment that `grep` matches. |
| `typescript-lsp` | No configuration lever exists — v5 dropped `--tsserver-path`, and a plugin `lspServers` entry cannot pass `initializationOptions`. Fixed by making the package resolvable from the workspace root (a gitignored root `node_modules/typescript` link). Returns 20 references across 7 files for `canLeaveCurrentScreen`. |
**The correction to conclusion 2**, which matters because the guidance it judges is still in use: the
note *"workflow agents must use csharp-lsp"* names the **`csharp-lsp` MCP server** tools
(`csharp_set_workspace`, `csharp_diagnostics`, `csharp_references`, …), not the `LSP` tool. MCP tools
**are** reachable from a subagent. The subagent measurement above is correct about the `LSP` tool and
was generalised one step too far: the note was unsatisfiable because that MCP server's `.mcp.json`
entry named a dotnet install that no longer existed, so it never started — not because agents cannot
invoke it. With the entry repaired the server serves 16 tools, so the guidance becomes satisfiable —
inferred from the MCP boundary generally (326 subagent MCP calls across four other servers), not yet
measured for `csharp-lsp` from a subagent. What must be briefed explicitly is **which surface**: pointing a subagent at the `LSP` tool
is still an instruction it cannot obey.
### 5.2 Python: the global instruction and this repo disagree, and the repo is silent
`~/.claude/CLAUDE.md` instructs `ruff check`, `ruff format --check`, and `pyright` after modifying
@@ -435,7 +455,7 @@ Measured over the session transcript corpus (811 files under
| `gitea` MCP | project `.mcp.json` **and** user scope (duplicate, identical values) | Heavy, same-day | Keep; de-duplicate the config |
| `ssh-mcp` | project `.mcp.json` | 16 transcripts, same-day | Keep |
| `mempalace` MCP | user scope | 10 transcripts, **last hit 2026-07-25** (19 days) | Keep, but see below |
| **`LSP` (all three servers)** | 3 plugins enabled + `csharp-lsp` in `enabledMcpjsonServers` | **0 calls in 811 transcripts** | Broken *and* unused |
| **`LSP` (all three servers)** | 3 plugins enabled + `csharp-lsp` in `enabledMcpjsonServers` | **0 calls in 811 transcripts** | Broken *and* unused — both C# and TS servers repaired 2026-08-14 (#777); the zero is the baseline a future measurement is compared against |
| `context7` | enabled, `CONTEXT7_API_KEY` set | 0 calls | Dead |
| `playwright` | enabled | 145 calls, last 2026-07-25 | Keep |
| `superpowers` | enabled | 29 transcripts, last 2026-08-04 | Keep |
+190
View File
@@ -0,0 +1,190 @@
# 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/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.
## 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.
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env bash
# Verify the local code-intelligence toolchain (ersatztv#777).
#
# Operator-run, NOT a CI gate: every dependency it checks is a developer-machine
# install (Homebrew dotnet, a global csharp-ls, web/node_modules), so there is no
# runner on which a red here would mean anything. It exists because the #777 root
# causes were both environment divergence — a config that silently pointed at a
# path this machine does not have — and that class is invisible until something
# looks. See docs/local-lsp-tooling.md.
#
# Exit 0 = every check ran AND passed. Exit 1 = at least one FAIL *or* SKIP —
# a skipped check is a non-result, not a success.
set -uo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
failures=0
checks=0
skipped=0
pass() { checks=$((checks + 1)); printf ' PASS %s\n' "$1"; }
fail() { checks=$((checks + 1)); failures=$((failures + 1)); printf ' FAIL %s\n' "$1"; }
# A skip is NOT a pass. Counting it as one would let the summary read "all checks
# passed" while the most important check never ran.
skip() { checks=$((checks + 1)); skipped=$((skipped + 1)); printf ' SKIP %s\n' "$1"; }
note() { printf ' %s\n' "$1"; }
echo "Local LSP toolchain check — $REPO_ROOT"
echo
# ---------------------------------------------------------------------------
echo "csharp-ls (the LSP tool's C# server)"
if ! command -v csharp-ls >/dev/null 2>&1; then
fail "csharp-ls is not on PATH"
note "install: dotnet tool install --global csharp-ls"
else
pass "csharp-ls on PATH ($(command -v csharp-ls))"
fi
# MSBuildLocator resolves the SDK next to the dotnet host it finds, and needs a
# sibling host/fxr. Homebrew's bin/dotnet has none — its real root is libexec.
dotnet_root="${DOTNET_ROOT:-}"
if [ -z "$dotnet_root" ]; then
if [ -n "${DOTNET_HOST_PATH:-}" ]; then
dotnet_root="$(dirname "$DOTNET_HOST_PATH")"
elif command -v dotnet >/dev/null 2>&1; then
dotnet_root="$(dirname "$(readlink -f "$(command -v dotnet)" 2>/dev/null || command -v dotnet)")"
fi
fi
if [ -z "$dotnet_root" ]; then
fail "no dotnet host could be resolved (DOTNET_ROOT, DOTNET_HOST_PATH, PATH all empty)"
elif compgen -G "$dotnet_root/host/fxr/*/libhostfxr.*" >/dev/null 2>&1; then
pass "dotnet root has host/fxr ($dotnet_root)"
else
fail "dotnet root has NO host/fxr — csharp-ls cannot initialize ($dotnet_root)"
note "this is the #777 failure: set DOTNET_ROOT to the root that owns host/fxr,"
note "e.g. /opt/homebrew/opt/dotnet/libexec on a Homebrew install."
note "Claude Code picks it up from .claude/settings.local.json -> env.DOTNET_ROOT"
note "(local, not the tracked settings.json — the value is machine-specific)."
fi
# ---------------------------------------------------------------------------
echo
echo "typescript-language-server (the LSP tool's TS server)"
if ! command -v typescript-language-server >/dev/null 2>&1; then
fail "typescript-language-server is not on PATH"
note "install: npm install -g typescript-language-server"
else
pass "typescript-language-server on PATH"
fi
# v5 has no --tsserver-path and the plugin cannot pass initializationOptions, so
# the ONLY lever is making `typescript` resolvable from the workspace root, which
# for Claude Code is the repo root — not web/.
if [ -e "$REPO_ROOT/node_modules/typescript/lib/tsserver.js" ]; then
pass "typescript resolvable from the repo root"
else
fail "typescript NOT resolvable from the repo root — the TS server will refuse to start"
note "the package lives in web/node_modules; link it at the root:"
note " mkdir -p '$REPO_ROOT/node_modules'"
note " ln -sfn '$REPO_ROOT/web/node_modules/typescript' '$REPO_ROOT/node_modules/typescript'"
fi
# ---------------------------------------------------------------------------
echo
echo "csharp-lsp MCP server (the C# path subagents can actually reach)"
mcp_json="$REPO_ROOT/.mcp.json"
if [ ! -f "$mcp_json" ]; then
fail ".mcp.json not present (it is gitignored — see docs/local-lsp-tooling.md to recreate it)"
elif [ "${SKIP_MCP_SMOKE:-0}" = "1" ]; then
skip "csharp-lsp MCP server (SKIP_MCP_SMOKE=1) — NOT verified"
elif ! command -v perl >/dev/null 2>&1 || ! command -v python3 >/dev/null 2>&1; then
fail "cannot run the MCP smoke test: perl and python3 are both required"
else
# Actually START the server and list its tools. Checking `[ -x command ]` instead
# would be vacuous: a DIRECTORY satisfies -x (`[ -x /bin ]` is true), so that
# predicate can report a pass for a server that cannot run at all.
#
# `exec @ARGV or die` is load-bearing. Without the `or die`, perl exits 0 when it
# cannot exec the command, the `if` reads that as success, and this branch prints
# a PASS having run nothing — the same false green one level up.
note "starting the MCP server (this takes ~30-60s on a cold build)..."
if smoke_out="$(perl -e 'alarm shift; exec @ARGV or die "exec failed: $!\n"' 240 \
python3 "$REPO_ROOT/scripts/mcp_smoke.py" "$mcp_json" csharp-lsp 200 \
--expect-server csharp-lsp-mcp \
--expect-tool csharp_set_workspace --expect-tool csharp_references 2>&1)"; then
pass "csharp-lsp MCP server starts and serves tools — ${smoke_out#OK: }"
else
fail "csharp-lsp MCP server did not come up"
note "${smoke_out:-(no output — the smoke test itself was killed)}"
note "#777's failure was an entry naming a dotnet install that no longer existed;"
note "a net8.0-targeted vendored clone on an SDK-10-only host fails the same way."
fi
fi
# ---------------------------------------------------------------------------
echo
passed=$(( checks - failures - skipped ))
if [ "$failures" -eq 0 ] && [ "$skipped" -eq 0 ]; then
echo "All $checks checks passed."
elif [ "$failures" -eq 0 ]; then
echo "$passed passed, $skipped SKIPPED of $checks — a skipped check is not a passed one."
else
echo "$failures of $checks checks FAILED ($skipped skipped) — see docs/local-lsp-tooling.md."
fi
# A skip is a non-result, so it is not success: exit non-zero unless everything ran.
exit $(( (failures > 0 || skipped > 0) ? 1 : 0 ))
+289
View File
@@ -0,0 +1,289 @@
#!/usr/bin/env python3
"""Bounded MCP smoke test for a server declared in an .mcp.json (ersatztv#777).
Written because the caller's original check only asked `[ -x command ]`, which a
DIRECTORY satisfies (`[ -x /bin ]` is true), so it could report a pass for a server
that cannot run. Review then found the first version of THIS file had the same
weakness one level in: it accepted any response carrying the right id, so a server
that was not the configured one or that answered with a malformed body passed.
Hence the shape and identity checks below: "it answered" is not "it answered
correctly", and a smoke test that cannot tell them apart is decoration.
Usage:
mcp_smoke.py <.mcp.json> <server> [timeout] [--expect-server NAME]
[--expect-tool NAME]...
Exit 0 only when the server answered `initialize` and `tools/list` with
well-formed bodies, matched `--expect-server` if given, and exposed every
`--expect-tool`. Failures exit non-zero with a diagnostic naming the stage.
Codes group failures by STAGE (config=3-5, command=6, project=7, spawn=8,
protocol=9-11, malformed=12, identity=13, tools=14) several distinct causes
deliberately share a stage code, so read the message, not the number.
ACCEPTED LIMIT: a server that writes a gigabyte with no newline can still exhaust
memory before the timeout fires. Guarding that needs the very frame-capping reader
whose caps caused three defects in an earlier round, and the input here is our OWN
configured server on a developer machine not an adversary. Stated rather than
silently unhandled.
Deliberately NOT bounded by message/line caps. A first attempt added them and
they were the defect: an over-long line had its suffix re-parsed as a fresh
message (a false green), and a cap reached before the awaited reply reported
"server did not start". What IS bounded is the set of retained DECODED responses
only a reply to the request in flight is kept, notifications are dropped as
they arrive and wall-clock, via the caller's timeout. The raw line buffer is
explicitly NOT bounded; that is the accepted limit stated above.
"""
from __future__ import annotations
import json
import os
import secrets
import shutil
import signal
import subprocess
import sys
import threading
import time
def fail(msg: str, code: int) -> int:
print(f"FAIL: {msg}")
return code
def main() -> int:
argv = sys.argv[1:]
expect_server: str | None = None
expect_tools: list[str] = []
positional: list[str] = []
i = 0
while i < len(argv):
if argv[i] == "--expect-server" and i + 1 < len(argv):
expect_server = argv[i + 1]; i += 2
elif argv[i] == "--expect-tool" and i + 1 < len(argv):
expect_tools.append(argv[i + 1]); i += 2
else:
positional.append(argv[i]); i += 1
if len(positional) < 2:
return fail("usage: mcp_smoke.py <.mcp.json> <server> [timeout] "
"[--expect-server NAME] [--expect-tool NAME]...", 2)
cfg_path, server = positional[0], positional[1]
if len(positional) > 2:
try:
budget = int(positional[2])
except ValueError:
return fail(f"timeout must be an integer, got {positional[2]!r}", 2)
if budget <= 0:
return fail(f"timeout must be positive, got {budget}", 2)
else:
budget = 180
try:
with open(cfg_path, encoding="utf-8") as fh:
doc = json.load(fh)
except FileNotFoundError:
return fail(f"{cfg_path} does not exist", 3)
except json.JSONDecodeError as exc:
return fail(f"{cfg_path} is not valid JSON: {exc}", 4)
except OSError as exc:
return fail(f"{cfg_path} could not be read: {exc}", 4)
servers = doc.get("mcpServers")
if not isinstance(servers, dict):
return fail(f"{cfg_path} has no 'mcpServers' object", 5)
cfg = servers.get(server)
if not isinstance(cfg, dict):
return fail(f"{cfg_path} has no '{server}' server entry", 5)
command = cfg.get("command")
args = cfg.get("args") or []
if not isinstance(command, str) or not command:
return fail(f"'{server}' has no string 'command'", 5)
if not isinstance(args, list) or not all(isinstance(a, str) for a in args):
return fail(f"'{server}' has a non-string-list 'args'", 5)
# A directory is executable, so `-x` alone is vacuous. A bare command name is
# legitimate and resolves via PATH (e.g. "gitea-mcp-server"), so resolve first
# and only then insist on a regular file.
resolved = command if os.path.sep in command else shutil.which(command)
if resolved is None:
return fail(f"command not found on PATH: {command}", 6)
if not os.path.isfile(resolved):
return fail(f"command is not a regular file: {resolved}", 6)
if not os.access(resolved, os.X_OK):
return fail(f"command is not executable: {resolved}", 6)
# The server runs with the config's directory as cwd, so a relative --project
# must be validated against THAT, not against the caller's cwd.
workdir = os.path.dirname(os.path.abspath(cfg_path)) or os.getcwd()
for idx, a in enumerate(args):
target = None
if a == "--project" and idx + 1 < len(args):
target = args[idx + 1]
elif a.startswith("--project="):
target = a.split("=", 1)[1]
if target:
probe = target if os.path.isabs(target) else os.path.join(workdir, target)
if not os.path.exists(probe):
return fail(f"--project path does not exist: {probe}", 7)
env = dict(os.environ)
extra = cfg.get("env") or {}
if isinstance(extra, dict):
env.update({k: v for k, v in extra.items() if isinstance(v, str)})
try:
proc = subprocess.Popen(
[resolved, *args], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, env=env, cwd=workdir,
start_new_session=True, # own process group, so children die with us
)
except OSError as exc:
return fail(f"could not start server: {exc}", 8)
try:
pgid = os.getpgid(proc.pid)
except OSError:
pgid = None
# Retain ONLY the reply to the request currently in flight. Keeping every integer
# id let a server pre-answer id 2 before it was asked, and `wait_for(2)` then
# accepted an answer to a question never posed — a false green. It also let a
# duplicate id overwrite an earlier reply, and let `responses` grow without bound.
# One pending id at a time fixes all three.
lock = threading.Lock()
pending: int | None = None
responses: dict[int, dict] = {}
drained = threading.Event()
def reader() -> None:
try:
for raw in proc.stdout: # type: ignore[union-attr]
line = raw.decode(errors="replace").strip()
if not line.startswith("{"):
continue
try:
msg = json.loads(line)
except json.JSONDecodeError:
continue
rid = msg.get("id")
if not isinstance(rid, int):
continue # a notification: nothing to retain
with lock:
# unsolicited, or a second answer to an already-answered id
if rid != pending or rid in responses:
continue
responses[rid] = msg
finally:
drained.set()
threading.Thread(target=reader, daemon=True).start()
def send(payload: dict) -> bool:
"""False when the pipe is gone — an instantly-exiting server is the #777
failure mode, so it must produce a diagnostic, not a BrokenPipeError."""
try:
proc.stdin.write((json.dumps(payload) + "\n").encode()) # type: ignore[union-attr]
proc.stdin.flush() # type: ignore[union-attr]
return True
except (BrokenPipeError, OSError, ValueError):
return False
def expect(req_id: int, payload: dict, deadline: float) -> dict | None:
"""Register the id BEFORE sending, so a reply cannot arrive unregistered."""
nonlocal pending
with lock:
pending = req_id
if not send(payload):
return None
return wait_for(req_id, deadline)
def wait_for(req_id: int, deadline: float) -> dict | None:
while time.time() < deadline:
if req_id in responses:
return responses[req_id]
# Only conclude "no answer" once the process is gone AND stdout is fully
# drained; otherwise a reply already in the pipe is reported as a no-show.
if proc.poll() is not None and drained.wait(timeout=2):
return responses.get(req_id)
time.sleep(0.25)
return responses.get(req_id)
def cleanup() -> None:
# `dotnet run` execs a CHILD (csharp-lsp-mcp), so the leader exiting on
# SIGTERM says nothing about the descendant. Always follow up with SIGKILL to
# the saved group: a stale server surviving a probe is exactly the litter
# this session found at start-up.
if pgid is not None:
for sig in (signal.SIGTERM, signal.SIGKILL):
try:
os.killpg(pgid, sig)
except OSError:
break # no group members left
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
pass
time.sleep(0.2)
else:
for sig in (signal.SIGTERM, signal.SIGKILL):
try:
proc.send_signal(sig)
proc.wait(timeout=5)
break
except (OSError, subprocess.TimeoutExpired):
continue
deadline = time.time() + budget
try:
# Random ids close the residual pre-answer race: holding a lock across send()
# cannot reject a frame the server emitted BEFORE the request, but a server
# cannot pre-answer an id it cannot guess.
id_init = secrets.randbelow(2**31 - 1000) + 1000
id_tools = secrets.randbelow(2**31 - 1000) + 1000
while id_tools == id_init:
id_tools = secrets.randbelow(2**31 - 1000) + 1000
init = expect(id_init, {"jsonrpc": "2.0", "id": id_init, "method": "initialize", "params": {
"protocolVersion": "2024-11-05", "capabilities": {},
"clientInfo": {"name": "mcp-smoke", "version": "0"}}}, deadline)
if init is None:
return fail(f"no 'initialize' response within {budget}s (server did not start)", 9)
if "error" in init:
return fail(f"initialize returned an error: {json.dumps(init['error'])[:300]}", 9)
result = init.get("result")
if not isinstance(result, dict):
return fail("initialize response has no 'result' object (malformed)", 12)
info = result.get("serverInfo")
if not isinstance(info, dict) or not isinstance(info.get("name"), str):
return fail("initialize result has no 'serverInfo.name' string (malformed)", 12)
actual = info["name"]
if expect_server is not None and actual != expect_server:
return fail(f"wrong server: expected '{expect_server}', got '{actual}'", 13)
send({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}})
listed = expect(id_tools, {"jsonrpc": "2.0", "id": id_tools,
"method": "tools/list", "params": {}}, deadline)
if listed is None:
return fail(f"no 'tools/list' response within {budget}s", 10)
lresult = listed.get("result")
if not isinstance(lresult, dict):
return fail("tools/list response has no 'result' object (malformed)", 12)
tools = lresult.get("tools")
if not isinstance(tools, list):
return fail("tools/list 'tools' is not a list (malformed)", 12)
names = {t.get("name") for t in tools if isinstance(t, dict) and isinstance(t.get("name"), str)}
if not names:
return fail("server started but exposes zero well-formed tools", 11)
missing = [t for t in expect_tools if t not in names]
if missing:
return fail(f"server '{actual}' is missing expected tool(s): {', '.join(missing)}", 14)
print(f"OK: {server} -> {actual} {info.get('version', '')}, {len(names)} tools")
return 0
finally:
cleanup()
if __name__ == "__main__":
sys.exit(main())