services/lsp/ — Language Server Protocol Service
Code intelligence via Language Server Protocol
Manages multiple language server processes, provides go-to-definition, find-references, hover info, and real-time diagnostics. Uses a factory-function architecture with closures for state management, routing requests to the correct server based on file extensions. The eyes and ears that let Claude Code understand your codebase.
📚 What is this & why does it exist?
The LSP service gives Claude Code the ability to understand code structurally, not just as text. When you ask "where is this function defined?" or "what calls this method?", the LSP service routes that question to the right language server (TypeScript, Python, Rust, etc.) and returns precise, compiler-grade answers. It also passively collects diagnostics — errors and warnings — so Claude can proactively notice when its edits break something.
💡 Real-world analogy
Think of a large hospital with specialist departments — cardiology, neurology, orthopedics. The LSP Manager is the front-desk receptionist who knows which department handles which condition. When a patient (file) arrives, the receptionist checks their symptoms (file extension), routes them to the right specialist (language server), and makes sure the specialist is staffed and ready. Each specialist can diagnose problems (diagnostics), point to related cases (references), and explain medical history (hover info). If a specialist crashes, the receptionist tries to restart them up to three times before giving up.
Contents
Architecture
flowchart TD MGR["manager.ts\nSingleton Lifecycle"] -->|creates| SM["LSPServerManager\n(createLSPServerManager)"] CFG["config.ts\nPlugin Config Loader"] -->|server configs| SM SM -->|per-language| SI["LSPServerInstance\n(createLSPServerInstance)"] SI -->|spawns| CL["LSPClient\n(createLSPClient)"] CL -->|JSON-RPC| LS["Language Server\n(external process)"] LS -->|publishDiagnostics| PF["passiveFeedback.ts\nNotification Handler"] PF -->|registers| DR["LSPDiagnosticRegistry\nDedup & Volume Limit"] SM -->|extension routing| EM["Extension Map\next → server name"] SM -->|file sync| OF["Open Files Map\nfileUri → serverName"]
Server Instance State Machine
stateDiagram-v2 [*] --> stopped stopped --> starting : start() starting --> running : initialize OK starting --> error : init failed error --> starting : restart (count < maxRestarts) error --> stopped : max restarts exceeded running --> stopping : stop() running --> error : crash detected stopping --> stopped : cleanup complete stopped --> [*]
Key Classes & Functions
| Class / Function | File | Purpose |
|---|---|---|
createLSPClient() | LSPClient.ts | Factory creating JSON-RPC client wrapper over a spawned language server process |
createLSPServerInstance() | LSPServerInstance.ts | Factory creating managed server instance with state machine, crash recovery, retry logic |
createLSPServerManager() | LSPServerManager.ts | Factory creating multi-server orchestrator with file routing and synchronization |
registerLSPNotificationHandlers() | passiveFeedback.ts | Registers textDocument/publishDiagnostics handlers on all servers |
formatDiagnosticsForAttachment() | passiveFeedback.ts | Converts LSP diagnostic params to Claude-compatible attachment format |
registerPendingLSPDiagnostic() | LSPDiagnosticRegistry.ts | Queues a diagnostic notification for deduplication and delivery |
checkForLSPDiagnostics() | LSPDiagnosticRegistry.ts | Retrieves deduplicated diagnostics with volume limiting (10/file, 30 total) |
initializeLspServerManager() | manager.ts | Creates and initializes the singleton manager (non-blocking, skips in bare mode) |
reinitializeLspServerManager() | manager.ts | Force re-init after plugin cache refresh; uses generation counter for stale invalidation |
getLspServerManager() | manager.ts | Returns singleton LSPServerManager | undefined |
getAllLspServers() | config.ts | Loads LSP server configs from all enabled plugins in parallel |
isLspConnected() | manager.ts | Returns true if at least one language server is healthy |
waitForInitialization() | manager.ts | Promise that resolves when LSP initialization completes (success or failure) |
getInitializationStatus() | manager.ts | Returns current state: 'not-started' | 'pending' | 'success' | 'failed' |
Data Structures
| Type | Name | Description |
|---|---|---|
| type | LSPClient | Interface: capabilities, isInitialized, start(), initialize(), sendRequest<T>(), sendNotification(), onNotification(), stop() |
| type | LSPServerInstance | Interface: name, config, state, startTime, lastError, restartCount, lifecycle methods |
| type | LSPServerManager | Interface: initialize(), shutdown(), getServerForFile(), sendRequest<T>(), openFile(), changeFile(), saveFile(), closeFile() |
| type | PendingLSPDiagnostic | { serverName, files: DiagnosticFile[], timestamp, attachmentSent } |
| type | HandlerRegistrationResult | { totalServers, successCount, registrationErrors, diagnosticFailures } |
| type | InitializationState | 'not-started' | 'pending' | 'success' | 'failed' |
| type | ScopedLspServerConfig | Server config with command, args, env, extensionToLanguage, initializationOptions |
| Map | servers | Map<string, LSPServerInstance> — registered server instances |
| Map | extensionMap | Map<string, string[]> — file extension → server name routing |
| Map | openedFiles | Map<string, string> — fileUri → serverName tracking for sync |
| Map | pendingDiagnostics | Map<string, PendingLSPDiagnostic> — UUID-keyed diagnostic queue |
| LRUCache | deliveredDiagnostics | LRUCache<string, Set<string>> — cross-turn dedup (max 500 files) |
Interactions
| Direction | Partner | Interface | Description |
|---|---|---|---|
| → | Language Servers | JSON-RPC (stdio) | LSP protocol: initialize, textDocument/*, workspace/* requests |
| ← | Language Servers | textDocument/publishDiagnostics | Passive diagnostic notifications pushed from servers |
| ← | LSPTool | sendRequest() | Agent tools invoke go-to-definition, find-references, hover |
| ← | Plugin System | getPluginLspServers() | Plugins register language server configs |
| → | Agent Message Loop | checkForLSPDiagnostics() | Diagnostics attached to agent messages for proactive error awareness |
| ↔ | File System | didOpen / didChange / didSave | File synchronization protocol keeps servers in sync with edits |
| ← | Language Servers | workspace/configuration | Server requests handled by returning null config items |
Configuration
| Knob / Parameter | Default | Description |
|---|---|---|
command | — | LSP server executable path |
args | [] | Command-line arguments passed to server |
env | {} | Environment variable overrides for server process |
extensionToLanguage | — | Map of file extensions to LSP language IDs (e.g., .ts → typescript) |
initializationOptions | {} | Server-specific initialization parameters |
startupTimeout | — | Initialization timeout in milliseconds |
maxRestarts | 3 | Maximum crash recovery attempts before giving up |
MAX_RETRIES_FOR_TRANSIENT_ERRORS | 3 | Retry attempts for ContentModified (-32801) errors |
RETRY_BASE_DELAY_MS | 500 | Base exponential backoff delay for transient retries |
MAX_DIAGNOSTICS_PER_FILE | 10 | Volume cap per file in diagnostic delivery |
MAX_TOTAL_DIAGNOSTICS | 30 | Volume cap across all files in a single delivery |
MAX_DELIVERED_FILES | 500 | LRU cache size for cross-turn diagnostic deduplication |
Directory Map
Source tree for lsp
lsp/
├── LSPClient.ts ← JSON-RPC client wrapper (448 lines)
├── LSPDiagnosticRegistry.ts ← Diagnostic dedup & volume limiting (387 lines)
├── LSPServerInstance.ts ← Single server lifecycle & state machine (512 lines)
├── LSPServerManager.ts ← Multi-server orchestration & file routing (421 lines)
├── config.ts ← Plugin-based config loading (80 lines)
├── manager.ts ← Singleton lifecycle & initialization (290 lines)
└── passiveFeedback.ts ← Diagnostic notification handlers (329 lines)
Deep-Dive Highlights
Core mechanisms from this sub-module have been extracted into dedicated focus areas. Each topic covers one protocol flow, subsystem, or algorithm in detail.
Deep-Dive Pages
Diagnostic Pipeline Focus
Within-batch and cross-turn deduplication, severity-sorted volume limiting, and LRU delivery tracking.
Server Lifecycle & Crash Recovery Focus
State machine transitions, exponential backoff on transient errors, max-restart guard, and generation-based invalidation.
File Synchronization Protocol Focus
didOpen/didChange/didSave/didClose flow, auto-promotion from change to open, extension-based language ID mapping.
Capabilities Negotiated
During the LSP initialize handshake, the client advertises these capabilities to each language server:
| Capability | Supported Features | Notes |
|---|---|---|
textDocument/synchronization | didOpen, didChange, didSave, didClose | Full content sync (no incremental) |
textDocument/publishDiagnostics | relatedInformation, tagSupport (Unnecessary, Deprecated), codeDescriptionSupport | Passive push from server |
textDocument/hover | markdown, plaintext content formats | Used by LSPTool for type info |
textDocument/definition | linkSupport enabled | Go-to-definition navigation |
textDocument/references | Basic find-references | Used for call-site analysis |
textDocument/documentSymbol | hierarchicalDocumentSymbolSupport | File outline / symbol tree |
textDocument/callHierarchy | Incoming/outgoing call chains | Deep dependency tracing |
general/positionEncodings | utf-16 | Standard LSP encoding |
Diagnostic Severity Mapping
LSP numeric severity values are mapped to human-readable strings for Claude's consumption:
// passiveFeedback.ts — mapLSPSeverity()
function mapLSPSeverity(lspSeverity: number | undefined): string {
switch (lspSeverity) {
case 1: return 'Error'; // DiagnosticSeverity.Error
case 2: return 'Warning'; // DiagnosticSeverity.Warning
case 3: return 'Info'; // DiagnosticSeverity.Information
case 4: return 'Hint'; // DiagnosticSeverity.Hint
default: return 'Error'; // Unknown → treat as Error
}
}
// Diagnostics sorted by severity so Errors are delivered first
// when volume limits truncate results
Code Examples
Example 1: Server Instance with Transient Error Retry
import { createLSPServerInstance } from './LSPServerInstance';
const instance = createLSPServerInstance('typescript', {
command: 'typescript-language-server',
args: ['--stdio'],
extensionToLanguage: { '.ts': 'typescript', '.tsx': 'typescriptreact' },
maxRestarts: 3,
});
await instance.start();
// Requests automatically retry on ContentModified (-32801) errors
// with exponential backoff: 500ms, 1000ms, 2000ms
try {
const result = await instance.sendRequest('textDocument/definition', {
textDocument: { uri: 'file:///project/src/index.ts' },
position: { line: 42, character: 10 },
});
console.log('Definition at:', result);
} catch (error) {
// After MAX_RETRIES_FOR_TRANSIENT_ERRORS (3), throws with full context
console.error(`LSP request failed: ${error.message}`);
}
Example 2: File Synchronization Protocol
import { createLSPServerManager } from './LSPServerManager';
const manager = createLSPServerManager();
await manager.initialize();
// Open a file — auto-routes to the right server via extensionMap
// Sends textDocument/didOpen with languageId from extensionToLanguage
await manager.openFile('src/app.ts', fileContent);
// After editing — sends textDocument/didChange (full content replacement)
// If file wasn't previously opened, auto-promotes to didOpen
await manager.changeFile('src/app.ts', updatedContent);
// After saving — sends textDocument/didSave
await manager.saveFile('src/app.ts');
// Check if file is tracked
console.log(manager.isFileOpen('src/app.ts')); // true
// Close — sends textDocument/didClose and removes from tracking
await manager.closeFile('src/app.ts');
Example 3: Diagnostic Collection with Deduplication
import { registerLSPNotificationHandlers } from './passiveFeedback';
import {
checkForLSPDiagnostics,
registerPendingLSPDiagnostic,
} from './LSPDiagnosticRegistry';
// Register handlers on all running servers
const result = registerLSPNotificationHandlers(manager);
console.log(`Registered on ${result.successCount}/${result.totalServers} servers`);
// Diagnostics arrive asynchronously via textDocument/publishDiagnostics
// and are queued in the registry. To retrieve them:
const diagnostics = checkForLSPDiagnostics();
// Returns deduplicated results:
// - Within-batch dedup (same file + range + message)
// - Cross-turn dedup via LRU cache (won't re-report same error)
// - Sorted by severity: Error < Warning < Info < Hint
// - Capped: 10 per file, 30 total across all files
for (const file of diagnostics) {
console.log(`${file.uri}: ${file.diagnostics.length} issues`);
for (const d of file.diagnostics) {
console.log(` [${d.severity}] L${d.range.start.line}: ${d.message}`);
}
}