Getting Started with the Language Server Protocol
26 March 2025
Updated: 23 December 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-Lengthheader 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 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:
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 -gThis 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'))