LSP Integration: How Language Server Protocol Enhances AI Coding

A deep dive into Claude Code's LSP integration — providing type information and diagnostics to AI, not IDE features for humans

The Problem

Language Server Protocol (LSP) is the cornerstone of modern IDEs — it provides editors with code completion, go-to-definition, find references, hover information, and more. But Claude Code is not an IDE; it's an AI coding assistant. So why does it need LSP?

Consider this scenario: the AI modifies a TypeScript file, changing a function parameter from string to number. But it doesn't check all the call sites — some callers still pass in a string. Without LSP, the AI has no idea it introduced type errors until the user runs the tsc compiler or sees red squiggly lines in their IDE.

Claude Code's LSP integration isn't about turning the terminal into an IDE. Its core purpose is to give the AI immediate semantic feedback after editing code — type errors, unused variables, missing references — information that helps the AI fix its own mistakes within the same interaction loop.


The Role of LSP in Claude Code

LSP in a Traditional IDE
VS Code / JetBrains
Language Server
Developer
Total ≈ 3 steps (parallel = faster)
LSP in Claude Code
Claude Code
Language Server
DiagnosticRegistry
AI Context
Claude AI
Total ≈ 5 steps (parallel = faster)

Two key differences:

  1. Different consumer — In IDEs, LSP output is for humans to see; in Claude Code, LSP output is for the AI to consume
  2. Different trigger model — IDEs proactively request LSP during user interaction; Claude Code passively receives diagnostics after file edits, and only sends active requests when the AI explicitly invokes LSPTool

LSPServerManager: Multi-Language Server Management

Architecture Overview

src/services/lsp/LSPServerManager.ts:16-43
TypeScript
16export type LSPServerManager = {
17 initialize(): Promise<void>
18 shutdown(): Promise<void>
19 getServerForFile(filePath: string): LSPServerInstance | undefined
20 ensureServerStarted(filePath: string): Promise<LSPServerInstance | undefined>
21 sendRequest<T>(filePath: string, method: string, params: unknown): Promise<T | undefined>
22 getAllServers(): Map<string, LSPServerInstance>
23 openFile(filePath: string, content: string): Promise<void>
24 changeFile(filePath: string, content: string): Promise<void>
25 saveFile(filePath: string): Promise<void>
26 closeFile(filePath: string): Promise<void>
27 isFileOpen(filePath: string): boolean
28}

LSPServerManager manages multiple language server instances and routes requests to the correct server based on file extension. It uses a factory function pattern (rather than classes), encapsulating internal state through closures:

src/services/lsp/LSPServerManager.ts:59-65
TypeScript
59export function createLSPServerManager(): LSPServerManager {
60 const servers: Map<string, LSPServerInstance> = new Map()
61 const extensionMap: Map<string, string[]> = new Map()
62 const openedFiles: Map<string, string> = new Map()
63 // ... private state within the closure
64}

Extension-to-Server Mapping

src/services/lsp/LSPServerManager.ts:88-104
TypeScript
88 for (const [serverName, config] of Object.entries(serverConfigs)) {
89 if (!config.command) {
90 throw new Error(`Server ${serverName} missing required 'command' field`)
91 }
92 if (!config.extensionToLanguage ||
93 Object.keys(config.extensionToLanguage).length === 0) {
94 throw new Error(`Server ${serverName} missing required 'extensionToLanguage'`)
95 }
96
97 const fileExtensions = Object.keys(config.extensionToLanguage)
98 for (const ext of fileExtensions) {
99 const normalized = ext.toLowerCase()
100 if (!extensionMap.has(normalized)) {
101 extensionMap.set(normalized, [])
102 }
103 extensionMap.get(normalized)!.push(serverName)
104 }
105
106 const instance = createLSPServerInstance(serverName, config)
107 servers.set(serverName, instance)
108 }

Each language server configuration declares the file extensions it supports and the corresponding language identifiers. An extension can map to multiple servers (though this is uncommon). Servers are started on first use (lazy initialization).

workspace/configuration Handling

src/services/lsp/LSPServerManager.ts:124-135
TypeScript
124 instance.onRequest(
125 'workspace/configuration',
126 (params: { items: Array<{ section?: string }> }) => {
127 logForDebugging(
128 `LSP: Received workspace/configuration request from ${serverName}`,
129 )
130 return params.items.map(() => null)
131 },
132 )

Some language servers (such as TypeScript) send workspace/configuration requests even when the client declares it doesn't support them. Claude Code returns null for each request item, satisfying the protocol requirement without providing actual configuration.


Global Singleton and Lifecycle

src/services/lsp/manager.ts:14-25
TypeScript
14type InitializationState = 'not-started' | 'pending' | 'success' | 'failed'
15
16let lspManagerInstance: LSPServerManager | undefined
17let initializationState: InitializationState = 'not-started'
18let initializationError: Error | undefined
19let initializationGeneration = 0
20let initializationPromise: Promise<void> | undefined

The LSP manager is a global singleton with four states:

...

Generation Counter

src/services/lsp/manager.ts:145-207
TypeScript
145export function initializeLspServerManager(): void {
146 if (isBareMode()) return
147
148 if (lspManagerInstance !== undefined && initializationState !== 'failed') return
149
150 lspManagerInstance = createLSPServerManager()
151 initializationState = 'pending'
152
153 const currentGeneration = ++initializationGeneration
154
155 initializationPromise = lspManagerInstance
156 .initialize()
157 .then(() => {
158 if (currentGeneration === initializationGeneration) {
159 initializationState = 'success'
160 if (lspManagerInstance) {
161 registerLSPNotificationHandlers(lspManagerInstance)
162 }
163 }
164 })
165 .catch((error: unknown) => {
166 if (currentGeneration === initializationGeneration) {
167 initializationState = 'failed'
168 lspManagerInstance = undefined
169 }
170 })
171}

initializationGeneration is a generation counter that prevents stale initialization Promises from updating state. When reinitializeLspServerManager() is called, the generation increments, so even if an old initialization completes later, it won't affect the new state.

This solved a real bug (issue #15521): loadAllPlugins() was memoized and called early during startup (via getCommands prefetching), when the marketplace hadn't coordinated yet, resulting in an empty plugin list. Once LSP was initialized with an empty list, it never reinitialized. The fix was to call reinitializeLspServerManager() when plugins are refreshed.

Health Check

src/services/lsp/manager.ts:100-110
TypeScript
100export function isLspConnected(): boolean {
101 if (initializationState === 'failed') return false
102 const manager = getLspServerManager()
103 if (!manager) return false
104 const servers = manager.getAllServers()
105 if (servers.size === 0) return false
106 for (const server of servers.values()) {
107 if (server.state !== 'error') return true
108 }
109 return false
110}

isLspConnected() checks whether at least one server is in a non-error state. This function underpins LSPTool.isEnabled() — the LSPTool only appears in the tool list when LSP is available.


LSP Diagnostic Registry: Passive Diagnostic Injection

The most important feature of LSP integration isn't the LSPTool (which the AI uses actively), but passive diagnostic injection — language servers automatically send diagnostics in the background, and the system injects them into the AI's context.

Notification Handling Flow

src/services/lsp/passiveFeedback.ts:125-279
TypeScript
125export function registerLSPNotificationHandlers(
126 manager: LSPServerManager,
127): HandlerRegistrationResult {
128 const servers = manager.getAllServers()
129
130 for (const [serverName, serverInstance] of servers.entries()) {
131 serverInstance.onNotification(
132 'textDocument/publishDiagnostics',
133 (params: unknown) => {
134 // Validate params structure
135 if (!params || typeof params !== 'object' ||
136 !('uri' in params) || !('diagnostics' in params)) {
137 return
138 }
139
140 const diagnosticParams = params as PublishDiagnosticsParams
141
142 // Convert LSP diagnostics to Claude format
143 const diagnosticFiles = formatDiagnosticsForAttachment(diagnosticParams)
144
145 // Register for async delivery
146 registerPendingLSPDiagnostic({
147 serverName,
148 files: diagnosticFiles,
149 })
150 },
151 )
152 }
153}

Every language server has a registered textDocument/publishDiagnostics notification handler. After a file is edited, the language server re-analyzes it and pushes new diagnostic information.

Severity Mapping

src/services/lsp/passiveFeedback.ts:18-35
TypeScript
18function mapLSPSeverity(
19 lspSeverity: number | undefined,
20): 'Error' | 'Warning' | 'Info' | 'Hint' {
21 switch (lspSeverity) {
22 case 1: return 'Error'
23 case 2: return 'Warning'
24 case 3: return 'Info'
25 case 4: return 'Hint'
26 default: return 'Error'
27 }
28}

The LSP protocol uses numbers for severity levels; Claude Code converts them to string labels. The default is Error — when severity is unknown, it's better to overestimate the danger.

DiagnosticRegistry: Deduplication and Rate Limiting

src/services/lsp/LSPDiagnosticRegistry.ts:41-47
TypeScript
41const MAX_DIAGNOSTICS_PER_FILE = 10
42const MAX_TOTAL_DIAGNOSTICS = 30
43const MAX_DELIVERED_FILES = 500
44
45const pendingDiagnostics = new Map<string, PendingLSPDiagnostic>()
46const deliveredDiagnostics = new LRUCache<string, Set<string>>({
47 max: MAX_DELIVERED_FILES,
48})

Three layers of limits prevent diagnostics from flooding the context:

  1. Max 10 per file — Sorted by severity priority (Error > Warning > Info > Hint)
  2. Max 30 total — Global limit
  3. Cross-turn deduplication — Previously delivered diagnostics are not sent again (tracked via an LRU cache for up to 500 files)

The deduplication key is composed of the message, severity, range, source, and code:

src/services/lsp/LSPDiagnosticRegistry.ts:110-124
TypeScript
110function createDiagnosticKey(diag: {
111 message: string
112 severity?: string
113 range?: unknown
114 source?: string
115 code?: unknown
116}): string {
117 return jsonStringify({
118 message: diag.message,
119 severity: diag.severity,
120 range: diag.range,
121 source: diag.source || null,
122 code: diag.code || null,
123 })
124}

Reset on File Edit

src/services/lsp/LSPDiagnosticRegistry.ts:372-379
TypeScript
372export function clearDeliveredDiagnosticsForFile(fileUri: string): void {
373 if (deliveredDiagnostics.has(fileUri)) {
374 logForDebugging(
375 `LSP Diagnostics: Clearing delivered diagnostics for ${fileUri}`,
376 )
377 deliveredDiagnostics.delete(fileUri)
378 }
379}

When a file is edited (triggered by FileWriteTool or FileEditTool), the delivered diagnostics for that file are cleared. This ensures new diagnostics are re-sent even if the content is identical — because they now correspond to the modified code.


LSPTool: Active AI Queries

LSPTool allows the AI to proactively request LSP capabilities, rather than just passively receiving diagnostics.

Supported Operations

src/tools/LSPTool/prompt.ts:3-21
TypeScript
3export const DESCRIPTION = `Interact with Language Server Protocol (LSP) servers...
4
5Supported operations:
6- goToDefinition: Find where a symbol is defined
7- findReferences: Find all references to a symbol
8- hover: Get hover information (documentation, type info)
9- documentSymbol: Get all symbols in a document
10- workspaceSymbol: Search for symbols across the workspace
11- goToImplementation: Find implementations of an interface
12- prepareCallHierarchy: Get call hierarchy item at a position
13- incomingCalls: Find all callers of a function
14- outgoingCalls: Find all callees of a function`

Nine operations cover the core needs of code navigation. Note that incomingCalls and outgoingCalls require a two-step protocol — first prepareCallHierarchy to get a CallHierarchyItem, then use it to request the actual call relationships.

Coordinate Conversion

src/tools/LSPTool/LSPTool.ts:427-513
TypeScript
427function getMethodAndParams(input: Input, absolutePath: string) {
428 const uri = pathToFileURL(absolutePath).href
429 // Convert from 1-based (user-friendly) to 0-based (LSP protocol)
430 const position = {
431 line: input.line - 1,
432 character: input.character - 1,
433 }
434 // ...
435}

The LSP protocol uses 0-based coordinates, but editors and FileReadTool use 1-based coordinates. LSPTool performs the conversion at the boundary, allowing the AI to directly use the line numbers it sees in Read tool output.

Gitignore Filtering

src/tools/LSPTool/LSPTool.ts:556-611
TypeScript
556async function filterGitIgnoredLocations<T extends Location>(
557 locations: T[],
558 cwd: string,
559): Promise<T[]> {
560 const uniquePaths = uniq(uriToPath.values())
561 const BATCH_SIZE = 50
562 for (let i = 0; i < uniquePaths.length; i += BATCH_SIZE) {
563 const batch = uniquePaths.slice(i, i + BATCH_SIZE)
564 const result = await execFileNoThrowWithCwd(
565 'git', ['check-ignore', ...batch],
566 { cwd, timeout: 5_000 }
567 )
568 // ... parse ignored paths
569 }
570 return locations.filter(loc => !ignoredPaths.has(filePath))
571}

LSP servers may return results from node_modules or other gitignored directories. Claude Code uses git check-ignore to batch-filter these results (50 paths per batch), preventing the AI from being distracted by irrelevant references.

File Size Limit

src/tools/LSPTool/LSPTool.ts:53
TypeScript
53const MAX_LSP_FILE_SIZE_BYTES = 10_000_000

Files exceeding 10MB are rejected from LSP analysis. Large files are typically generated code or data files — analyzing them with LSP is both slow and unhelpful.

Deferred Tool Setup

src/tools/LSPTool/LSPTool.ts:137-139
TypeScript
137 shouldDefer: true,
138 isEnabled() {
139 return isLspConnected()
140 },

LSPTool is marked as shouldDefer: true — it doesn't appear in the initial prompt, and the AI needs to load it through ToolSearchTool. The isEnabled() check ensures the tool is only available when at least one language server has connected successfully.


Bridge LSP Sharing

In Bridge mode (Claude Code running as a backend for the VS Code extension), the role of LSP changes. VS Code already has its own language servers, so Claude Code doesn't need to start another set.

src/services/lsp/manager.ts:145-150
TypeScript
145export function initializeLspServerManager(): void {
146 // --bare / SIMPLE: no LSP
147 if (isBareMode()) {
148 return
149 }
150 // ...
151}

In bare mode (scripted -p invocations), LSP is completely disabled — there's no user interaction, so diagnostic feedback isn't needed.

In Bridge mode, diagnostics may be pushed directly from VS Code's LSP client (via the MCP SDK), rather than being managed by Claude Code's own language servers. This avoids the problem of two LSP clients competing for the same language server.


Integration with File Editing Tools

The most valuable aspect of LSP integration is its automatic coordination with file editing tools:

sequenceDiagram
    participant AI as Claude
    participant Edit as FileEditTool
    participant LSP as LSPServerManager
    participant Reg as DiagnosticRegistry
    participant Attach as Attachment System

    AI->>Edit: Edit file.ts
    Edit->>Edit: Write file

    Edit->>LSP: clearDeliveredDiagnostics(file.ts)
    Edit->>LSP: changeFile(file.ts, newContent)
    Edit->>LSP: saveFile(file.ts)

    Note over LSP: TypeScript server re-analyzes

    LSP-->>Reg: publishDiagnostics<br>2 errors, 1 warning

    Note over AI: AI continues with next operation

    Reg-->>Attach: Pending: 3 diagnostics
    Attach-->>AI: [Attachment] TypeScript errors:<br>1. Type 'string' not assignable...<br>2. Property 'foo' does not exist...

    Note over AI: AI sees errors and auto-fixes

This flow is fully automated — the AI doesn't need to proactively call anything to receive diagnostic feedback. FileEditTool notifies the LSP server after writing a file, the server analyzes the changes and pushes diagnostics, and the diagnostics are injected into the AI's next query via the attachment system.


Design Insights

Claude Code's LSP integration embodies several core design principles:

  1. Designed for AI, not for humans — LSP output isn't used to draw red squiggly lines in a UI. It's converted to structured text and injected as attachments into the AI's context

  2. Passive first, active as supplement — Diagnostic injection is automatic (passive), while LSPTool is on-demand (active). Most of the time the AI doesn't need to actively call LSP — error information is delivered automatically

  3. Volume control — 10 per file, 30 total, LRU deduplication — these limits ensure LSP information doesn't crowd out other valuable context

  4. Lazy startup — Language servers start on demand, LSPTool loads lazily. In scenarios that don't need LSP (plain text editing, bash operations), the system doesn't pay the LSP initialization cost

  5. Defensive error handling — Initialization failure returns undefined instead of throwing; generation counters prevent stale callbacks; notification handlers for each server are isolated — any LSP issue won't affect Claude Code's core functionality