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

Language Server Protocol Specification

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:

1
const { 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:

  1. A headers section containing a Content-Length header structured like Content-Length: 123
  2. 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)

1
Content-Length: 123\r\n
2
\r\n
3
{
4
"jsonrpc": "2.0",
5
"id": 1,
6
"method": "example/method",
7
"params": {
8
...
9
}
10
}

There are two types of messages, namely Requests and Notifications. 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:

1
Content-Length: 123\r\n
2
\r\n
3
{
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:

1
Content-Length: 123\r\n
2
\r\n
3
{
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:

  1. cli.js - the language server
  2. package.json

In the cli.js you can simply add something like:

1
#!/usr/bin/env node
2
3
console.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:

1
npm 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 server
2
[language-server.example]
3
command = "example-lsp"
4
5
# associate a language with the language server
6
[[language]]
7
name = "example"
8
scope = "source.example"
9
file-types = ["example"]
10
language-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:

1
Content-Length: 2011
2
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:

1
const { appendFileSync } = require("fs")
2
3
function 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:

1
const { stdout, stdin } = require("process")
2
const { appendFileSync } = require("fs")
3
4
function 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`
10
stdin.on('data', (buff) => {
11
// convert the buffer into lines
12
const message = buff.toString().split('\r\n')
13
14
// get the message content
15
const content = message[message.length - 1]
16
17
// parse the message content into a request
18
const request = JSON.parse(content)
19
20
// log the request to a file for later use
21
log(request)
22
23
if (message.method !== 'initialize') {
24
// currently we only support the initialize message
25
throw new Error("Unsupported message " + message.method)
26
}
27
28
// respond with a JSON RPC message
29
const result = JSON.stringify({
30
jsonrpc: "2.0",
31
// reference the ID of the request
32
id: request.id,
33
// the result depends on the type of message being responded to
34
result: {
35
capabilities: {
36
// we can add any functionality we want to support here as per the spec
37
},
38
serverInfo: {
39
name: "example-lsp",
40
version: "0.0.1"
41
}
42
}
43
})
44
45
// create the Content-Length header
46
const length = Buffer.byteLength(result, 'utf-8')
47
const header = `Content-Length: ${length}`
48
49
// join the header and message into a response
50
const response = `${header}\r\n\r\n${result}`
51
52
// send the response
53
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:

lsp-example/package.json
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
}
lsp-example/cli.js
1
#!/usr/bin/env node
2
// @ts-check
3
4
const { appendFileSync } = require("fs")
5
const { stdout, stdin } = require("process")
6
7
function log(contents) {
8
const logMessage = typeof contents === 'string' ? contents : JSON.stringify(contents)
9
return appendFileSync('./log', "\n" + logMessage + "\n")
10
}
11
12
log("LSP Started")
13
14
function 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 body
21
}
22
23
24
function createResponse(message, result, error) {
25
return {
26
jsonrpc: "2.0",
27
id: message.id,
28
result,
29
error
30
}
31
}
32
33
const METHOD_NOT_FOUND = -32601
34
35
function handleMessage(message) {
36
const isNotification = message.id === undefined
37
38
if (isNotification) {
39
// Notifications don't require a response
40
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
74
function 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
85
stdin.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
95
process.on('exit', () => log('LSP process terminated'))

References