:PROPERTIES:
:ID: davidwalter0-lspbridge-readme
:END:
#+title: lspbridge — shared LSP client + broker for the CQRS semantic tier
#+date: 2026-07-16
* What this is
=lspbridge= is the shared *semantic tier* substrate for the davidwalter0
polyglot CQRS tooling ([[https://github.com/davidwalter0/mcp-ast][mcp-ast]] = read, [[https://github.com/davidwalter0/mcp-agent-editor][mcp-agent-editor / ae]] = write).
It provides an out-of-process Language Server Protocol path for every
language *except Go* — Go keeps its in-process =go/types= + VTA whole-program
analysis (mcp-ast ADR-0001 rejected gopls delegation).
Two layers (this repo builds them bottom-up):
1. *Client library* — a JSON-RPC 2.0 over stdio transport (=pkg/jsonrpc=)
and a minimal LSP lifecycle client (=pkg/lsp=): initialize / didOpen /
didChange / shutdown, plus the protocol types (Location, Range,
WorkspaceEdit, Diagnostic) that map to the shared =structast= DTO.
2. *Broker* (=pkg/broker= + =cmd/lspbroker=) — an ADR-0013-style
socket-activated daemon owning one warm LSP session per
=(projectRoot, language)=, multiplexed to BOTH mcp-ast (read) and ae
(write). Lazy spawn, per-session single-flight cold start, idle-reap, and
clean shutdown-all on SIGTERM; path-based unix sockets only (abstract
sockets rejected). Never a merged third server: CQRS stays intact — reads
(documentSymbol / references / definition / hover) and writes (rename →
WorkspaceEdit → wsedit) hit the same warm session but through separate
client roles. A thin =broker.Client= is the caller-side seam for
mcp-ast / ae.
The per-language project scope is discovered through the =pkg/projectcontext=
seam (tsconfig.json / pubspec.yaml / compile_commands.json / pyrightconfig /
Cargo.toml / angular.json), which generalizes the C/C++ include-context
resolver. Six presets are wired end-to-end today — Python (pyright),
TypeScript/JavaScript (typescript-language-server), Rust (rust-analyzer),
Dart (the Dart Analysis Server, LIVE-proven), and Angular (the Angular
Language Server — preset + resolver wired, but NOT live-proven; see the
Roadmap entry below for why); C/C++ remains an aspirational entry until
clangd's preset lands.
* Roadmap (mgmt f51cf319, P0 keystone)
- [X] P0.1 JSON-RPC 2.0 stdio transport (=pkg/jsonrpc=)
- [X] P0.1 LSP lifecycle client + minimal types (=pkg/lsp=)
- [X] P0.1 ProjectContext seam (=pkg/projectcontext=)
- [X] P0.2a subprocess launch plumbing (=pkg/server=) + fake-server integration test
- [X] P0.2b pyright reference server LIVE: =pkg/server.PyrightSpec=, =pkg/lsp.DiagnosticsCollector=
(publishDiagnostics), real launch + initialize + didOpen + diagnostics proof against
pyright 1.1.411 (=npm install -g pyright@1.1.411=)
- [X] P0.2 WorkspaceEdit -> ae adapter (=pkg/wsedit=): neutral byte-offset edit
=Plan= (=FromWorkspaceEdit=, URI→path, LSP line/UTF-16-char→UTF-8 byte offset);
ae adapts FROM it (offset→unified-diff) so lspbridge stays zero-dep /
CGO_ENABLED=0. Seam: =docs/design/wsedit-ae-seam.org=. Plan carries the
=wsedit/1= schema marker (=wsedit.SchemaV1=) ae's =wseditingest= ingests.
- [X] P0.3 LSP READ methods (=pkg/lsp=): documentSymbol (hierarchical
DocumentSymbol + flat SymbolInformation fallback), references, definition,
hover, and rename (→ WorkspaceEdit; both =changes= and =documentChanges= wire
forms normalized). LIVE proofs against pyright 1.1.411: documentSymbol
=[greet main]=, references at both use lines, and the rename WRITE LOOP
(rename → WorkspaceEdit → =wsedit.FromWorkspaceEdit= → =FileEdit.Apply=).
- [X] P0.3 socket-activated broker daemon (ADR-0013 pattern): =pkg/broker=
library + =cmd/lspbroker=. Warm-session cache keyed on =Context.Key=, lazy
spawn (resolver chain → Spec; pyright preset), single-flight init, idle-reap
(default 10m), SIGTERM shutdown-all, path-based unix socket with stale-socket
cleanup. Wire protocol over =pkg/jsonrpc=: =broker/query= / =broker/status= /
=broker/shutdown=. LIVE end-to-end proof: client → socket → activated pyright
→ documentSymbol + rename write-loop.
- [X] P0.3 read/write client roles for mcp-ast / ae: =broker.Client=
(=Query= / =Status= / =Shutdown=) — the CQRS multiplexing seam; each consumer
builds its own LSP method+params, the broker keeps them on one warm session.
- [X] Codegen: =pkg/lsp= data types generated from the pinned LSP 3.18
metaModel.json instead of hand-transcribed (mgmt 8a6c4d87) — see
"Codegen" below.
- [X] Server presets beyond Python + Node ProjectContext (mgmt f51cf319
remaining items (2)+(3); lspbridge side of mgmt c8d915b0 parts (b)+(c)):
=projectcontext.TypeScriptResolver= / =JavaScriptResolver= (.ts/.tsx/.mts/.cts
and .js/.mjs/.cjs/.jsx; marker precedence tsconfig.json > jsconfig.json >
package.json; languageId "typescript"/"javascript", switching to
"typescriptreact"/"javascriptreact" for the JSX-flavored extensions per the
LSP spec's language-identifier table) and =RustResolver= (Cargo.toml); the
=MarkerResolver= type grew an optional =LanguageFor(ext)= hook to let one
resolver vary its languageId per extension (nil preserves every prior
preset's behavior unchanged). =projectcontext.DefaultChain()= composes all
four (python, typescript, javascript, rust) and is now the broker's default
resolver. =server.TypeScriptLanguageServerSpec= (=typescript-language-server
--stdio=; one process serves the whole JS/TS family) and
=server.RustAnalyzerSpec= (=rust-analyzer=, no flags — LSP-over-stdio is its
only mode) join =PyrightSpec= as launch presets; their missing-binary errors
name the =exttool install --tool NAME --confirm= remediation (exttool is
this ecosystem's declared non-Go-tool installer) WITHOUT lspbridge
importing exttool itself — that would add a dependency and break the
zero-=go.sum= invariant, so the message only names the fix. =broker.DefaultSpecFor=
now routes typescript/typescriptreact/javascript/javascriptreact →
typescript-language-server and rust → rust-analyzer alongside the existing
python → pyright case. LIVE proofs (skip-if-absent, all ran live against
typescript-language-server 4.3.4 and rust-analyzer 1.93.1 on the
development host): a =pkg/server=-level tsserver documentSymbol proof and a
=pkg/broker=-level end-to-end proof — an ALL-DEFAULT =broker.Config{}= (real
=DefaultChain= + =DefaultSpecFor=, no test-only wiring) dials over a real unix
socket against a fixture with package.json+tsconfig.json, proving the Node
ProjectContext seam end-to-end through the broker exactly as a real
consumer would exercise it; and a =pkg/server=-level rust-analyzer
documentSymbol proof over a tiny cargo project (generous timeout, skips
rather than fails if indexing is still cold at the deadline — rust-analyzer's
cargo metadata/check warm-up is host- and cache-dependent, so a still-empty
result there is "could not verify in budget," not a defect).
- [X] Dart + Angular server presets (semantic halves of mgmt
mcp-agent-editor c42a63e6 and mcp-ast 266242ac): =projectcontext.DartResolver=
(.dart; marker pubspec.yaml; languageId "dart") and
=projectcontext.AngularResolver= (.html; marker angular.json; languageId
"html" — verified directly against the installed (if incomplete)
@angular/language-server v20.0.1's own bundled source, whose internal
LanguageId enum names external templates "html", cross-checked against
https://angular.dev/tools/language-service and
https://emacs-lsp.github.io/lsp-mode/page/lsp-angular/; "angular" is not a
recognized LSP languageId anywhere in that chain). AngularResolver is the
first =MarkerResolver= preset where the extension alone does not imply a
claim — plain ".html" is ordinary markup outside an Angular workspace — so
=MarkerResolver= grew a second optional field, =RequireMarker= (alongside
the existing =LanguageFor= hook): when true, an unresolved marker walk
means "not claimed," not "fall back to the file's own directory," so a
plain static .html file is correctly left unclaimed by the whole chain
instead of being misrouted to ngserver.
=server.DartAnalysisServerSpec= (=dart language-server=, plus an explicit
--protocol=lsp flag — already the server's own default, but passed
explicitly per this package's defensive-explicitness convention; verified
live via =dart language-server --help= against Dart SDK 3.12.2) and
=server.AngularLanguageServerSpec= (=ngserver --stdio --tsProbeLocations DIR
--ngProbeLocations DIR=; both probe-location flags are required by the
server and point at the workspace root, which Node's own module-resolution
algorithm treats as a search starting point — confirmed by reading the
installed server's bundled resolver source) join the existing presets.
=broker.DefaultSpecFor= now routes dart → Dart Analysis Server and html →
Angular Language Server. =missingBinaryError= (shared by every preset's
LookPath failure) grew a second parameter separating the LookPath'd
command name from exttool's tool name, because — unlike every preset
before it — Angular's resolved binary ("ngserver") and exttool's
package-derived tool name ("angular-language-server") are different
strings; the remediation message names the latter.
LIVE proof: a =pkg/server=-level Dart Analysis Server documentSymbol proof
over a tiny pubspec.yaml package, run live against Dart SDK 3.12.2 on the
development host — =[greet main]= came back in well under a second, no
cold-index wait observed (unlike rust-analyzer/tsserver) — though the test
still carries this package's generous-timeout / skip-if-still-cold pattern
for host independence.
NO live proof for Angular: @angular/language-server is not available in a
working state on the development host. A stray, unrelated global npm
install was found (v20.0.1, bin shim dated May 2025 — evidently leftover
state from something else, not provisioned for this ecosystem) but it is
missing its required @angular/language-service peer (minimum version
15.0); invoking it live fails resolving that peer. AngularLanguageServerSpec's
args were instead verified by reading that incomplete install's own
bundled source directly (its --help usage text and internal LanguageId
enum) — a stronger primary source than either secondary doc page, even
without a working end-to-end run. Test coverage is a FAKE-SERVER unit
test, not a live one: a temp-dir stand-in executable named "ngserver" that
does nothing but exit 0 is placed first on PATH so exec.LookPath resolves
deterministically, proving the Spec-construction logic (Command/Args/Dir)
without any real LSP conversation.
Remaining per mgmt 266242ac: install a complete, exttool-managed
@angular/language-server + peer, a live documentSymbol/diagnostics proof,
and the template↔component cross-binding semantics (a property renamed in
the .ts component reflected in the .html template) that is the actual
point of this preset — resolver + Spec wiring alone doesn't give that yet.
Remaining per mgmt c42a63e6: a thin flutter CLI wrapper (build/run/
hot-reload/test lifecycle) — an Exec-gated command surface, not LSP, so
out of scope for lspbridge itself.
* Design
Full design: =mcp/docs/ecosystem/design/ecosystem-language-tooling-matrix.org=
(the P0 keystone row) and the lspbridge design in the fork-program findings
journal. Reference server for P0 is *pyright* (=pyright-langserver --stdio=):
cleanest single-server, no framework layering — its proof-of-concept also
closes the Python-semantic gap.
* Codegen: pkg/lsp types from the LSP metaModel (mgmt 8a6c4d87)
Most of =pkg/lsp='s protocol types are generated from a pinned copy of the
official LSP metaModel.json rather than hand-transcribed, so the type shapes
stay faithful to spec as the method surface grows. Trigger for this
migration: the read-method surface landed (documentSymbol / references /
definition / hover / rename / foldingRange / workspace-symbol) plus
publishDiagnostics — well past the "~5 more methods" threshold the original
hand-written =pkg/lsp= doc comment flagged.
- *Pin*: LSP 3.18.0 =metaModel.json=, vendored at =cmd/lspgen/metaModel.json=
(sha256 =caae8df639a4248520a3f589fd72945365e9d8ebca5baf564161a515430d9d41=,
fetched 2026-07-18 from
https://raw.githubusercontent.com/microsoft/language-server-protocol/gh-pages/_specifications/lsp/3.18/metaModel/metaModel.json).
=cmd/lspgen= embeds the file (=go:embed=) and re-verifies this hash at
every run, so a corrupted or hand-edited vendor copy fails loudly instead
of silently generating from the wrong spec text.
- *Generator*: =cmd/lspgen= (stdlib-only — =encoding/json= + =text/template=
+ =go/format= + =embed= — the root zero-=go.sum= invariant holds; verified
via =go list -deps=). It reads a CONFIGURED ALLOWLIST of
structures/enumerations (=cmd/lspgen/config.go=), not the whole 3.18
surface, and renders =pkg/lsp/types_gen.go= (DO-NOT-EDIT). Regenerate
after bumping the pin or editing the allowlist:
: go generate ./pkg/lsp/...
Generation is deterministic — identical input always produces
byte-identical output. =cmd/lspgen='s own tests assert this in-process;
the migration itself was additionally verified with two independent
=go generate= process invocations diffing byte-for-byte clean.
- *Generated* (=types_gen.go=): the base geometry (Position, Range, Location,
LocationLink), TextEdit, Diagnostic — plus the new DiagnosticRelatedInformation
(the todo's explicit "+related" fidelity gain) — and its
DiagnosticSeverity / SymbolKind / SymbolTag / FoldingRangeKind enums,
DocumentSymbol / SymbolInformation, FoldingRange, MarkupContent, every
read-method Params type (TextDocumentIdentifier, ReferenceContext/Params,
DefinitionParams, HoverParams, RenameParams, FoldingRangeParams,
WorkspaceSymbolParams, DocumentSymbolParams), the lifecycle/text-sync types
(ServerInfo, ClientInfo, TextDocumentItem, VersionedTextDocumentIdentifier,
DidOpenTextDocumentParams, DidChangeTextDocumentParams, InitializeResult,
PublishDiagnosticsParams), and a deliberately trimmed ClientCapabilities
family (ClientCapabilities through PublishDiagnosticsClientCapabilities —
each restricted, via the generator's =onlyFields= mechanism, to just the
field(s) lspbridge actually advertises today).
- *Hand-written, deliberately NOT generated* — documented field-by-field in
=cmd/lspgen/config.go='s header comment: WorkspaceEdit (+
TextDocumentEdit / OptionalVersionedTextDocumentIdentifier) for its custom
wire-form normalization; Hover for its custom polymorphic-contents
normalization; TextDocumentContentChangeEvent for its deliberate
whole-document-sync-only subset; DocumentSymbolResult, an lspbridge-invented
discriminator with no metaModel structure of its own; and InitializeParams,
a deliberately minimal subset of a much larger real structure. A handful of
individual fields also carry documented =fieldOverride= entries
(Diagnostic.code/message, MarkupContent.kind, TextDocumentItem.languageId,
FoldingRange.start/endCharacter, InitializeResult.capabilities) where the
mechanical spec-accurate type would either break an existing call site or
pull in a structure this package intentionally doesn't model (e.g.
ServerCapabilities, by far the largest structure in the whole metaModel).
- *The migration is a drop-in*: every pre-existing =pkg/lsp= and =org-lsp=
test passes unchanged, and the generated Go type + json tag for every type
that already existed is byte-for-byte what was hand-written before.
* Build
: make build # compile all (incl. cmd/lspbroker daemon)
: make test # unit tests (live pyright/tsserver/rust-analyzer/dart proofs skip when absent)
: make check # lint + vet + vuln + test + cov (the check-suite gate)
: make cov # per-package coverage table
Run the broker daemon:
: lspbroker [--socket PATH] [--idle-timeout DUR] [--verbose]
Pure standard library, =CGO_ENABLED=0=. (The daemon uses the stdlib =flag=
package rather than pflag, deliberately: adding a flag dependency would break
the zero-external-dependency / no-=go.sum= keystone invariant.)