The process is supervised: on exit after a successful initialize it is spawned again with exponential backoff (500ms to 30s), open documents are re-sent as didOpen before the new session is announced, and requests caught by the crash wait for it and retry. A crash more than a minute after the previous one starts a fresh streak; reaching the streak limit (default 3) ends restarting for good, after which every request fails with an LspError saying so. Restarts and give-ups are logged and forwarded to the MCP client. Writes to the child's stdin go through a PassThrough so a broken pipe ends the session instead of surfacing as an unhandled rejection.
4.3 KiB
4.3 KiB
lsp-mcp
An MCP server that exposes one Language Server to agents as a headless editor.
Effect 4 (effect/unstable/ai for MCP, effect/unstable/cli for the CLI) plus
vscode-languageserver-protocol for LSP. TypeScript runs directly on Node 26
(type stripping); there is no build step.
Commands
pnpm tsc— type-check (tsgo with the Effect language-service plugin)pnpm test— vitest; integration tests spawntest/fixtures/server.ts, a real LSP serverpnpm biome check --write --unsafe— lint/format (run before committing; its rewrites are safe)node src/main.ts --help— the CLI; smoke-test withnode src/main.ts --root <dir> -- <server> [args]- Commit with
jj commit -m "...", nevergit
The shell may have an HTTP proxy configured; when curling the --http transport locally, pass --noproxy '*'.
Layout
src/Model.ts agent-facing Schemas (1-based positions); drives tool input/output schemas
src/Tools.ts Toolkit definition + handlers wiring + capability gating (supportedBy)
src/Resources.ts MCP resources (lsp://server, lsp://diagnostics[/{file}]) and notifications
src/Server.ts layer composition: LanguageServer → Documents+Diagnostics → transport → tools/resources
src/main.ts CLI entry (Command/Flag/Argument), logs to stderr, NodeRuntime.runMain
src/lsp/ the ONLY place vscode-* packages are used at runtime
LanguageServer.ts spawn + JSON-RPC + initialize/shutdown, supervised: restarts on crash per policy, reopens documents
request() waits for $/progress idle and retries ContentModified / a server that is being restarted
Documents.ts on-demand didOpen, LRU via ScopedCache, disk change detection → didChange+didSave, applyEdit
Diagnostics.ts publishDiagnostics accumulation; pull (textDocument/diagnostic) or settled push per doc version
Editor.ts the operations = MCP tool handlers; LSP results → Model values
test/fixtures/server.ts fixture language server (hover/definition/references/rename/symbols/TODO diagnostics)
test/fixtures/workspace.ts temp workspace layer running the fixture server
Rule: outside src/lsp/, vscode-* may only be imported as types.
Design decisions worth knowing
- Positions handed to agents are 1-based;
Whereacceptscolumnor asymbolname to find on the line. - Client capabilities are deliberately narrow (no dynamic registration, no watched files, UTF-16 only) so
servers fall back to their own file watching. Settings come from
--settingsviaworkspace/configuration. - Tools are registered after LSP
initializeso unsupported ones are never offered (Tools.supportedBy).diagnosticsis always offered. - Tool failures are
LspError | DocumentError(declaredfailureschema), so their messages reach the agent. - Optional fields in
Modeluse a localoptionalhelper that omits the key when encoding, because MCPstructuredContentmust be JSON (noundefined). - Diagnostics waiting: pull if the server supports it; otherwise wait for a publish for the doc's version plus a 250ms quiet period, bounded by 5s, memoized per (uri, version).
- Tests that talk to the fixture server use
it.layer(..., { excludeTestServices: true })so the real clock is used (Stream.debounce / timeouts would hang under TestClock). - Crashes:
--restart never|always|N(default 3). A crash is the process exiting or closing the connection after a successful initialize; a server that cannot initialize fails lsp-mcp immediately. Restarts back off from 500ms to 30s, a crash more than a minute after the previous one starts a new streak, and the streak limitNends restarting for good. Requests in flight during a crash wait for the new process and retry; documents are re-didOpened with their current text; the capabilities and tool set stay those of the first process. Each restart and the final give-up are logged and sent to the MCP client asnotifications/message. Once given up, every tool call fails with anLspErrorthat says so.
Status
Complete for the first release: 13 tools, 2 resources + 1 template, stdio and HTTP transports,
verified against the fixture server and typescript-language-server.
Ideas not done: inlay hints, type hierarchy, semantic tokens, partial results, workspace/didChangeWatchedFiles when a server insists on it.