Getting Started with the Language Server Protocol
26 March 2025
Updated: 26 March 2025
Note that the code here serves as a high level example for the development of a language server and is in no means intended to be a production-ready implementation
The Language Server Protocol
The Language Server Protocol (LSP) is a specification for communication between code editing applications and language servers that provide information about the programming language being worked with
The protocol enables language related functionality such as diagnostics or documentation to be implemented once and reused in all editors that support LSP
The specification uses JSON RPC 2.0 for communicating between the editor and language server
Creating a Basic Language Server
Input and Output
A basic language server can be created using standard in and out as the communication mechanism for the server. This is quite easy to get access to using Node.js as we can get it from process
like so:
1const { stdout, stdin } = require("process")
stdout
and stdin
are streams, we can read from the stdin
stream using the data
event, and we can write to stdout
directly
The code editor that’s managing the language server will pass data to it using stdin
, responses need to be sent using stdout
Each message that’s sent/received consists of two parts:
- A headers section containing a
Content-Length
header structured likeContent-Length: 123
- A content section
The header and content sections are separated by \r\n\r\n
The overall structure looks like this (\r\n
shown as well since they are a strictly considered in the message)
1Content-Length: 123\r\n2\r\n3{4 "jsonrpc": "2.0",5 "id": 1,6 "method": "example/method",7 "params": {8 ...9 }10}
There are two types of messages, namely Request
s and Notification
s. Request
messages require a response. The id
of the message is relevant and is sent back with a response, a Response
looks a bit like this:
1Content-Length: 123\r\n2\r\n3{4 "jsonrpc": "2.0",5 "id": 1,6 "result": {7 ...8 }9}
The other type of message is a Notification
. Notification
messages don’t require a Response
. Additionally, they will not have an id
and will look something like this:
1Content-Length: 123\r\n2\r\n3{4 "jsonrpc": "2.0",5 "params": {6 ...7 }8}
Initializing the Project
I’m assuming you’ve got some basic idea of how Node.js works and that you’re familiar with the overall idea of creating a CLI tool with Node.
Since the language server needs a command, we need to do a little thing with npm
to register the server as a command. This can be done by first defining our language server as a bin
. To do this we need a JS file with the language server
In this example, I’m creating a folder with the following two files:
cli.js
- the language serverpackage.json
In the cli.js
you can simply add something like:
1#!/usr/bin/env node2
3console.log("Hello from Language Server")
Next, you’ll need to add the following to a package.json
file:
1{2 "name": "example-lsp",3 "version": "1.0.0",4 "license": "MIT",5 "bin": "./cli.js",6}
The name
and bin
fields are needed so that npm
knows how to call your command after installing it.
Once the above two files are in place, you can install/register your command using:
1npm i -g
This will make the example-lsp
command available and it will automatically run the cli.js
file
Linking to an Editor
The example below is for Helix, on other editors this process is different and is not the focus of this post
Language servers are started by or connected to from the code editing application. I’m using Helix which uses a config file that specifies the available language servers. We can reference the server we’ve created in Helix’s languages.toml
file like so:
1# define the language server2[language-server.example]3command = "example-lsp"4
5# associate a language with the language server6[[language]]7name = "example"8scope = "source.example"9file-types = ["example"]10language-servers = ["example"]
Seeing your editor’s logs may vary so I’m not going to get too into that, but if you’re trying to debug issues with a language server that’s probably a good place to start
Handling Messages
Once we’ve got our editor setup, we should be able to open a file with a .example
extension which will trigger our LSP
When the language server is started it will receive an initialize
event which will look something like this:
1Content-Length: 20112
3{"jsonrpc":"2.0","method":"initialize","params":{"capabilities":{"general":{"positionEncodings":["utf-8","utf-32","utf-16"]},"textDocument":{"codeAction":{"codeActionLiteralSupport":{"codeActionKind":{"valueSet":["","quickfix","refactor","refactor.extract","refactor.inline","refactor.rewrite","source","source.organizeImports"]}},"dataSupport":true,"disabledSupport":true,"isPreferredSupport":true,"resolveSupport":{"properties":["edit","command"]}},"completion":{"completionItem":{"deprecatedSupport":true,"insertReplaceSupport":true,"resolveSupport":{"properties":["documentation","detail","additionalTextEdits"]},"snippetSupport":true,"tagSupport":{"valueSet":[1]}},"completionItemKind":{}},"formatting":{"dynamicRegistration":false},"hover":{"contentFormat":["markdown"]},"inlayHint":{"dynamicRegistration":false},"publishDiagnostics":{"tagSupport":{"valueSet":[1,2]},"versionSupport":true},"rename":{"dynamicRegistration":false,"honorsChangeAnnotations":false,"prepareSupport":true},"signatureHelp":{"signatureInformation":{"activeParameterSupport":true,"documentationFormat":["markdown"],"parameterInformation":{"labelOffsetSupport":true}}}},"window":{"workDoneProgress":true},"workspace":{"applyEdit":true,"configuration":true,"didChangeConfiguration":{"dynamicRegistration":false},"didChangeWatchedFiles":{"dynamicRegistration":true,"relativePatternSupport":false},"executeCommand":{"dynamicRegistration":false},"fileOperations":{"didRename":true,"willRename":true},"inlayHint":{"refreshSupport":false},"symbol":{"dynamicRegistration":false},"workspaceEdit":{"documentChanges":true,"failureHandling":"abort","normalizesLineEndings":false,"resourceOperations":["create","rename","delete"]},"workspaceFolders":true}},"clientInfo":{"name":"helix","version":"25.01.1 (e7ac2fcd)"},"processId":34756,"rootPath":"/example-lsp","rootUri":"file:///example-lsp","workspaceFolders":[{"name":"example-lsp","uri":"file:///example-lsp"}]},"id":0}
We want to try to view and process this message on the language server.The first thing we’re going to run into when trying do this is that we can’t log things anymore since the stdout
is used as a way to send messages to the code editor - so instead, we can output our logs to a file
A little log
function will do that:
1const { appendFileSync } = require("fs")2
3function log(contents) {4 const logMessage = typeof contents === 'string' ? contents : JSON.stringify(contents)5 return appendFileSync('./log', "\n" + logMessage + "\n")6}
So this will output the logs to a file called log
and can be super useful to debug/inspect any issues that come up
Now, to receive a message we can listen to the data
even on stdin
and respond by logging on stdout
The initialize
message expects a response with some basic information about the language server. Reading this message and responding can be done as follows:
1const { stdout, stdin } = require("process")2const { appendFileSync } = require("fs")3
4function log(contents) {5 const logMessage = typeof contents === 'string' ? contents : JSON.stringify(contents)6 return appendFileSync('./log', "\n" + logMessage + "\n")7}8
9// listen to the `data` event on `stdin` returns a `Buffer`10stdin.on('data', (buff) => {11 // convert the buffer into lines12 const message = buff.toString().split('\r\n')13
14 // get the message content15 const content = message[message.length - 1]16
17 // parse the message content into a request18 const request = JSON.parse(content)19
20 // log the request to a file for later use21 log(request)22
23 if (message.method !== 'initialize') {24 // currently we only support the initialize message25 throw new Error("Unsupported message " + message.method)26 }27
28 // respond with a JSON RPC message29 const result = JSON.stringify({30 jsonrpc: "2.0",31 // reference the ID of the request32 id: request.id,33 // the result depends on the type of message being responded to34 result: {35 capabilities: {36 // we can add any functionality we want to support here as per the spec37 },38 serverInfo: {39 name: "example-lsp",40 version: "0.0.1"41 }42 }43 })44
45 // create the Content-Length header46 const length = Buffer.byteLength(result, 'utf-8')47 const header = `Content-Length: ${length}`48
49 // join the header and message into a response50 const response = `${header}\r\n\r\n${result}`51
52 // send the response53 stdout.write(response)54})
Note that the above example assumes that the entire message is received completely at once - there is no actual guarantee of this but it’s retained here for the sake of simplicity
This is the basic flow for building a language server. Listening to events from some input, often stdin/stdout
and responding to it using JSON RPC. The types of messages that can be received along with the expected response or behavior is all outlined in the LSP Specification
Complete Example
A more complete example showing the handling of multiple messages can be seen below:
1{2 "name": "example-lsp",3 "version": "1.0.0",4 "license": "MIT",5 "bin": "./cli.js",6 "devDependencies": {7 "@types/node": "^22.13.13"8 }9}
1#!/usr/bin/env node2// @ts-check3
4const { appendFileSync } = require("fs")5const { stdout, stdin } = require("process")6
7function log(contents) {8 const logMessage = typeof contents === 'string' ? contents : JSON.stringify(contents)9 return appendFileSync('./log', "\n" + logMessage + "\n")10}11
12log("LSP Started")13
14function parseMessage(message) {15 log("Received")16 log(message)17 const parts = message.split('\r\n')18 const body = JSON.parse(parts[parts.length - 1])19
20 return body21}22
23
24function createResponse(message, result, error) {25 return {26 jsonrpc: "2.0",27 id: message.id,28 result,29 error30 }31}32
33const METHOD_NOT_FOUND = -3260134
35function handleMessage(message) {36 const isNotification = message.id === undefined37
38 if (isNotification) {39 // Notifications don't require a response40 return log(`Not responding to notification ${message.method}`)41 }42
43 switch (message.method) {44 case "initialize":45 return createResponse(message, {46 capabilities: {47 codeActionProvider: true,48 executeCommandProvider: {49 "commands": ["_example.doSomethingCool"]50 }51 },52 serverInfo: {53 name: "example-lsp",54 version: "0.0.1"55 }56 })57
58 case "textDocument/codeAction":59 return createResponse(message, [{60 title: "Do something cool",61 kind: "quickfix",62 command: "_example.doSomethingCool"63 }])64
65 default:66 return createResponse(message, undefined, {67 code: METHOD_NOT_FOUND,68 message: `Requested method not found in handler ${message.method}`69 })70 }71
72}73
74function sendResponse(result) {75 const content = JSON.stringify(result)76 const length = Buffer.byteLength(content, 'utf-8')77
78 const response = `Content-Length: ${length}\r\n\r\n${content}`79
80 log("Sending")81 log(response)82 stdout.write(response)83}84
85stdin.on('data', (d) => {86 const message = parseMessage(d.toString())87
88 const response = handleMessage(message)89 if (response) {90 sendResponse(response)91 }92})93
94
95process.on('exit', () => log('LSP process terminated'))