Lsp builder
Agent Skills
npx -y skills add woyxiang/skills --skill lsp-builderAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Guides building Language Server Protocol (LSP) servers from scratch based on the LSP 3.17 specification. Use this skill when the user wants to create, debug, or extend an LSP server in any language. Trigger on mentions of "language server", "LSP", "language server protocol", "textDocument", "completion provider", "hover provider", "diagnostics", or any LSP method name. This skill covers the full server lifecycle: base protocol transport, capabilities negotiation, text document synchronization, language features, workspace features, and window features.
SKILL.md
18.3 KB, as published. Nobody here has run it
LSP Builder Skill
A step-by-step guide for building Language Server Protocol servers. Reference: LSP 3.17 specification.
When to Use This Skill
Use whenever a user asks to:
- Build a new language server or LSP implementation
- Add language features (hover, completion, go-to-definition, etc.) to an editor via LSP
- Debug or extend an existing LSP server
- Understand how LSP transport, capabilities, or features work
Architecture Overview
An LSP server is a process that communicates with a client (editor) using JSON-RPC messages over a transport layer. The protocol has three layers:
- Transport — How bytes move between client and server (stdio, sockets, pipes)
- Base Protocol — JSON-RPC message framing with
Content-Lengthheaders - Language Protocol — Semantic requests/responses (hover, completion, diagnostics, etc.)
Phase 1: Base Protocol Transport
Read references/specification.md sections on Base Protocol and Header Part.
The wire format is:
Content-Length: <byte-length-of-json>\r\n
\r\n
<json-rpc-message>
Requirements:
- Header is ASCII-encoded, terminated by
\r\n - Content is UTF-8 JSON-RPC 2.0
Content-Lengthis mandatory;Content-Typedefaults toapplication/vscode-jsonrpc; charset=utf-8
Implementation steps:
- Open stdin/stdout (or socket) for bidirectional communication
- Read headers until
\r\n\r\nis found - Parse
Content-Lengthvalue - Read exactly that many bytes as the JSON content
- Parse JSON to determine message type (request, response, or notification)
- Repeat
Message types:
| Type | Has id? | Has method? | Requires response? |
|---|---|---|---|
| Request | yes | yes | yes |
| Response | yes | no | no |
| Notification | no | yes | no |
Reference: Message Transport (Python)
import json
import sys
def read_message():
"""Read one JSON-RPC message from stdin using Content-Length framing."""
headers = {}
while True:
line = sys.stdin.buffer.readline()
if not line:
return None # EOF
line = line.decode("ascii")
if line == "\r\n":
break
name, value = line.strip().split(": ", 1)
headers[name.lower()] = value
content_length = int(headers["content-length"])
body = sys.stdin.buffer.read(content_length)
return json.loads(body.decode("utf-8"))
def write_message(msg):
"""Write one JSON-RPC message to stdout using Content-Length framing."""
body = json.dumps(msg, separators=(",", ":")).encode("utf-8")
header = f"Content-Length: {len(body)}\r\n\r\n"
sys.stdout.buffer.write(header.encode("ascii"))
sys.stdout.buffer.write(body)
sys.stdout.buffer.flush()
Phase 2: Server Lifecycle
Read references/specification.md sections on Server Lifecycle.
The lifecycle sequence is strictly defined:
Client Server
| |
|--- initialize (request) ----->|
|<-- initialize (response) -----|
|--- initialized (notify) ----->|
| | (server is now ready)
| |
| ... normal operation ... |
| |
|--- shutdown (request) ------->|
|<-- shutdown (response) -------|
|--- exit (notify) ------------>|
| | (server exits)
initialize Request
Method: initialize
- Client sends capabilities and workspace info
- Server responds with its capabilities
- Must not send other requests/notifications before receiving
initialize
InitializeParams (from client):
{
"processId": 12345,
"rootUri": "file:///path/to/workspace",
"capabilities": { ... },
"clientInfo": { "name": "VSCode", "version": "1.74.0" }
}
InitializeResult (server response):
{
"capabilities": {
"textDocumentSync": 1,
"completionProvider": { "triggerCharacters": [".", ":"] },
"hoverProvider": true,
"definitionProvider": true,
"referencesProvider": true,
"documentSymbolProvider": true,
"codeActionProvider": true,
"documentFormattingProvider": true
},
"serverInfo": { "name": "my-lang-server", "version": "0.1.0" }
}
initialized Notification
Method: initialized
- Server can now register capabilities, send
window/showMessage, etc.
shutdown and exit
shutdown: Server returnsnullresult. Must stop processing new requests.exit: Server exits with code 0 (success) or 1 (if shutdown wasn't received).
Reference: Lifecycle Dispatch (Python)
def main():
shutting_down = False
while True:
msg = read_message()
if msg is None:
break
method = msg.get("method")
msg_id = msg.get("id") # Notifications have NO id
params = msg.get("params", {})
if msg_id is None:
# This is a NOTIFICATION (no response required)
if method == "exit":
return 0 if shutting_down else 1
# Other notifications: "initialized", "textDocument/didOpen", etc.
handle_notification(method, params)
continue
# This is a REQUEST (must send a response)
if method == "shutdown":
shutting_down = True
write_message({"jsonrpc": "2.0", "id": msg_id, "result": None})
continue
result = handle_request(method, params)
write_message({"jsonrpc": "2.0", "id": msg_id, "result": result})
Phase 3: Text Document Synchronization
Read references/specification.md sections on Text Document Synchronization.
The server MUST implement all three or none:
textDocument/didOpen— Notification from client: document openedtextDocument/didChange— Notification from client: content changedtextDocument/didClose— Notification from client: document closed
These are notifications, not requests — they have no id. The server must NOT send responses to them.
Configure via textDocumentSync capability:
{
"textDocumentSync": {
"openClose": true,
"change": 2
}
}
change values:
0(None): No sync1(Full): Send entire document content on each change2(Incremental): Send only changed ranges
Core Types
interface Position {
line: uinteger; // 0-indexed
character: uinteger; // 0-indexed UTF-16 offset by default
}
interface Range {
start: Position;
end: Position;
}
interface TextDocumentIdentifier {
uri: DocumentUri; // e.g. "file:///path/to/file.py"
}
interface TextDocumentItem {
uri: DocumentUri;
languageId: string;
version: integer;
text: string;
}
interface VersionedTextDocumentIdentifier extends TextDocumentIdentifier {
version: integer | null;
}
Full Sync (change: 1)
The simplest approach. On each didChange, the client sends the entire document text:
{
"textDocument": { "uri": "file:///foo.py", "version": 2 },
"contentChanges": [{ "text": "entire document content here" }]
}
Server just stores contentChanges[-1]["text"].
Incremental Sync (change: 2)
The client sends only the changed range. The server must apply these edits to its in-memory copy:
{
"textDocument": { "uri": "file:///foo.py", "version": 2 },
"contentChanges": [{
"range": {
"start": { "line": 0, "character": 5 },
"end": { "line": 0, "character": 10 }
},
"rangeLength": 5,
"text": "world"
}]
}
Reference: Incremental Sync Implementation (Python)
class DocumentStore:
def __init__(self):
self._docs = {} # uri -> text
def open(self, uri, text):
self._docs[uri] = text
def close(self, uri):
self._docs.pop(uri, None)
def apply_incremental(self, uri, changes):
text = self._docs.get(uri, "")
for change in changes:
if "range" in change:
text = self._apply_range(text, change)
else:
# Full content replacement
text = change.get("text", text)
self._docs[uri] = text
@staticmethod
def _apply_range(text, change):
lines = text.split("\n")
rng = change["range"]
s_line = rng["start"]["line"]
s_char = rng["start"]["character"]
e_line = rng["end"]["line"]
e_char = rng["end"]["character"]
new_text = change.get("text", "")
before = "\n".join(lines[:s_line])
if s_line > 0:
before += "\n"
prefix = lines[s_line][:s_char] if s_line < len(lines) else ""
suffix = lines[e_line][e_char:] if e_line < len(lines) else ""
after = "\n".join(lines[e_line + 1:]) if e_line + 1 < len(lines) else ""
result = before + prefix + new_text + suffix + after
return result
def get(self, uri):
return self._docs.get(uri)
Position encoding: Lines are 0-indexed. Characters are 0-indexed UTF-16 offsets by default. Since 3.17, you can negotiate UTF-8 or UTF-32 via positionEncoding in capabilities.
Phase 4: Language Features
Each feature requires:
- Server advertises the capability in
initializeresponse - Server implements the handler for the corresponding method
Hover (textDocument/hover)
Return documentation for the symbol at a given position.
Capability: "hoverProvider": true
Request: HoverParams with textDocument and position
Response: Hover with contents (MarkupContent or MarkedString) and optional range
interface Hover {
contents: MarkedString | MarkedString[] | MarkupContent;
range?: Range;
}
interface HoverParams extends TextDocumentPositionParams {
// textDocument: TextDocumentIdentifier
// position: Position
}
Completion (textDocument/completion)
Provide code completions at a cursor position.
Capability:
"completionProvider": {
"triggerCharacters": ["."],
"resolveProvider": true,
"completionItem": { "labelDetailsSupport": true }
}
Request: CompletionParams with textDocument, position, context
Response: CompletionList with items[] and isIncomplete flag, or CompletionItem[]
Important: Always include triggerCharacters when advertising completionProvider — clients use this to know when to automatically request completions.
interface CompletionItem {
label: string;
kind?: CompletionItemKind; // 1=Text, 2=Method, ..., 14=Keyword, 6=Variable, ...
detail?: string;
documentation?: string | MarkupContent;
deprecated?: boolean;
preselect?: boolean;
sortText?: string;
filterText?: string;
insertText?: string;
insertTextFormat?: InsertTextFormat; // 1=PlainText, 2=Snippet
textEdit?: TextEdit | InsertReplaceEdit;
additionalTextEdits?: TextEdit[];
commitCharacters?: string[];
command?: Command;
data?: LSPAny;
}
CompletionItemKind values (common):
| Value | Kind | Value | Kind |
|---|---|---|---|
| 1 | Text | 7 | Interface |
| 2 | Method | 8 | Function |
| 3 | Function | 10 | Module |
| 5 | Class | 13 | Value |
| 6 | Variable | 14 | Keyword |
| 15 | Snippet |
Go to Definition (textDocument/definition)
Capability: "definitionProvider": true
Request: DefinitionParams
Response: Location, Location[], or LocationLink[]
interface Location {
uri: DocumentUri;
range: Range;
}
References (textDocument/references)
Capability: "referencesProvider": true
Request: ReferenceParams with context.includeDeclaration
Response: Location[]
Diagnostics (Push Model)
The server sends diagnostics to the client via textDocument/publishDiagnostics notification. No request is needed — the server pushes whenever diagnostics change.
Notification: PublishDiagnosticsParams with uri, version, and diagnostics[]
interface Diagnostic {
range: Range;
severity?: DiagnosticSeverity; // 1=Error, 2=Warning, 3=Information, 4=Hint
code?: integer | string;
codeDescription?: { href: URI };
source?: string; // e.g. "my-lang-linter"
message: string;
tags?: DiagnosticTag[]; // 1=Unnecessary, 2=Deprecated
relatedInformation?: DiagnosticRelatedInformation[];
data?: LSPAny;
}
Document Symbols (textDocument/documentSymbol)
Capability: "documentSymbolProvider": true
Response: DocumentSymbol[] (hierarchical) or SymbolInformation[] (flat)
interface DocumentSymbol {
name: string;
detail?: string;
kind: SymbolKind; // 5=Class, 12=Function, 13=Variable, 14=Constant, ...
tags?: SymbolTag[];
deprecated?: boolean;
range: Range; // Full range of this symbol (including body)
selectionRange: Range; // Range that should be selected (the name)
children?: DocumentSymbol[];
}
SymbolKind values (common):
| Value | Kind | Value | Kind |
|---|---|---|---|
| 5 | Class | 12 | Function |
| 6 | Method | 13 | Variable |
| 7 | Property | 14 | Constant |
| 8 | Field | 23 | Struct |
| 10 | Enum | 26 | TypeParameter |
| 11 | Interface |
Formatting (textDocument/formatting)
Capability: "documentFormattingProvider": true
Request: DocumentFormattingParams with options (tabSize, insertSpaces, etc.)
Response: TextEdit[]
interface TextEdit {
range: Range;
newText: string;
}
Code Actions (textDocument/codeAction)
Capability:
"codeActionProvider": {
"codeActionKinds": ["quickfix", "refactor"]
}
Request: CodeActionParams with textDocument, range, context.diagnostics
Response: (Command | CodeAction)[]
Other Features (implement as needed)
| Feature | Method | Capability Key |
|---|---|---|
| Signature Help | textDocument/signatureHelp | signatureHelpProvider |
| Rename | textDocument/rename | renameProvider |
| Code Lens | textDocument/codeLens | codeLensProvider (has resolve) |
| Document Highlights | textDocument/documentHighlight | documentHighlightProvider |
| Document Links | textDocument/documentLink | documentLinkProvider (has resolve) |
| Folding Ranges | textDocument/foldingRange | foldingRangeProvider |
| Selection Ranges | textDocument/selectionRange | selectionRangeProvider |
| Semantic Tokens | textDocument/semanticTokens/* | semanticTokensProvider |
| Inlay Hints | textDocument/inlayHint | inlayHintProvider (has resolve) |
| Type Hierarchy | textDocument/typeHierarchy/* | typeHierarchyProvider |
| Call Hierarchy | textDocument/callHierarchy/* | callHierarchyProvider |
Phase 5: Workspace Features
| Feature | Method | Capability Key |
|---|---|---|
| Symbol Search | workspace/symbol | workspaceSymbolProvider |
| Execute Command | workspace/executeCommand | executeCommandProvider |
| Configuration | workspace/configuration | N/A (server requests config) |
| Did Change Config | workspace/didChangeConfiguration | textDocumentSync options |
| File Operations | workspace/didCreateFiles etc. | workspace.fileOperations |
Phase 6: Window Features (Server → Client)
These are notifications the server sends TO the client:
| Feature | Method | Direction |
|---|---|---|
| Show Message | window/showMessage | Server → Client |
| Log Message | window/logMessage | Server → Client |
| Progress | $/progress | Bidirectional |
Error Handling
Use ResponseError with standard error codes:
-32700 Parse Error
-32600 Invalid Request
-32601 Method Not Found
-32602 Invalid Params
-32603 Internal Error
-32002 Server Not Initialized
-32800 Request Cancelled
-32801 Content Modified
-32802 Server Cancelled
-32803 Request Failed
Implementation Checklist
When building a server, work through this order:
- Set up transport (stdio, socket, or pipe)
- Implement header parsing (
Content-Lengthbased message framing) - Implement JSON-RPC message dispatch (request/notification/response routing)
- Implement
initializehandler with capabilities - Implement
initializedhandler - Implement
shutdownandexithandlers - Implement text document sync (
didOpen,didChange,didClose) - Implement at least one language feature (hover is simplest)
- Implement diagnostics (push via
textDocument/publishDiagnostics) - Add more language features as needed
- Implement workspace features if applicable
- Test with an LSP client (VS Code extension, Emacs lsp-mode, Neovim LSP)
Key References
Read references/specification.md for:
- Complete type definitions for every request/response/notification
- Detailed capability structures
- The full list of features with their client/server capability paths
- Registration options for dynamic capabilities
- Work done progress and partial result progress mechanisms
- Notebook document support (3.17)
Common Pitfalls
- Don't send requests before
initialize— Clients will reject them - Don't notify without being asked — Only send
publishDiagnosticsafter document sync - Positions are 0-indexed — Both line and character start at 0
- UTF-16 character offsets by default — Unless you negotiate UTF-8/UTF-32 via
positionEncoding - Every request needs a response — Even if returning
null - Notifications must NOT get responses — They have no
idfield.didOpen,didChange,didClose,initialized,exitare all notifications - Cancellation returns error, not no response — Use
RequestCancellederror code - Always use Content-Length framing — Never read raw lines from stdin; use binary readline + exact byte reads
- Incremental sync needs careful position math — Convert 0-indexed line/character to string offsets before splicing