Skip to content

LSP Integration

The Language Server Protocol (LSP) gives coding agents access to the same intelligence that powers IDE features: type information on hover, go-to-definition, find-all-references, workspace symbol search, and real-time diagnostics. For a coding agent, LSP integration means the model can verify its edits compile, discover where symbols are defined before modifying them, and find all callers of a function it plans to refactor.

The hard problems are:

  1. Server lifecycle management — LSP servers are long-running processes per language. The agent must spawn them on demand, feed them the correct workspace root, and shut them down cleanly.
  2. Multi-server coordination — A monorepo with Python backend, TypeScript frontend, and Rust tooling needs three different LSP servers running simultaneously, each watching a different set of files.
  3. Diagnostic timing — After an edit, diagnostics don’t arrive instantly. The agent must wait (but not forever) for the server to reanalyze the file before reporting results.
  4. Context injection — Raw LSP responses (JSON with URIs, ranges, severity codes) must be transformed into a format the LLM can use effectively.

Reference commit: b9050e1d5faf8096eae7a46a9ecc05a86231384b

Aider does not use LSP. There is no LSP client, no server management, and no diagnostic integration anywhere in the codebase.

Instead, Aider uses two alternative approaches for code intelligence:

Aider’s RepoMap (aider/repomap.py) uses tree-sitter to extract definitions and references from source files. Tree-sitter queries (.scm files in aider/queries/) identify name.definition.* and name.reference.* capture groups, which feed into the PageRank-based repo map algorithm. This gives Aider structural awareness of the codebase without needing a running language server.

For languages where tree-sitter queries only provide definitions (like C++), Aider falls back to Pygments lexer tokenization to extract references — a much coarser approach than LSP’s semantic analysis.

Aider’s Linter class (aider/linter.py) runs external lint commands (configurable via --lint-cmd) after edits. These produce diagnostics via stdout/stderr parsing, not LSP. The linter also has a tree-sitter-based fallback that checks for syntax errors by attempting to parse the edited file.

Aider’s design philosophy prioritizes zero-configuration operation. LSP requires language servers to be installed, configured, and maintained — a significant user burden. Tree-sitter, by contrast, ships as a bundled library with pre-compiled grammars. The trade-off is that Aider gets syntactic intelligence (what identifiers exist and where) but not semantic intelligence (type information, cross-module resolution, or compiler diagnostics).


Reference commit: 4ab44e2c5cc54ed47e47a6729dfd8aa5a3dc2476

Codex has no LSP integration. There is no LSP client, no lsp-types crate in the workspace dependencies, and no diagnostic collection mechanism.

The lsp-types crate appears in Cargo.lock as a transitive dependency of starlark_syntax (part of the Buck2/Bazel build infrastructure used internally), but it is never imported or used by any Codex crate.

Codex’s code intelligence is minimal and focused on two areas:

  1. Tree-sitter for syntax highlighting: codex-rs/tui/src/render/highlight.rs uses tree_sitter_highlight to colorize Bash output in the TUI. This is purely visual — no structural analysis.
  2. Tree-sitter for shell command parsing: codex-rs/shell-command/src/bash.rs uses tree_sitter and tree_sitter_bash to parse shell commands for validation and argument extraction.

Neither of these provides language intelligence in the LSP sense. Codex relies entirely on the LLM’s training knowledge for code understanding, with no external validation of edits beyond the sandbox execution model (run the code and see if it works).


Reference commit: 7ed449974864361bad2c1f1405769fd2c2fcdf42

OpenCode has the most comprehensive LSP integration of the three tools, with a full client implementation, 30+ built-in server configurations, and tight integration between diagnostics and the AI’s tool execution loop.

The LSP system lives in packages/opencode/src/lsp/ and consists of three main modules:

ModuleFilePurpose
index.tslsp/index.tsState management, server registry, client initialization
client.tslsp/client.tsJSON-RPC connection, request routing, diagnostic collection
server.tslsp/server.ts30+ built-in server definitions with spawn/root/extension configs

OpenCode’s client declares the following capabilities during initialization (client.ts, line 82):

connection.sendRequest("initialize", {
rootUri: pathToFileURL(input.root).href,
processId: input.server.process.pid,
workspaceFolders: [{ name: "workspace", uri: pathToFileURL(input.root).href }],
initializationOptions: { ...input.server.initialization },
capabilities: {
window: { workDoneProgress: true },
workspace: {
configuration: true,
didChangeWatchedFiles: { dynamicRegistration: true },
},
textDocument: {
synchronization: { didOpen: true, didChange: true },
publishDiagnostics: { versionSupport: true },
},
},
})

The operations exposed to the AI via the LSP tool (tool/lsp.ts + tool/lsp.txt):

  • goToDefinitiontextDocument/definition
  • findReferencestextDocument/references (with includeDeclaration: true)
  • hovertextDocument/hover (type info, documentation)
  • documentSymboltextDocument/documentSymbol (all symbols in a file)
  • workspaceSymbolworkspace/symbol (cross-workspace symbol search)
  • goToImplementationtextDocument/implementation
  • prepareCallHierarchytextDocument/prepareCallHierarchy
  • incomingCallscallHierarchy/incomingCalls
  • outgoingCallscallHierarchy/outgoingCalls

Background notifications handled by the client:

  • textDocument/publishDiagnostics — Collects diagnostics per file
  • textDocument/didOpen / textDocument/didChange — Sent on file touch
  • workspace/didChangeConfiguration — Propagates config to servers
  • workspace/didChangeWatchedFiles — Notifies servers of file changes

When a file is first accessed (read, edited, or explicitly queried), LSP.getClients(file) (index.ts, line 177) triggers lazy initialization:

  1. Extension matching: The file’s extension is matched against all registered servers. Multiple servers can match the same extension (e.g., both TypeScript and ESLint serve .ts files).
  2. Root detection: Each server defines a root() function that walks up the directory tree looking for anchor files (e.g., package.json for TypeScript, Cargo.toml for Rust, go.mod for Go).
  3. Server spawn: The server’s spawn() function launches the LSP process, returning a child process handle and initialization options.
  4. Connection creation (client.ts, line 46):
const connection = createMessageConnection(
new StreamMessageReader(input.server.process.stdout),
new StreamMessageWriter(input.server.process.stdin),
)
  1. Initialize handshake: Send initialize request (45-second timeout), then initialized notification, then workspace/didChangeConfiguration.
  2. Caching: The client is cached per root + serverID combination in the LSP state.

OpenCode ships with 30+ pre-configured LSP servers (server.ts, 2000+ lines). Selected highlights:

Server IDLanguageCommandAuto-Download
typescriptTS/JStypescript-language-server --stdioNo
denoDenodeno lspNo
vueVuevue-language-server --stdioYes (npm)
eslintJS/TS lintingVS Code ESLint serverYes (GitHub)
pyrightPythonpyright-langserver --stdioYes (pip)
goplsGogoplsYes (go install)
rustRustrust-analyzerNo
clangdC/C++clangdYes (GitHub releases)
astroAstroastro-ls --stdioNo
biomeJS/TS/CSSbiome lsp-proxy --stdioNo

Each server definition includes:

  • extensions: File extensions to match (e.g., [".ts", ".tsx", ".js", ".jsx"])
  • root(): Async function to find workspace root (e.g., walk up looking for tsconfig.json or package.json)
  • spawn(root): Launches the server process with correct cwd and env vars
  • initialization: Server-specific init options (e.g., TypeScript SDK path, Python venv path)

Auto-download servers fetch from GitHub releases or package managers when the binary isn’t found locally. This is controlled by the OPENCODE_DISABLE_LSP_DOWNLOAD flag.

Diagnostics flow through a push model with debouncing:

  1. Collection (client.ts, line 52): The LSP server pushes textDocument/publishDiagnostics notifications. The client stores them in a per-file Map<string, Diagnostic[]>.

  2. Event bus: Each diagnostic update publishes a lsp.client.diagnostics event with the file path and server ID.

  3. Post-edit waiting: After a file edit (tool/edit.ts, line 133), OpenCode calls:

await LSP.touchFile(filePath, true) // true = wait for diagnostics

The touchFile function sends textDocument/didOpen or textDocument/didChange to the server, then waits up to 3 seconds (with 150ms debounce) for diagnostics to arrive.

  1. Error filtering and injection (tool/edit.ts, line 135):
const diagnostics = await LSP.diagnostics()
const issues = diagnostics[normalizedFilePath] ?? []
const errors = issues.filter((item) => item.severity === 1) // Error only
if (errors.length > 0) {
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
output += `\n\nLSP errors detected in this file, please fix:\n` +
`<diagnostics file="${filePath}">\n` +
`${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n` +
`</diagnostics>`
}

Only Error severity (code 1) diagnostics are injected. Warnings, info, and hints are ignored. The format is:

LSP errors detected in this file, please fix:
<diagnostics file="src/main.ts">
ERROR [10:5] Property 'foo' does not exist on type 'Bar'
ERROR [15:12] Cannot find name 'baz'
</diagnostics>

This text is appended to the tool output, so the AI sees it as part of the edit result and can immediately fix the errors.

The same pattern applies in the write tool (tool/write.ts, line 56) — any file creation or overwrite also triggers diagnostic collection and feedback.

When reading files (tool/read.ts, line 183), OpenCode touches the file non-blocking:

LSP.touchFile(filepath, false) // false = don't wait

This warms up the LSP server so that subsequent operations (hover, definition lookup) are faster, without blocking the read operation.

Users override or add servers in opencode.json:

{
"lsp": {
"pyright": { "disabled": true },
"custom-python": {
"command": ["custom-python-lsp", "--stdio"],
"extensions": [".py"],
"env": { "PYTHONPATH": "/custom/path" },
"initialization": { "pythonPath": "/usr/bin/python3" }
}
}
}

Setting "lsp": false disables all LSP integration entirely. Individual servers can be disabled by name.

Configuration priority (highest to lowest):

  1. Managed config directory (/etc/opencode on Linux; platform-specific equivalent on macOS/Windows)
  2. Inline config (OPENCODE_CONFIG_CONTENT env var)
  3. .opencode directory configs (.opencode/opencode.json{,c} from project/home and optional OPENCODE_CONFIG_DIR)
  4. Project config (opencode.json{,c} discovered from cwd up to worktree)
  5. Custom config (OPENCODE_CONFIG env var path)
  6. Global config (~/.config/opencode/opencode.json{,c})
  7. Remote config (.well-known/opencode)

OpenCode’s auto-download mechanism (fetching LSP binaries from GitHub releases) is convenient but fragile. Network failures, rate limits, and version mismatches can leave users without language intelligence. The OPENCODE_DISABLE_LSP_DOWNLOAD flag is a necessary escape hatch.

The 3-second wait for diagnostics after edits is a compromise. Fast servers (rust-analyzer, clangd) typically respond within 500ms. Slow servers (jdtls for Java, pyright on large codebases) may not finish analysis in 3 seconds, causing false negatives — the agent thinks there are no errors when the server simply hasn’t finished checking.

OpenCode only injects Error-severity diagnostics into the AI context. This misses important warnings (unused variables, deprecated APIs, potential null dereferences) that could indicate the edit introduced problems. However, including all severities would flood the context with noise — there’s no good middle ground without a per-language severity policy.

Multiple servers can serve the same file extension. For example, .ts files might be served by both typescript and eslint. OpenCode handles this by creating separate clients for each server, but diagnostic deduplication is left to the consumer — the same error could appear twice from different servers.

Each server uses its own root detection heuristic (e.g., TypeScript looks for package.json, Rust looks for Cargo.toml). In monorepos, different servers may detect different roots for the same file, leading to inconsistent behavior. OpenCode’s per-server root() function is correct but can produce surprising results.

The absence of LSP in Aider and Codex means the AI operates without compiler-level feedback. When the model introduces a type error or references a non-existent symbol, it won’t know until the user runs the code. This is a fundamental capability gap — but it also means zero setup burden.

Language servers can crash, hang, or consume excessive memory. OpenCode tracks broken servers in a Set and skips them on future queries, but doesn’t attempt automatic restart. A crashed server means no diagnostics for that language until the session restarts.


Implement LSP as an optional subsystem that can be disabled entirely. The core agent must function without LSP — it’s an enhancement, not a requirement.

pub struct LspManager {
clients: HashMap<(PathBuf, String), LspClient>, // (root, server_id) -> client
servers: Vec<LspServerConfig>, // Registered server configs
broken: HashSet<String>, // Server IDs that have crashed
}
pub struct LspClient {
connection: Connection, // lsp-server or tower-lsp based
diagnostics: DashMap<PathBuf, Vec<Diagnostic>>,
capabilities: ServerCapabilities,
}
pub struct LspServerConfig {
pub id: String,
pub extensions: Vec<String>,
pub command: Vec<String>,
pub root_markers: Vec<String>, // Files to search for when finding root
pub initialization: serde_json::Value,
}
CratePurpose
lsp-typesLSP protocol type definitions
tower-lsp or lsp-serverJSON-RPC transport layer
tokio::processAsync server process management
dashmapConcurrent diagnostic storage

Mirror OpenCode’s pattern but with configurable severity filtering:

pub struct DiagnosticConfig {
pub min_severity: DiagnosticSeverity, // Default: Error
pub max_per_file: usize, // Default: 10
pub wait_timeout: Duration, // Default: 3s
pub debounce: Duration, // Default: 150ms
}

After every file write, call touch_file(path, wait=true). Collect diagnostics at or above min_severity, format them into a <diagnostics> XML block, and append to the tool output.

Ship a built-in registry similar to OpenCode’s but defined in a TOML manifest rather than 2000 lines of TypeScript:

[servers.typescript]
extensions = [".ts", ".tsx", ".js", ".jsx"]
command = ["typescript-language-server", "--stdio"]
root_markers = ["tsconfig.json", "package.json"]
[servers.rust]
extensions = [".rs"]
command = ["rust-analyzer"]
root_markers = ["Cargo.toml"]

Users override via openoxide.toml or environment variables. Auto-download is out of scope for v1 — users must have servers installed.

Expose LSP operations as a tool the model can call explicitly:

pub enum LspOperation {
GoToDefinition { file: PathBuf, line: u32, character: u32 },
FindReferences { file: PathBuf, line: u32, character: u32 },
Hover { file: PathBuf, line: u32, character: u32 },
DocumentSymbols { file: PathBuf },
WorkspaceSymbol { query: String },
}

This keeps the model in control — it decides when to query the language server rather than being bombarded with unsolicited information. Diagnostics after edits are the exception: those are always injected automatically.