· 5 min read ·

Two Crates, One Protocol: What Building an LSP Server in Rust Actually Looks Like

Source: lobsters

The Language Server Protocol has a reputation for complexity that the underlying wire format does not deserve. Strip away the editor integrations and the spec’s sheer volume of optional capabilities, and what remains is JSON-RPC 2.0 over a Content-Length framed byte stream, almost always on stdio. Every message your server sends or receives looks like this:

Content-Length: 143\r\n
\r\n
{"jsonrpc":"2.0","id":2,"method":"textDocument/hover","params":{"textDocument":{"uri":"file:///home/user/main.rs"},"position":{"line":0,"character":3}}}

That header, two bytes of CRLF, then a JSON body whose byte length matches the header. That is the entire framing story. You can implement a compliant LSP server that handles hover requests in a few hundred lines once you have that framing in place. This is why the codeinput.com walkthrough on building an LSP server in Rust lands with the feeling of a pleasant surprise rather than a grind.

Rust’s ecosystem for LSP servers is also more developed than many people realize, and it presents a genuine architectural choice that is worth understanding before you start.

The Protocol Lifecycle

Before picking a crate, it helps to know what any server must handle. The LSP 3.17 initialization sequence is a three-step handshake. The client sends an initialize request carrying its ClientCapabilities; the server responds with InitializeResult declaring its own ServerCapabilities; then the client fires an initialized notification to signal readiness. After that exchange, document sync notifications flow (textDocument/didOpen, textDocument/didChange, textDocument/didClose) and request-response pairs handle hover, completion, go-to-definition, and the rest. Shutdown is symmetric: a shutdown request followed by an exit notification.

The most consequential capability declaration is textDocumentSync. Setting this to Full means the client sends the entire file content on every change. Setting it to Incremental means you receive TextDocumentContentChangeEvent arrays describing edits by range. For any server that maintains in-memory state, incremental sync is strongly preferred.

Two Crates, Two Philosophies

Rust has two primary libraries for building LSP servers, and they reflect fundamentally different approaches.

tower-lsp (current version 0.20.0) is a high-level framework built on Tower’s Service trait and the tokio async runtime. You implement the LanguageServer trait, which has an async method for every LSP operation, and the framework handles framing, dispatch, and the JSON-RPC layer entirely. The entry point looks like:

use tower_lsp::{Client, LanguageServer, LspService, Server};
use tower_lsp::lsp_types::*;
use async_trait::async_trait;

#[derive(Debug)]
struct Backend {
    client: Client,
}

#[async_trait]
impl LanguageServer for Backend {
    async fn initialize(&self, _: InitializeParams) -> Result<InitializeResult> {
        Ok(InitializeResult {
            capabilities: ServerCapabilities {
                hover_provider: Some(HoverProviderCapability::Simple(true)),
                text_document_sync: Some(TextDocumentSyncCapability::Kind(
                    TextDocumentSyncKind::INCREMENTAL,
                )),
                ..Default::default()
            },
            server_info: Some(ServerInfo {
                name: "my-lsp".to_string(),
                version: Some("0.1.0".to_string()),
            }),
        })
    }

    async fn initialized(&self, _: InitializedParams) {
        self.client
            .log_message(MessageType::INFO, "server initialized!")
            .await;
    }

    async fn shutdown(&self) -> Result<()> { Ok(()) }

    async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
        let pos = params.text_document_position_params.position;
        Ok(Some(Hover {
            contents: HoverContents::Markup(MarkupContent {
                kind: MarkupKind::Markdown,
                value: format!("Cursor at {}:{}", pos.line, pos.character),
            }),
            range: None,
        }))
    }
}

#[tokio::main]
async fn main() {
    let (service, socket) = LspService::new(|client| Backend { client });
    Server::new(tokio::io::stdin(), tokio::io::stdout(), socket)
        .serve(service)
        .await;
}

All LanguageServer methods take &self, not &mut self, which forces interior mutability (Arc<RwLock<...>>, DashMap) for any server state. That is a deliberate design choice enabling concurrent request handling via tokio task spawning. The Client handle is Arc-cloned, letting you call client.publish_diagnostics(...) or client.show_message(...) from background tasks. Tower’s ServiceBuilder composability is also available, meaning you can wrap the service with timeout layers or custom middleware.

The caveat is that tower-lsp has been in maintenance mode since roughly 2023. No major releases have landed since 0.20. The crate is stable enough for most purposes, but it is not where active framework innovation is happening.

lsp-server (current version 0.7.6) takes the opposite approach. It is the library extracted from rust-analyzer itself, actively maintained by the rust-analyzer team, and it gives you raw Message values over a crossbeam channel. No async runtime. No trait to implement. You write the dispatch loop:

use lsp_server::{Connection, Message, Request, Response};
use lsp_types::{request::GotoDefinition, GotoDefinitionResponse, ServerCapabilities};

fn main() -> anyhow::Result<()> {
    let (connection, io_threads) = Connection::stdio();
    let caps = serde_json::to_value(ServerCapabilities {
        definition_provider: Some(lsp_types::OneOf::Left(true)),
        ..Default::default()
    })?;
    let _init_params = connection.initialize(caps)?;

    for msg in &connection.receiver {
        match msg {
            Message::Request(req) => {
                if connection.handle_shutdown(&req)? { break; }
                if let Ok((id, _params)) = req.extract::<GotoDefinitionResponse>(
                    GotoDefinition::METHOD
                ) {
                    let result = serde_json::to_value(GotoDefinitionResponse::Array(vec![]))?;
                    connection.sender.send(Message::Response(Response {
                        id, result: Some(result), error: None,
                    }))?;
                }
            }
            Message::Notification(_) => {}
            Message::Response(_) => {}
        }
    }
    io_threads.join()?;
    Ok(())
}

This is more verbose, but the control it provides is total. Custom protocol extensions, unusual message ordering, request cancellation semantics, server-initiated requests with tracked response futures; all of these are easier to handle when you own the dispatch loop rather than fitting them into a trait method. This is also what rust-analyzer itself does, combined with a thread pool for expensive analysis work and crossbeam channels to send results back to the main loop.

For anything beyond a toy server, lsp-server is the more defensible foundation. The boilerplate cost is real but bounded.

Rust vs Other Runtime Choices

Node.js has vscode-languageserver-node, Microsoft’s reference TypeScript implementation. It is ergonomic and draft LSP features appear there first, since it is Microsoft’s own codebase. The cost is a startup latency in the 200-500ms range and a Node runtime requirement. For extensions distributed via the VS Code marketplace, that runtime is guaranteed, so it is often the pragmatic choice. Elsewhere it is a real constraint.

Python has pygls, which uses asyncio and decorator-based dispatch. It is well-suited to language servers that need to call into Python ML libraries, which is a genuine and growing use case. The startup overhead and GIL remain factors.

Rust servers start in under 20ms, produce a single statically-linked binary, and carry no runtime dependency. Those properties matter for server distribution outside of a specific editor’s extension ecosystem. They also matter for language servers that run many concurrent analysis tasks, where tokio’s cooperative scheduling and Rust’s ownership model give you finer control than most alternatives.

Servers Worth Studying

The Rust LSP ecosystem already includes several production-grade servers that are worth reading as reference implementations.

taplo is a TOML language server and formatter using tower-lsp. It handles JSON Schema validation, schema-driven hover documentation, and formatting, and also ships a WASM build for browser environments.

nil is a Nix language server using lsp-server directly, with a custom incremental IR. Its source is instructive for seeing how the low-level crate gets used in practice.

harper implements English grammar checking as an LSP server using tower-lsp, runs entirely offline, and also builds to WASM for Obsidian integration.

biome, the JavaScript and TypeScript linter and formatter, ships its own JSON-RPC implementation and uses it to expose an LSP interface alongside its CLI.

Rust-analyzer itself remains the most architecturally sophisticated example: salsa for incremental query memoization, Chalk for type inference, a virtual file system overlay for unsaved buffers, and a cancel-and-retry pattern for long-running analysis.

Starting Point

tower-lsp is the faster path to something working. The LanguageServer trait removes the framing concerns entirely, the Client handle is ergonomic for pushing diagnostics, and for standard LSP capabilities the verbosity is low. For a language server that will see real users and needs custom protocol extensions or careful control over concurrency, migrating toward the lsp-server pattern will pay off.

Either way, the underlying protocol is not the obstacle people fear it is. The types are well-specified, lsp-types 0.97 covers LSP 3.17 comprehensively, and the framing layer is a Content-Length header followed by JSON. Understanding that foundation makes both crates easier to work with, and it makes the occasional need to drop down to raw bytes far less intimidating.

Was this interesting?