← Back to DevBytes

How to Integrate LSP with AI Coding Assistants

Introduction to LSP and AI Coding Assistants

The Language Server Protocol (LSP) defines a standard JSON-RPC protocol used between an editor or IDE and a language server that provides language features like auto-completion, go-to-definition, hover, and diagnostics. Originally created by Microsoft for Visual Studio Code, LSP has become the de facto standard for language tooling integration.

AI coding assistants — such as GitHub Copilot, Continue, Tabby, and custom in-house tools — increasingly rely on rich semantic context to produce high-quality suggestions. By integrating LSP with an AI assistant, you give the model access to symbol information, type definitions, references, and diagnostics, dramatically improving the relevance and accuracy of generated code.

Why This Integration Matters

Large language models are powerful, but they operate on text alone. Without semantic context, an AI assistant must guess at types, available methods, and project structure. LSP bridges this gap by providing structured, language-aware metadata that can be injected into prompts or used to filter and rank completions.

How LSP Works at a Glance

An LSP server is a long-running process that communicates with a client over stdio or a socket. The client sends requests, responses, and notifications using JSON-RPC 2.0. The lifecycle typically follows these steps:

For an AI assistant, you act as an LSP client. You spawn the language server, maintain document state, and query it for the context you need.

Setting Up an LSP Client

Let's build a minimal LSP client in Node.js that connects to the TypeScript language server and queries hover information for a given file and position. We will use the vscode-languageserver-node libraries, which provide robust JSON-RPC handling over stdio.

First, install the dependencies:

npm install vscode-languageserver-protocol vscode-languageserver-textdocument vscode-jsonrpc

Next, create a basic client that spawns the TypeScript language server and performs an initialize handshake:

const { spawn } = require('child_process');
const {
  createProtocolConnection,
  StreamMessageReader,
  StreamMessageWriter,
  NotificationType,
} = require('vscode-languageserver-protocol');
const { TextDocument } = require('vscode-languageserver-textdocument');

const serverProcess = spawn('typescript-language-server', ['--stdio'], {
  cwd: process.cwd(),
});

const connection = createProtocolConnection(
  new StreamMessageReader(serverProcess.stdout),
  new StreamMessageWriter(serverProcess.stdin),
  console
);

connection.listen();

async function initializeServer(rootUri) {
  const initResult = await connection.sendRequest('initialize', {
    processId: process.pid,
    rootUri,
    capabilities: {
      textDocument: {
        hover: { dynamicRegistration: false },
        completion: { dynamicRegistration: false },
        synchronization: { didOpen: true, didChange: true, didClose: true },
      },
      workspace: {
        workspaceEdit: { documentChanges: true },
      },
    },
  });

  connection.sendNotification('initialized', {});
  return initResult;
}

module.exports = { connection, initializeServer, serverProcess };

This client spawns the TypeScript language server, performs the initialize handshake, and sends the initialized notification. The connection object can now be used to send document and feature requests.

Feeding LSP Context Into an AI Assistant

The core idea is to translate LSP responses into text that can be appended to the prompt sent to the AI model. Let's build a function that opens a document, queries hover at a cursor position, and returns a formatted context string.

const { connection } = require('./lsp-client');
const { TextDocument } = require('vscode-languageserver-textdocument');
const fs = require('fs');
const path = require('path');
const url = require('url');

function fileToUri(filePath) {
  return url.pathToFileURL(filePath).toString();
}

async function buildContextForPosition(filePath, line, character) {
  const uri = fileToUri(filePath);
  const content = fs.readFileSync(filePath, 'utf-8');

  const textDocument = TextDocument.create(uri, 'typescript', 1, content);

  // Notify the server about the document
  connection.sendNotification('textDocument/didOpen', {
    textDocument: { uri, languageId: 'typescript', version: 1, text: content },
  });

  // Query hover information
  const hover = await connection.sendRequest('textDocument/hover', {
    textDocument: { uri },
    position: { line, character },
  });

  // Query definition
  const definition = await connection.sendRequest('textDocument/definition', {
    textDocument: { uri },
    position: { line, character },
  });

  // Query references
  const references = await connection.sendRequest('textDocument/references', {
    textDocument: { uri },
    position: { line, character },
    context: { includeDeclaration: true },
  });

  let contextParts = [];

  if (hover && hover.contents) {
    const hoverText = typeof hover.contents === 'string'
      ? hover.contents
      : hover.contents.value;
    contextParts.push(`[Hover] ${hoverText}`);
  }

  if (definition) {
    const defs = Array.isArray(definition) ? definition : [definition];
    for (const def of defs) {
      const defContent = extractRangeContent(def.uri, def.range);
      contextParts.push(`[Definition] ${defContent}`);
    }
  }

  if (references) {
    contextParts.push(`[References] Found ${references.length} reference(s).`);
  }

  return contextParts.join('\n\n');
}

function extractRangeContent(uri, range) {
  const filePath = url.fileURLToPath(uri);
  const content = fs.readFileSync(filePath, 'utf-8').split('\n');
  const { start, end } = range;
  return content.slice(start.line, end.line + 1).join('\n');
}

module.exports = { buildContextForPosition };

Now we can use this context builder when constructing prompts for an AI model. Here is an example that combines the LSP context with a user question and sends it to an OpenAI-compatible endpoint:

const { buildContextForPosition } = require('./lsp-context');

async function askAssistant(userQuestion, filePath, line, character) {
  const lspContext = await buildContextForPosition(filePath, line, character);

  const systemPrompt = `You are an expert coding assistant.
Use the provided language server context to ground your answer.
Do not invent APIs that are not present in the context.

Language Server Context:
${lspContext}`;

  const response = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`,
    },
    body: JSON.stringify({
      model: 'gpt-4o',
      messages: [
        { role: 'system', content: systemPrompt },
        { role: 'user', content: userQuestion },
      ],
      temperature: 0.2,
    }),
  });

  const data = await response.json();
  return data.choices[0].message.content;
}

askAssistant(
  'Refactor this function to use async/await and add error handling.',
  './src/userService.ts',
  12,
  5
).then(console.log);

Using Diagnostics for Error-Aware Suggestions

One of the most valuable LSP features for AI assistants is diagnostics. When the language server reports errors or warnings, you can feed them to the model to generate targeted fixes. Diagnostics are pushed from the server via the textDocument/publishDiagnostics notification.

const { connection } = require('./lsp-client');

const diagnosticsMap = new Map();

connection.onNotification('textDocument/publishDiagnostics', (params) => {
  diagnosticsMap.set(params.uri, params.diagnostics);
});

async function getDiagnosticsContext(filePath) {
  const uri = fileToUri(filePath);
  // Allow the server time to process
  await new Promise((resolve) => setTimeout(resolve, 500));

  const diagnostics = diagnosticsMap.get(uri) || [];
  if (diagnostics.length === 0) {
    return '[Diagnostics] No issues found.';
  }

  const lines = fs.readFileSync(filePath, 'utf-8').split('\n');
  const formatted = diagnostics.map((d) => {
    const lineContent = lines[d.range.start.line] || '';
    return `Line ${d.range.start.line + 1}: ${d.severity === 1 ? 'Error' : 'Warning'} - ${d.message}\n  > ${lineContent.trim()}`;
  });

  return `[Diagnostics]\n${formatted.join('\n')}`;
}

You can then include this diagnostics context in the prompt when asking the AI to fix or review code:

async function suggestFix(filePath) {
  const diagnosticsContext = await getDiagnosticsContext(filePath);
  const fileContent = fs.readFileSync(filePath, 'utf-8');

  const prompt = `The following file has these issues reported by the language server:

${diagnosticsContext}

File content:
\`\`\`typescript
${fileContent}
\`\`\`

Propose a corrected version of the file. Explain each change briefly.`;

  // Send prompt to your AI model of choice
  return callModel(prompt);
}

Workspace Symbols for Cross-File Context

For broader questions like "Where is the User model used?", the workspace/symbol request lets you search across the entire project. This is useful for building chat-style assistants that need to explore the codebase.

async function searchWorkspaceSymbols(query) {
  const symbols = await connection.sendRequest('workspace/symbol', { query });
  return symbols.map((s) => ({
    name: s.name,
    kind: s.kind,
    location: s.location,
    containerName: s.containerName,
  }));
}

async function buildSymbolContext(query) {
  const symbols = await searchWorkspaceSymbols(query);
  if (symbols.length === 0) {
    return `[Workspace Symbols] No symbols found for "${query}".`;
  }

  const lines = symbols.slice(0, 20).map((s) => {
    const loc = s.location;
    return `- ${s.name} (kind: ${s.kind}) at ${loc.uri}:${loc.range.start.line + 1}`;
  });

  return `[Workspace Symbols for "${query}"]\n${lines.join('\n')}`;
}

Best Practices

Manage Document State Carefully

The language server maintains an in-memory model of open documents. Always send didOpen before querying, didChange when content updates, and didClose when finished. Failing to synchronize documents leads to stale results and incorrect diagnostics.

Debounce and Cache LSP Queries

LSP requests can be expensive, especially for large workspaces. Cache hover and definition results keyed by file, version, and position. Debounce diagnostics collection so you do not query the server on every keystroke.

const cache = new Map();

function cacheKey(uri, version, line, character) {
  return `${uri}:${version}:${line}:${character}`;
}

async function cachedHover(uri, version, line, character, content) {
  const key = cacheKey(uri, version, line, character);
  if (cache.has(key)) return cache.get(key);

  const result = await connection.sendRequest('textDocument/hover', {
    textDocument: { uri },
    position: { line, character },
  });

  cache.set(key, result);
  return result;
}

Keep Context Concise

AI models have token limits. Do not dump entire LSP responses into the prompt. Extract the most relevant fields — hover text, definition source, a summary of references — and trim aggressively. A good rule of thumb is to keep LSP context under 2,000 tokens.

Handle Server Crashes Gracefully

Language servers can crash or hang. Wrap LSP calls in timeouts and restart logic. Monitor the child process exit event and respawn the server if it dies unexpectedly.

serverProcess.on('exit', (code) => {
  console.error(`Language server exited with code ${code}. Restarting...`);
  // Implement restart logic here
});

function withTimeout(promise, ms) {
  return Promise.race([
    promise,
    new Promise((_, reject) =>
      setTimeout(() => reject(new Error('LSP request timed out')), ms)
    ),
  ]);
}

const hover = await withTimeout(
  connection.sendRequest('textDocument/hover', params),
  5000
);

Respect Server Capabilities

Not all language servers support every feature. During initialization, inspect the returned capabilities object and only send requests the server actually supports. For example, a server may advertise hoverProvider: true but not documentSymbolProvider.

const initResult = await initializeServer(rootUri);
const caps = initResult.capabilities;

if (caps.hoverProvider) {
  // Safe to send hover requests
}
if (caps.workspaceSymbolProvider) {
  // Safe to send workspace/symbol requests
}

Use Semantic Tokens for Richer Context

Some language servers support textDocument/semanticTokens, which classifies every token in a document by type (function, variable, type, etc.). This can help the AI assistant understand which identifiers are types versus values, improving the quality of generated code in statically typed languages.

Conclusion

Integrating LSP with an AI coding assistant transforms a text-based model into a semantically aware pair programmer. By leveraging hover, definition, references, diagnostics, and workspace symbol queries, you provide the model with grounded context that reduces hallucinations and improves code quality. The key is to act as a well-behaved LSP client — synchronizing documents, respecting capabilities, caching results, and keeping prompts concise. With these building blocks in place, you can extend the approach to support multiple languages, richer chat experiences, and automated fix workflows that combine the precision of language tooling with the flexibility of large language models.

— Ad —

Google AdSense will appear here after approval

← Back to all articles