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
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:
@@ -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/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
Executable
+131
@@ -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 ))
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user