Claude Code LSP Integration: Persistent Servers, Push-Based Diagnostics
How Claude Code runs a persistent multi-server LSP stack, collects diagnostics passively via push notifications, and bounds the registry to 10 diagnostics per file and 30 total.
Most agent coding tools call a language server on demand — ask a question, get a diagnostic, discard.
Claude Code runs a persistent multi-server LSP stack as background infrastructure.
The difference matters: passive diagnostic collection means the agent has up-to-date type errors and reference data without paying a startup cost per query.
File structure
src/
├── services/
│ ├── lsp/
│ │ ├── LSPServerInstance.ts
│ │ ├── LSPDiagnosticRegistry.ts
│ │ ├── LSPManager.ts
│ │ └── lspDiagnosticsTool.tssrc/services/lsp/LSPServerInstance.ts
Language server lifecycle manager
Factory-pattern server instance with 5 lifecycle states: initializing, ready, error, restarting, stopped. Manages process spawn, JSON-RPC channel, capability negotiation, and restart-on-crash logic.
Key Exports
- LSPServerInstance
- LSPServerState
- createLSPServer
Why It Matters
- The factory pattern means LSPServerInstance is created per server type (TypeScript, Python, Rust), not per file.
- Restart-on-crash is built into the lifecycle, not bolted on — the state machine handles the transition from error back to initializing.
- Capability negotiation at initialization determines which LSP features are available, so not all servers offer the same surface.
src/services/lsp/LSPDiagnosticRegistry.ts
Bounded diagnostic cache with LRU dedup
Collects diagnostics pushed by language servers via textDocument/publishDiagnostics notifications. Enforces bounds: max 10 diagnostics per file, 30 total. LRU deduplication prevents the same error from filling the registry.
Key Exports
- LSPDiagnosticRegistry
- getDiagnostics
- updateDiagnostics
Why It Matters
- The registry is push-based — servers publish diagnostics asynchronously, not on request.
- The 10-per-file and 30-total bounds are a deliberate product decision: more than this would overflow context when injected into a prompt.
- LRU deduplication prevents a single file with many variants of the same error from consuming the entire budget.
src/services/lsp/LSPTool.ts
LSP tool surface exposed to the agent
Wraps LSP server capabilities (hover, definition, references, rename, completion) as tool-protocol-compatible functions. The model can call these like any other tool, but they route to the appropriate language server instance.
Key Exports
- LSPTool
- LSPToolInput
- LSPToolOutput
Why It Matters
- LSPTool is the bridge between the agent's tool protocol and the JSON-RPC language server protocol.
- Each capability (hover, definition, references) becomes a distinct tool the model can call by name.
- The tool layer handles server routing — the model does not need to know which server handles which file type.
Why a persistent stack instead of on-demand
LSP stack design in Claude Code
From persistent server process to agent-accessible diagnostic data.
- 1
Spawn language server processes at session start
services/lsp/LSPServerInstance.tsLSPServerInstance spawns one server process per supported language. Startup cost is paid once, not per query. Servers stay alive for the session.
- 2
Negotiate capabilities
services/lsp/LSPServerInstance.ts (initialize handshake)Each server reports which LSP methods it supports during initialization. The capability map determines what LSPTool can offer for files of that language.
- 3
Collect diagnostics passively
services/lsp/LSPDiagnosticRegistry.tsLanguage servers push textDocument/publishDiagnostics notifications whenever they detect errors. The registry collects these in the background without any agent request.
- 4
Bound and deduplicate
services/lsp/LSPDiagnosticRegistry.tsThe registry enforces 10 diagnostics per file and 30 total. LRU deduplication prevents a single noisy file from consuming the entire budget.
- 5
Expose as tool surface
services/lsp/LSPTool.tsLSPTool wraps hover, definition, references, rename, and completion as tool-protocol-compatible functions the model can call by name.
The passive collection model
The most important design choice in the LSP stack is that diagnostic collection is push-based.
Language servers emit textDocument/publishDiagnostics notifications whenever they recompute. The registry listens and updates. The agent does not need to request diagnostics — they are already there when the model asks for context.
This is meaningfully different from a request-response model where the agent asks "what are the current errors in file.ts?" and waits for the server to respond.
With passive collection:
- diagnostic data is available at turn start, not mid-turn
- no round-trip cost per diagnostic request
- the agent can reference "current errors" without triggering a separate tool call
The tradeoff is that the registry may be slightly stale if a server is slow to recompute. Claude Code accepts this tradeoff because staleness on the order of seconds is acceptable for the typical agent coding loop.
The registry bounds are a prompt budget decision
The 10-per-file and 30-total limits are not arbitrary.
They are a prompt budget decision.
If the agent injects diagnostics into a turn's context, those diagnostics consume tokens. Unbounded diagnostics from a project with many type errors could easily fill the available context budget before the actual task is described.
The registry bounds ensure that diagnostic context is always bounded and predictable.
The LRU deduplication ensures that one file with 50 variants of the same null reference error does not consume all 30 slots, leaving no room for diagnostics from other files.
Server routing and multi-language support
LSPTool handles routing transparently.
When the model calls a tool like lsp_go_to_definition with a file path argument, LSPTool:
- determines the file's language from its extension
- routes to the appropriate LSPServerInstance for that language
- sends the JSON-RPC request and returns the result
The model never needs to know which server handles TypeScript versus Python versus Rust. That routing is infrastructure, not capability.
LSP stack pattern across languages
Persistent server, push-based diagnostic collection, and bounded registry. JS shows the actual shape; Python and Go show the same pattern.
5-state server lifecycle and push-based registry — actual pattern from learning-claude-code
type LSPServerState =
| 'initializing'
| 'ready'
| 'error'
| 'restarting'
| 'stopped'
class LSPServerInstance {
private state: LSPServerState = 'initializing'
private process: ChildProcess | null = null
private capabilities: ServerCapabilities = {}
async start() {
this.process = spawnLanguageServer(this.serverType)
const capabilities = await this.initialize()
this.capabilities = capabilities
this.state = 'ready'
this.process.on('exit', () => {
if (this.state !== 'stopped') {
this.state = 'restarting'
this.restart()
}
})
}
async sendRequest(method, params) {
if (this.state !== 'ready') throw new Error('Server not ready: ' + this.state)
return this.channel.sendRequest(method, params)
}
}
// LSPDiagnosticRegistry: push-based, bounded
class LSPDiagnosticRegistry {
private byFile = new Map()
private totalCount = 0
// Called by server when it pushes textDocument/publishDiagnostics
updateDiagnostics(fileUri: string, diagnostics: Diagnostic[]): void {
const prev = this.byFile.get(fileUri) ?? []
this.totalCount -= prev.length
// Enforce per-file and total bounds
const bounded = dedupLRU(diagnostics).slice(0, 10)
const allowedCount = Math.min(bounded.length, 30 - this.totalCount)
const accepted = bounded.slice(0, allowedCount)
this.byFile.set(fileUri, accepted)
this.totalCount += accepted.length
}
getDiagnostics(fileUri?: string): Diagnostic[] {
if (fileUri) return this.byFile.get(fileUri) ?? []
return [...this.byFile.values()].flat()
}
}Claude Code's LSP diagnostic registry enforces a 10-per-file and 30-total bound. A project has 8 files each with 6 type errors, for a total of 48 errors. How many diagnostics will the registry contain, and why?
mediumThe registry uses LRU deduplication and enforces bounds. Updates arrive as push notifications from the language server, one file at a time.
A48 — the bounds are only enforced if the user configures them
Incorrect.The bounds are always enforced. They are not configurable per-project.B30 — the per-file cap of 10 applies first, then the total cap of 30 limits the rest
Correct!Correct. Each file contributes at most 10 diagnostics. With 8 files × up to 6 errors each = 48 raw errors. After the per-file cap: 8 files × 6 errors (all under 10) = 48 accepted per-file. But the total cap of 30 is then applied, so only 30 total diagnostics are retained. The exact distribution across files depends on the order updates arrived — later files may be crowded out if earlier ones filled the budget.C80 — 10 per file × 8 files
Incorrect.The per-file cap is 10, but there is also a total cap of 30 across all files. 10 × 8 = 80 would exceed the total cap.D6 — only the errors from the most recently updated file are retained
Incorrect.The registry is not last-write-wins. It accumulates diagnostics from all files up to the total cap.