Analysis Date: 2026-04-02 Files Analyzed: 7 TypeScript files, ~2,460 LOC Purpose: Complete system architecture and protocol engineering documentation
Anthropic engineered Claude Code's LSP support as a plugin-driven, multi-server, asynchronous diagnostic pipeline. The system spawns independent LSP server processes, communicates via JSON-RPC over stdio, tracks server health/state, and delivers diagnostics to Claude's conversation context without blocking user interaction. The architecture emphasizes graceful degradation (LSP is optional), crash recovery (automatic restart on failure), and deduplication (preventing duplicate diagnostics across conversation turns).
Key Design Principle: Factory function pattern with closures for state encapsulation (no classes), enabling lazy-loading of the expensive vscode-jsonrpc library (~129KB) only when LSP servers are instantiated.
Mechanism: JSON-RPC 2.0 over stdin/stdout with child process spawning
Implementation in LSPClient.ts:88-105:
process = spawn(command, args, {
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...subprocessEnv(), ...options?.env },
cwd: options?.cwd,
windowsHide: true,
})- stdio configuration: Standard three-stream piping (stdin, stdout, stderr)
- Windows consideration:
windowsHide: trueprevents visible console window on Windows - Environment merging: Combines subprocess environment (
subprocessEnv()) with caller-supplied env vars
Message Connection Setup (LSPClient.ts:180-183):
const reader = new StreamMessageReader(process.stdout)
const writer = new StreamMessageWriter(process.stdin)
connection = createMessageConnection(reader, writer)- Uses vscode-jsonrpc library's
StreamMessageReaderandStreamMessageWriter - Handles framing, JSON encoding/decoding, and message ordering
- Protocol tracing enabled via
connection.trace(Trace.Verbose, {...})
Startup Sequence (LSPServerInstance.ts:135-264):
-
Spawn process (
client.start())- Execute LSP server binary with args
- Wait for 'spawn' event before using streams
- Handle spawn failures (ENOENT, permission denied, etc.)
-
Initialize (
client.initialize())- Send
initializerequest with InitializeParams - Receive InitializeResult containing server capabilities
- Send
initializednotification - Set
isInitialized = true
- Send
-
Ready for requests - Server now accepts LSP method calls
Shutdown Sequence (LSPClient.ts:373-445):
await connection.sendRequest('shutdown', {})
await connection.sendNotification('exit', {})- Graceful shutdown: shutdown request, then exit notification
- Fallback cleanup: dispose connection, kill process, clear event listeners
- State reset:
isInitialized = false,capabilities = undefined
Generated in LSPServerInstance.ts:167-237:
const initParams: InitializeParams = {
processId: process.pid,
initializationOptions: config.initializationOptions ?? {},
workspaceFolders: [{
uri: pathToFileURL(workspaceFolder).href,
name: path.basename(workspaceFolder),
}],
rootPath: workspaceFolder, // Deprecated but still needed
rootUri: workspaceUri, // Deprecated but still needed
capabilities: {
workspace: {
configuration: false, // Don't support workspace/configuration
workspaceFolders: false, // Don't support workspace/didChangeWorkspaceFolders
},
textDocument: {
synchronization: { didSave: true, ... },
publishDiagnostics: { relatedInformation: true, ... },
hover: { contentFormat: ['markdown', 'plaintext'] },
definition: { linkSupport: true },
references: {},
documentSymbol: { hierarchicalDocumentSymbolSupport: true },
callHierarchy: {},
},
general: { positionEncodings: ['utf-16'] },
}
}Key Characteristics:
- Client capabilities: Declares supported LSP features to server
- Deprecated fields: Both
rootPath/rootUriincluded for compatibility with older servers - Disable workspace/configuration: Prevents servers from requesting config not implemented
- TextDocument features: Hover, definition, references, document symbols, call hierarchy support
Workspace Folder Setup (LSPServerInstance.ts:164-165):
- Uses
pathToFileURL()to convert filesystem paths to file:// URIs - Handles workspace resolution via
config.workspaceFolder || getCwd()
State Machine (LSPServerInstance.ts:74-78):
stopped → starting → running
running → stopping → stopped
any state → error → starting (on retry)
State Enumeration (LSPServerInstance.ts:113, config schema):
'stopped'- Server not running'starting'- Startup in progress'running'- Ready for requests'stopping'- Shutdown in progress'error'- Crashed or failed
Health Check (LSPServerInstance.ts:338-340):
function isHealthy(): boolean {
return state === 'running' && client.isInitialized
}Transient Error Handling (LSPServerInstance.ts:342-410):
Transient Error Constant (LSPServerInstance.ts:17):
const LSP_ERROR_CONTENT_MODIFIED = -32801Retry Logic:
- Max retries:
MAX_RETRIES_FOR_TRANSIENT_ERRORS = 3 - Base delay:
RETRY_BASE_DELAY_MS = 500ms - Exponential backoff:
delay = 500 * 2^attempt→ 500ms, 1000ms, 2000ms
Implementation (LSPServerInstance.ts:355-410):
for (let attempt = 0; attempt <= MAX_RETRIES_FOR_TRANSIENT_ERRORS; attempt++) {
try {
return await client.sendRequest(method, params)
} catch (error) {
const errorCode = (error as { code?: number }).code
const isContentModifiedError = errorCode === LSP_ERROR_CONTENT_MODIFIED
if (isContentModifiedError && attempt < MAX_RETRIES_FOR_TRANSIENT_ERRORS) {
const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt)
await sleep(delay)
continue
}
break
}
}Crash Recovery (LSPServerInstance.ts:121-125, 142-150):
- Crash callback:
onCrashcallback triggered on non-zero exit codes - Sets state to
'error'and incrementscrashRecoveryCount - Max crash recoveries:
config.maxRestarts ?? 3(default 3) - Prevents unbounded child process spawning on persistent crashes
Manager Type (LSPServerManager.ts:59-420):
Core Data Structures (LSPServerManager.ts:61-64):
const servers: Map<string, LSPServerInstance> = new Map()
const extensionMap: Map<string, string[]> = new Map()
const openedFiles: Map<string, string> = new Map() // URI → server nameFile Extension Mapping (LSPServerManager.ts:88-117):
- Derives file extensions from each server's
extensionToLanguagemapping - Maps extensions (lowercase, e.g., ".ts", ".py") to server names
- Multiple servers can handle same extension (first registered wins)
File Open Tracking (LSPServerManager.ts:270-298):
// Prevent duplicate didOpen notifications
if (openedFiles.get(fileUri) === server.name) {
return // Already opened on this server
}
await server.sendNotification('textDocument/didOpen', {
textDocument: {
uri: fileUri,
languageId: server.config.extensionToLanguage[ext] || 'plaintext',
version: 1,
text: content,
},
})
openedFiles.set(fileUri, server.name)Three-Phase Lifecycle:
-
didOpen (LSPServerManager.ts:289-296)
- First notification for a file
- Includes full document text
- Sets initial version number (1)
- Triggered on file open or first change
-
didChange (LSPServerManager.ts:327-333)
- Subsequent file modifications
- Only sent if file already open on server
- Uses full-text replacement:
contentChanges: [{ text: content }] - Incremental changes not implemented (always full-text sync)
-
didSave (LSPServerManager.ts:354-357)
- Sent after file written to disk
- Triggers server-side diagnostics
- Lightweight notification (no content)
-
didClose (LSPServerManager.ts:377-390)
- Removes file from server tracking
- Cleans up
openedFilesmap - Allows file to be reopened later
Safety Features (LSPServerManager.ts:312-324):
// If file hasn't been opened on this server yet, open it first
// LSP servers require didOpen before didChange
if (openedFiles.get(fileUri) !== server.name) {
return openFile(filePath, content)
}Passive Feedback Handler Registration (passiveFeedback.ts:125-328):
Handler Setup (passiveFeedback.ts:161-278):
serverInstance.onNotification(
'textDocument/publishDiagnostics',
(params: unknown) => {
// 1. Validate params structure
if (!params || typeof params !== 'object' || !('uri' in params) || !('diagnostics' in params)) {
return // Skip invalid notifications
}
// 2. Convert LSP → Claude diagnostic format
const diagnosticFiles = formatDiagnosticsForAttachment(params)
// 3. Register for async delivery
registerPendingLSPDiagnostic({ serverName, files: diagnosticFiles })
},
)Severity Mapping (passiveFeedback.ts:18-35):
function mapLSPSeverity(lspSeverity: number | undefined): 'Error' | 'Warning' | 'Info' | 'Hint' {
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'
}
}Registry State (LSPDiagnosticRegistry.ts:49-56):
const pendingDiagnostics = new Map<string, PendingLSPDiagnostic>()
const deliveredDiagnostics = new LRUCache<string, Set<string>>({
max: MAX_DELIVERED_FILES, // 500 files tracked
})Deduplication Strategy (LSPDiagnosticRegistry.ts:136-184):
- Within-batch deduplication: Same file/diagnostic combination in single delivery
- Cross-turn deduplication: Track delivered diagnostics across conversation turns
- Deduplication key (LSPDiagnosticRegistry.ts:110-124):
function createDiagnosticKey(diag: {...}): string { return jsonStringify({ message: diag.message, severity: diag.severity, range: diag.range, source: diag.source || null, code: diag.code || null, }) }
Volume Limiting (LSPDiagnosticRegistry.ts:257-289):
- Max 10 diagnostics per file
- Max 30 total diagnostics per delivery
- Sorted by severity (Errors first)
- Truncated with logging
Delivery Checkpoint (LSPDiagnosticRegistry.ts:193-338):
export function checkForLSPDiagnostics(): Array<{
serverName: string
files: DiagnosticFile[]
}> {
// Collect all pending diagnostics
// Deduplicate across all files
// Apply volume limiting
// Track in deliveredDiagnostics LRU
// Return deduplicated files ready for attachment
}Spawn Validation (LSPClient.ts:106-131):
// Wait for spawn event before using streams
// This catches ENOENT (command not found), permission errors, etc.
await new Promise<void>((resolve, reject) => {
const onSpawn = (): void => { cleanup(); resolve() }
const onError = (error: Error): void => { cleanup(); reject(error) }
spawnedProcess.once('spawn', onSpawn)
spawnedProcess.once('error', onError)
})Process Error Handlers (LSPClient.ts:143-179):
// Handle crashes during operation (after successful spawn)
process.on('error', error => {
if (!isStopping) {
startFailed = true
startError = error
logError(new Error(`LSP server ${serverName} failed to start: ${error.message}`))
}
})
// Handle non-zero exit codes
process.on('exit', (code, _signal) => {
if (code !== 0 && code !== null && !isStopping) {
isInitialized = false
const crashError = new Error(`LSP server ${serverName} crashed with exit code ${code}`)
onCrash?.(crashError)
}
})
// Handle stdin stream errors (prevent unhandled rejections)
process.stdin.on('error', (error: Error) => {
if (!isStopping) {
logForDebugging(`LSP server ${serverName} stdin error: ${error.message}`)
}
})Error & Close Handlers (LSPClient.ts:187-207):
connection.onError(([error, _message, _code]) => {
if (!isStopping) {
startFailed = true
startError = error
logError(new Error(`LSP server ${serverName} connection error: ${error.message}`))
}
})
connection.onClose(() => {
if (!isStopping) {
isInitialized = false
logForDebugging(`LSP server ${serverName} connection closed`)
}
})Stopping Marker (LSPClient.ts:62, 144, 189, 202, 322, 377):
let isStopping = false // Track intentional shutdown
// During stop()
isStopping = true
// ... perform cleanup ...
isStopping = false // Reset for potential restartPurpose: Distinguish intentional shutdown from crashes, preventing spurious error logging during normal teardown.
Health Check Before Requests (LSPServerInstance.ts:355-363):
async function sendRequest<T>(method: string, params: unknown): Promise<T> {
if (!isHealthy()) {
const error = new Error(
`Cannot send request to LSP server '${name}': server is ${state}` +
`${lastError ? `, last error: ${lastError.message}` : ''}`
)
throw error
}
// ... attempt request with retry logic ...
}Source (config.ts:15-79):
- LSP servers loaded only from plugins (not user/project settings)
- Loaded asynchronously during initialization
- Parallel plugin processing via Promise.all()
- Merged into single configuration object
Configuration Schema (plugins/schemas.ts:708-788):
export const LspServerConfigSchema = z.strictObject({
command: z.string().min(1),
args: z.array(nonEmptyString()).optional(),
extensionToLanguage: z.record(fileExtension(), nonEmptyString()),
transport: z.enum(['stdio', 'socket']).default('stdio'),
env: z.record(z.string(), z.string()).optional(),
initializationOptions: z.unknown().optional(),
settings: z.unknown().optional(),
workspaceFolder: z.string().optional(),
startupTimeout: z.number().int().positive().optional(),
shutdownTimeout: z.number().int().positive().optional(),
restartOnCrash: z.boolean().optional(),
maxRestarts: z.number().int().nonnegative().optional(),
})Core Configuration Type (LSPServerConfig):
command(required): Server executable (no spaces - use args for arguments)args(optional): Command-line argumentsextensionToLanguage(required): Map from file extensions to LSP language IDstransport(default: 'stdio'): Currently only stdio implementedenv(optional): Environment variablesinitializationOptions(optional): Server-specific init optionssettings(optional): Server settings (via workspace/didChangeConfiguration)workspaceFolder(optional): Workspace path (defaults to cwd)startupTimeout(optional): Max initialization wait (ms)shutdownTimeout(optional): Not yet implementedrestartOnCrash(optional): Not yet implementedmaxRestarts(optional, default: 3): Max crash recovery attempts
Scoped Configuration (ScopedLspServerConfig):
- Derived from LspServerConfig with plugin scope prefix
- Server names scoped to avoid collisions (e.g., "plugin-name:typescript-lsp")
let lspManagerInstance: LSPServerManager | undefined
let initializationState: InitializationState = 'not-started'
let initializationError: Error | undefined
let initializationGeneration = 0
let initializationPromise: Promise<void> | undefinedState Transitions (manager.ts:14):
type InitializationState = 'not-started' | 'pending' | 'success' | 'failed'Lazy Initialization Pattern:
- Called during Claude Code startup
- Creates manager synchronously
- Starts async initialization without blocking
- Returns immediately (non-blocking)
Generation Counter (manager.ts:35, 173):
- Prevents stale initializations from updating state
- Incremented on reinit/shutdown
- Current generation checked before state updates
Initialization Promise Tracking (manager.ts:180-207):
initializationPromise = lspManagerInstance
.initialize()
.then(() => {
if (currentGeneration === initializationGeneration) {
initializationState = 'success'
registerLSPNotificationHandlers(lspManagerInstance)
}
})
.catch((error: unknown) => {
if (currentGeneration === initializationGeneration) {
initializationState = 'failed'
initializationError = error as Error
lspManagerInstance = undefined
}
})Dynamic Require (avoiding ESM issues):
const { createLSPClient } = require('./LSPClient.js') as {
createLSPClient: typeof createLSPClientType
}Benefit: Defers loading of vscode-jsonrpc (~129KB) until first LSP server instantiation, not at module import time.
Client Capabilities in Initialize Request (LSPServerInstance.ts:188-236):
Workspace Support:
configuration: false- Don't support workspace/configuration requestsworkspaceFolders: false- Don't support workspace/didChangeWorkspaceFolders
TextDocument Synchronization:
didSave: true- Trigger diagnostics on file savewillSave: false- Don't support pre-save notificationswillSaveWaitUntil: false- No pre-save wait support
TextDocument Features:
hover- Hover information (markdown + plaintext)definition- Go-to-definition with link supportreferences- Find referencesdocumentSymbol- Document outline with hierarchy supportcallHierarchy- Call hierarchy navigationpublishDiagnostics- Diagnostic notifications with related information and tags
General:
positionEncodings: ['utf-16']- UTF-16 position encoding
workspace/configuration- Would require config providerworkspace/didChangeWorkspaceFolders- Would require workspace tracking- Dynamic registration - All capabilities statically declared
- Incremental text document synchronization
textDocument/willSavecallbackstextDocument/willSaveWaitUntilsupport
Validation Steps:
// 1. Validate config has required fields
if (!config.command) {
throw new Error(`Server ${serverName} missing required 'command' field`)
}
if (!config.extensionToLanguage || Object.keys(config.extensionToLanguage).length === 0) {
throw new Error(`Server ${serverName} missing required 'extensionToLanguage' field`)
}
// 2. Map file extensions to server
const fileExtensions = Object.keys(config.extensionToLanguage)
for (const ext of fileExtensions) {
const normalized = ext.toLowerCase()
if (!extensionMap.has(normalized)) extensionMap.set(normalized, [])
extensionMap.get(normalized)!.push(serverName)
}
// 3. Create server instance
const instance = createLSPServerInstance(serverName, config)
servers.set(serverName, instance)
// 4. Register default handlers (workspace/configuration)
instance.onRequest('workspace/configuration', (params) => {
return params.items.map(() => null) // Return null config for each item
})Error Handling: Continues with other servers if one fails (graceful degradation).
Security Model:
- LSP servers run as separate child processes (independent privilege boundary)
- Stdio pipes restrict communication to JSON-RPC only
- No direct file system access (files passed via protocol)
- No access to Claude Code's memory or network
URI Handling (passiveFeedback.ts:47-61):
let uri: string
try {
uri = params.uri.startsWith('file://')
? fileURLToPath(params.uri)
: params.uri
} catch (error) {
// Gracefully fallback to original URI
uri = params.uri
}File Path Normalization (LSPServerManager.ts:274, 318, 356, 381):
const fileUri = pathToFileURL(path.resolve(filePath)).href- Resolves relative paths to absolute paths
- Converts to file:// URIs
- Prevents directory traversal via relative path components
Schema Validation (plugins/schemas.ts:708-788):
- Zod strict object validation
- Command field validated (no spaces)
- extensionToLanguage required and non-empty
- Positive integer timeouts
- Type-safe configuration
Path Traversal Prevention in Plugin (lspPluginIntegration.ts:28-45):
function validatePathWithinPlugin(pluginPath: string, relativePath: string): string | null {
const resolvedPluginPath = resolve(pluginPath)
const resolvedFilePath = resolve(pluginPath, relativePath)
const rel = relative(resolvedPluginPath, resolvedFilePath)
if (rel.startsWith('..') || resolve(rel) === rel) {
return null // Path escapes plugin directory
}
return resolvedFilePath
}Error Isolation (passiveFeedback.ts:249-276, LSPServerManager.ts:270-343):
// Errors in handler don't break notification loop
try {
// Handler logic
} catch (error) {
const err = toError(error)
logError(err)
// Don't re-throw - isolate errors to this server only
}Per-Server Failure Tracking (passiveFeedback.ts:136-137, 232-247):
const diagnosticFailures: Map<string, { count: number; lastError: string }> = new Map()
// Track consecutive failures per server
if (failures.count >= 3) {
logForDebugging(`WARNING: LSP diagnostic handler for ${serverName} has failed...`)
}-
Startup (Claude Code starts)
initializeLspServerManager()called- Creates manager, marks state as 'pending'
- Returns immediately (non-blocking)
-
Async Initialization (background)
getAllLspServers()loads plugin configs- Creates LSPServerInstance objects (no servers started yet)
- Registers notification handlers for all servers
- Sets state to 'success'
-
First Use (file open/hover/etc.)
ensureServerStarted(filePath)called- Gets server for file extension
- Calls
server.start()if not running - Server binary spawned, initialization handshake occurs
- Server now ready for requests
-
Active Use (subsequent requests)
- Requests routed to running servers
- Diagnostics accumulated in registry
- Delivered to conversation as attachments
-
Shutdown Initiated
shutdownLspServerManager()called- Filters servers in 'running' or 'error' state
- Calls
stop()on each server via Promise.allSettled()
-
Per-Server Stop (LSPClient.ts:373-445)
- Send
shutdownrequest - Send
exitnotification - Dispose connection
- Kill process (graceful timeout not implemented)
- Clear event listeners
- Send
-
Final State Reset
- Clear servers, extensionMap, openedFiles
- Clear pendingDiagnostics, deliveredDiagnostics
- Increment generation counter
Initialization Timeout (LSPServerInstance.ts:240-248):
if (config.startupTimeout !== undefined) {
await withTimeout(
initPromise,
config.startupTimeout,
`LSP server '${name}' timed out after ${config.startupTimeout}ms during initialization`,
)
}Timeout Implementation (LSPServerInstance.ts:499-511):
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
let timer: ReturnType<typeof setTimeout>
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout((rej, msg) => rej(new Error(msg)), ms, reject, message)
})
return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer!))
}Timeout Cleanup:
- On timeout, client.stop() called to kill spawned process
- Prevents orphaned child processes
- Clears initialization promise to prevent unhandled rejections
Pre-Connection Registration (LSPClient.ts:337-350, 352-371):
const pendingHandlers: Array<{ method: string; handler: ... }> = []
const pendingRequestHandlers: Array<{ method: string; handler: ... }> = []
onNotification(method: string, handler: ...): void {
if (!connection) {
// Queue handler for application when connection is ready
pendingHandlers.push({ method, handler })
return
}
connection.onNotification(method, handler)
}Handler Application on Connection Ready (LSPClient.ts:228-244):
for (const { method, handler } of pendingHandlers) {
connection.onNotification(method, handler)
}
pendingHandlers.length = 0 // Clear the queueServer-to-Client Requests (LSPServerManager.ts:125-135):
Some LSP servers send requests TO the client (reverse direction):
instance.onRequest('workspace/configuration', (params) => {
logForDebugging(`LSP: Received workspace/configuration request from ${serverName}`)
return params.items.map(() => null)
})Example: TypeScript language server sends workspace/configuration requests even though client declares no support.
DiagnosticFile Structure (LSPDiagnosticRegistry.ts:16):
export type PendingLSPDiagnostic = {
serverName: string
files: DiagnosticFile[]
timestamp: number
attachmentSent: boolean
}Individual Diagnostic Structure (passiveFeedback.ts:63-92):
{
message: string
severity: 'Error' | 'Warning' | 'Info' | 'Hint'
range: {
start: { line: number; character: number }
end: { line: number; character: number }
}
source?: string // Server identifier
code?: string // Diagnostic code
}Flow (LSPDiagnosticRegistry.ts:33-38):
- LSP server sends
textDocument/publishDiagnosticsnotification registerPendingLSPDiagnostic()stores diagnosticcheckForLSPDiagnostics()retrieves pending diagnostics- Deduplication, volume limiting applied
getLSPDiagnosticAttachments()converts to Attachment[]getAttachments()delivers to conversation automatically
Async Pattern:
- Diagnostics registered when received
- Checked on next user query
- Delivered as attachments without blocking
- Prevents interrupting user interaction
| Constant | Value | Location | Purpose |
|---|---|---|---|
LSP_ERROR_CONTENT_MODIFIED |
-32801 | LSPServerInstance.ts:17 | Transient error code for retry logic |
MAX_RETRIES_FOR_TRANSIENT_ERRORS |
3 | LSPServerInstance.ts:22 | Max attempts on content-modified errors |
RETRY_BASE_DELAY_MS |
500 | LSPServerInstance.ts:28 | Initial backoff delay in exponential sequence |
MAX_DIAGNOSTICS_PER_FILE |
10 | LSPDiagnosticRegistry.ts:42 | Diagnostic volume limit per file |
MAX_TOTAL_DIAGNOSTICS |
30 | LSPDiagnosticRegistry.ts:43 | Total diagnostic limit per delivery |
MAX_DELIVERED_FILES |
500 | LSPDiagnosticRegistry.ts:46 | LRU cache size for cross-turn deduplication |
vscode-jsonrpc/node.js:
createMessageConnection()- Creates JSON-RPC connectionStreamMessageReader- Reads JSON-RPC messages from streamStreamMessageWriter- Writes JSON-RPC messages to streamTraceenum - For protocol tracing- ~129KB library size (lazy-loaded)
vscode-languageserver-protocol:
InitializeParams- Server initialization request typeInitializeResult- Server initialization response typeServerCapabilities- Server feature declarationsPublishDiagnosticsParams- Diagnostic notification type
Built-in Node.js:
child_process.spawn()- Process spawningfsandpath- File system operationsurl.pathToFileURL()- URI conversion
logForDebugging(),logError()- Debugging and error loggingerrorMessage(),toError()- Error utilitiessubprocessEnv()- Environment setupsleep()- Delay utilityjsonStringify()- JSON serializationLRUCache- Bounded memory caching
Pervasive Logging (throughout codebase):
logForDebugging(`LSP client started for ${serverName}`)
logForDebugging(`[LSP SERVER ${serverName}] ${output}`)
logForDebugging(`LSP Diagnostics: Deduplication removed ${count} duplicate(s)`)Error Logging:
logError(new Error(`LSP server ${serverName} crashed with exit code ${code}`))Manager Reset (manager.ts:48-53):
export function _resetLspManagerForTesting(): void {
initializationState = 'not-started'
initializationError = undefined
initializationPromise = undefined
initializationGeneration++
}Diagnostic Reset (LSPDiagnosticRegistry.ts:357-363):
export function resetAllLSPDiagnosticState(): void {
pendingDiagnostics.clear()
deliveredDiagnostics.clear()
}Bare Mode Check (manager.ts:147-149):
if (isBareMode()) {
return // --bare / SIMPLE: no LSP
}Purpose: LSP is for editor integration (diagnostics, hover, go-to-def in REPL). Scripted -p calls have no use for it.
LSP Disabled When:
--bareflag usedSIMPLEenvironment variable set- Headless subcommand path (no initialization)
Reinit Function (manager.ts:226-253):
export function reinitializeLspServerManager(): void {
if (initializationState === 'not-started') return
// Shutdown old servers (best-effort)
if (lspManagerInstance) {
void lspManagerInstance.shutdown().catch(...)
}
// Reset state and reinit
lspManagerInstance = undefined
initializationState = 'not-started'
initializationGeneration++
initializeLspServerManager()
}Issue Fix (manager.ts:216-220): Fixes anthropics/claude-code#15521:
loadAllPlugins()memoized and called early in startup- Memoized result cached before marketplaces reconciled
- Results in 0 servers on initial LSP manager init
- Reinit now called on plugin refresh to pick up new servers
Minimum interface for JSON-RPC communication (LSPClient.ts:21-41):
type LSPClient = {
readonly capabilities: ServerCapabilities | undefined
readonly isInitialized: boolean
start(command: string, args: string[], options?: {...}) => Promise<void>
initialize(params: InitializeParams) => Promise<InitializeResult>
sendRequest<TResult>(method: string, params: unknown) => Promise<TResult>
sendNotification(method: string, params: unknown) => Promise<void>
onNotification(method: string, handler: (params: unknown) => void) => void
onRequest<TParams, TResult>(method: string, handler: (params: TParams) => TResult | Promise<TResult>) => void
stop() => Promise<void>
}Per-server lifecycle management (LSPServerInstance.ts:33-65):
type LSPServerInstance = {
readonly name: string
readonly config: ScopedLspServerConfig
readonly state: LspServerState
readonly startTime: Date | undefined
readonly lastError: Error | undefined
readonly restartCount: number
start() => Promise<void>
stop() => Promise<void>
restart() => Promise<void>
isHealthy() => boolean
sendRequest<T>(method: string, params: unknown) => Promise<T>
sendNotification(method: string, params: unknown) => Promise<void>
onNotification(method: string, handler: (params: unknown) => void) => void
onRequest<TParams, TResult>(method: string, handler: (params: TParams) => TResult | Promise<TResult>) => void
}Multi-server orchestration (LSPServerManager.ts:16-43):
type LSPServerManager = {
initialize() => Promise<void>
shutdown() => Promise<void>
getServerForFile(filePath: string) => LSPServerInstance | undefined
ensureServerStarted(filePath: string) => Promise<LSPServerInstance | undefined>
sendRequest<T>(filePath: string, method: string, params: unknown) => Promise<T | undefined>
getAllServers() => Map<string, LSPServerInstance>
openFile(filePath: string, content: string) => Promise<void>
changeFile(filePath: string, content: string) => Promise<void>
saveFile(filePath: string) => Promise<void>
closeFile(filePath: string) => Promise<void>
isFileOpen(filePath: string) => boolean
}-
Factory Functions with Closures (not classes)
- State encapsulation without class overhead
- Clean separation of public API from private state
- Enables lazy requires
-
Lazy Initialization
- Non-blocking startup (LSP init in background)
- Servers start on first use (not during init)
- Generation counter prevents stale state updates
-
Promise-Based Async
- All blocking operations (start, stop, initialize) return Promises
- Proper error propagation
- Timeout handling with cleanup
-
Fail-Safe Error Handling
- Graceful degradation (LSP optional, continue without)
- Per-server error isolation (one server failure doesn't break others)
- Comprehensive logging for diagnostics
-
Deduplication Pipeline
- Within-batch deduplication (same batch)
- Cross-turn deduplication (LRU-tracked across turns)
- Volume limiting (prevent spam)
| Decision | Tradeoff |
|---|---|
| Full-text sync only | Simpler implementation, but uses more bandwidth |
| Stdio-only transport | Eliminates socket implementation complexity, good for local use |
| No workspace/configuration | Simpler, but some servers expect to negotiate config |
| Process-per-server | Better isolation, but more resource usage |
| Flat extension map | First registered server wins (ambiguity), but simple logic |
| LRU diagnostic cache | Bounded memory, but recent diagnostics may be redelivered |
| Single initialization attempt | No retry loop, but consistent behavior |
Blocked in Config (LSPServerInstance.ts:95-104):
if (config.restartOnCrash !== undefined) {
throw new Error(`LSP server '${name}': restartOnCrash is not yet implemented...`)
}
if (config.shutdownTimeout !== undefined) {
throw new Error(`LSP server '${name}': shutdownTimeout is not yet implemented...`)
}Document Sync:
- Only full-text synchronization (not incremental)
- Version numbering basic (always 1)
Socket Transport:
- Defined in schema but not implemented (only stdio works)
File Close Integration:
// NOTE: Currently available but not yet integrated with compact flow.
// TODO: Integrate with compact - call closeFile() when compact removes files from context- No workspace folder change notifications (workspaceFolders: false)
- No configuration requests (workspace/configuration returns null)
- No dynamic capability registration
- No multi-workspace support (single workspace per server)
- No progress reporting ($/progress notifications ignored)
- No diagnostic versioning support
Process Escape: LSP server crashes don't affect Claude Code (separate process)
Command Injection: Command field validated (no spaces), args array prevents shell injection
Path Traversal: File paths converted to URIs, resolved to absolute paths
Malicious Diagnostics: Validated before delivery, serialized safely
Configuration Injection: Schema validation via Zod, strict object mode
- Process Isolation: Separate child process, stdio-only communication
- Input Validation: Schema validation, path normalization, URI conversion
- Error Isolation: Handler errors don't propagate, per-server failure tracking
- Graceful Degradation: LSP optional, one server failure doesn't break others
- Resource Limits: Volume limiting (10/30 diagnostics), LRU cache (500 files), max restarts (3)
| File | LOC | Role |
|---|---|---|
| LSPClient.ts | 447 | JSON-RPC transport layer, process lifecycle, connection management |
| LSPServerInstance.ts | 512 | Single server state machine, health checks, request retry logic |
| LSPServerManager.ts | 420 | Multi-server routing, file extension mapping, document sync orchestration |
| config.ts | 79 | Plugin LSP server configuration loading |
| manager.ts | 290 | Global singleton, async initialization, startup/shutdown orchestration |
| LSPDiagnosticRegistry.ts | 387 | Diagnostic storage, deduplication, volume limiting, cross-turn tracking |
| passiveFeedback.ts | 329 | Notification handler registration, LSP→Claude format conversion, error isolation |
| TOTAL | 2,460 | Complete LSP integration system |
Anthropic engineered Claude Code's LSP support as a robust, plugin-driven, multi-server system with:
- Transport: JSON-RPC 2.0 over stdio (process spawning + stream pipes)
- State Management: Finite state machines per server + singleton manager
- Resilience: Crash recovery (3 retries), transient error retry (exponential backoff), graceful degradation
- Diagnostics: Asynchronous delivery, within/cross-turn deduplication, volume limiting
- Configuration: Plugin-sourced via Zod schema validation
- Isolation: Per-server error containment, process separation
- Initialization: Non-blocking background setup, lazy server startup
The implementation prioritizes reliability and observability over feature completeness, with comprehensive logging and error isolation ensuring that a broken LSP server doesn't crash Claude Code.
End of Report