Language Server Protocol Skill
language-server-protocol
Language Server Protocol (LSP) - Microsoft's open standard for IDE-language server communication. Use for building language servers, implementing LSP clients, understanding protocol architecture, and integrating code intelligence features.
SKILL.md
Full skill instructions
Language Server Protocol Skill
The Language Server Protocol (LSP) is an open standard that defines the protocol used between an editor or IDE and a language server that provides language features like auto-complete, go to definition, find all references, and more. Think of LSP as "USB-C for code intelligence" - a standardized interface that allows any language server to work with any compatible editor.
Core Value Proposition: Build once, integrate everywhere. A single language server implementation works across VS Code, Neovim, Emacs, Sublime Text, and dozens of other editors without modification.
When to Use This Skill
This skill should be triggered when:
- Building language servers for programming languages or DSLs
- Implementing LSP client support in editors or IDEs
- Understanding LSP protocol architecture and message flow
- Adding code intelligence features (completion, diagnostics, navigation)
- Debugging LSP communication issues
- Integrating existing language servers into tools
- Extending language server capabilities
Protocol Overview
The Problem LSP Solves
Before LSP:
- Each editor implemented language features independently
- Every language needed N integrations for N editors
- M languages × N editors = M×N implementations
After LSP:
- One protocol specification
- M + N implementations needed
- Any server works with any client
The USB-C Analogy
Just as USB-C provides a universal connector:
- LSP Client = Device (editor like VS Code, Neovim)
- LSP Server = Peripheral (language analyzer like rust-analyzer, pyright)
- LSP Protocol = USB-C specification (JSON-RPC over stdio/TCP)
Architecture
Core Components
┌─────────────────────────────────────────────────────────────┐
│ LSP ARCHITECTURE │
└─────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ EDITOR / IDE (Client) │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ LSP CLIENT │ │
│ │ • Sends document events (open, change, save) │ │
│ │ • Requests language features (completion, definition) │ │
│ │ • Displays diagnostics and suggestions │ │
│ └───────────┬────────────────────────────────────────────┘ │
└──────────────┼───────────────────────────────────────────────┘
│ JSON-RPC (stdio / TCP / WebSocket)
▼
┌──────────────────────────────────────────────────────────────┐
│ LANGUAGE SERVER │
│ │
│ • Parses and analyzes source code │
│ • Maintains project model and symbol tables │
│ • Responds to feature requests │
│ • Publishes diagnostics (errors, warnings) │
└──────────────────────────────────────────────────────────────┘
Communication Protocol
LSP uses JSON-RPC 2.0 with a specific message format:
Content-Length: 123\r\n
Content-Type: application/vscode-jsonrpc; charset=utf-8\r\n
\r\n
{"jsonrpc":"2.0","id":1,"method":"textDocument/definition","params":{...}}
Message Types:
| Type | Has ID | Expects Response | Example |
|---|---|---|---|
| Request | Yes | Yes | textDocument/completion |
| Response | Yes (matches request) | N/A | Result or error |
| Notification | No | No | textDocument/didOpen |
Lifecycle
┌─────────────────────────────────────────────────────────────┐
│ LSP LIFECYCLE │
└─────────────────────────────────────────────────────────────┘
1. INITIALIZATION
Client ──initialize──────────► Server
└─ capabilities, rootUri, clientInfo
Client ◄──result────────────── Server
└─ capabilities, serverInfo
Client ──initialized─────────► Server (notification)
2. DOCUMENT SYNCHRONIZATION
Client ──didOpen─────────────► Server (file opened)
Client ──didChange───────────► Server (content changed)
Client ◄──publishDiagnostics── Server (errors/warnings)
Client ──didSave─────────────► Server (file saved)
Client ──didClose────────────► Server (file closed)
3. FEATURE REQUESTS
Client ──completion──────────► Server
Client ◄──completionItems───── Server
Client ──definition──────────► Server
Client ◄──location───────────── Server
4. SHUTDOWN
Client ──shutdown────────────► Server
Client ◄──result (null)─────── Server
Client ──exit────────────────► Server (notification)
Language Features
Navigation Features
| Method | Purpose | Returns |
|---|---|---|
textDocument/definition | Go to symbol definition | Location(s) |
textDocument/declaration | Go to symbol declaration | Location(s) |
textDocument/typeDefinition | Go to type definition | Location(s) |
textDocument/implementation | Go to implementations | Location(s) |
textDocument/references | Find all references | Location[] |
Example Request (Go to Definition):
{
"jsonrpc": "2.0",
"id": 1,
"method": "textDocument/definition",
"params": {
"textDocument": {
"uri": "file:///project/src/main.ts"
},
"position": {
"line": 10,
"character": 15
}
}
}
Example Response:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"uri": "file:///project/src/utils.ts",
"range": {
"start": { "line": 5, "character": 0 },
"end": { "line": 5, "character": 20 }
}
}
}
Completion
Request:
{
"jsonrpc": "2.0",
"id": 2,
"method": "textDocument/completion",
"params": {
"textDocument": { "uri": "file:///project/src/main.ts" },
"position": { "line": 12, "character": 8 },
"context": {
"triggerKind": 1,
"triggerCharacter": "."
}
}
}
Response:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"isIncomplete": false,
"items": [
{
"label": "toString",
"kind": 2,
"detail": "(): string",
"documentation": "Returns a string representation",
"insertText": "toString()"
},
{
"label": "valueOf",
"kind": 2,
"detail": "(): number",
"insertText": "valueOf()"
}
]
}
}
Completion Item Kinds:
| Kind | Value | Kind | Value |
|---|---|---|---|
| Text | 1 | Method | 2 |
| Function | 3 | Constructor | 4 |
| Field | 5 | Variable | 6 |
| Class | 7 | Interface | 8 |
| Module | 9 | Property | 10 |
| Snippet | 15 | Keyword | 14 |
Diagnostics
Servers push diagnostics via notification:
{
"jsonrpc": "2.0",
"method": "textDocument/publishDiagnostics",
"params": {
"uri": "file:///project/src/main.ts",
"version": 3,
"diagnostics": [
{
"range": {
"start": { "line": 10, "character": 0 },
"end": { "line": 10, "character": 10 }
},
"severity": 1,
"code": "TS2322",
"source": "typescript",
"message": "Type 'string' is not assignable to type 'number'",
"relatedInformation": [
{
"location": {
"uri": "file:///project/src/types.ts",
"range": { "start": { "line": 5, "character": 2 }, "end": { "line": 5, "character": 8 } }
},
"message": "The expected type comes from property 'count'"
}
]
}
]
}
}
Diagnostic Severities:
| Value | Severity |
|---|---|
| 1 | Error |
| 2 | Warning |
| 3 | Information |
| 4 | Hint |
Hover Information
// Request
{
"method": "textDocument/hover",
"params": {
"textDocument": { "uri": "file:///project/src/main.ts" },
"position": { "line": 8, "character": 10 }
}
}
// Response
{
"result": {
"contents": {
"kind": "markdown",
"value": "```typescript\nfunction calculateSum(a: number, b: number): number\n```\n\nCalculates the sum of two numbers."
},
"range": {
"start": { "line": 8, "character": 4 },
"end": { "line": 8, "character": 16 }
}
}
}
Code Actions
Request quick fixes and refactorings:
// Request
{
"method": "textDocument/codeAction",
"params": {
"textDocument": { "uri": "file:///project/src/main.ts" },
"range": {
"start": { "line": 10, "character": 0 },
"end": { "line": 10, "character": 20 }
},
"context": {
"diagnostics": [...],
"only": ["quickfix"]
}
}
}
// Response
{
"result": [
{
"title": "Convert to template literal",
"kind": "refactor.rewrite",
"edit": {
"changes": {
"file:///project/src/main.ts": [
{
"range": { "start": { "line": 10, "character": 0 }, "end": { "line": 10, "character": 25 } },
"newText": "`Hello, ${name}!`"
}
]
}
}
}
]
}
Document Symbols
// Request
{
"method": "textDocument/documentSymbol",
"params": {
"textDocument": { "uri": "file:///project/src/main.ts" }
}
}
// Response (hierarchical)
{
"result": [
{
"name": "Calculator",
"kind": 5,
"range": { "start": { "line": 0, "character": 0 }, "end": { "line": 20, "character": 1 } },
"selectionRange": { "start": { "line": 0, "character": 6 }, "end": { "line": 0, "character": 16 } },
"children": [
{
"name": "add",
"kind": 6,
"range": { "start": { "line": 2, "character": 2 }, "end": { "line": 4, "character": 3 } },
"selectionRange": { "start": { "line": 2, "character": 2 }, "end": { "line": 2, "character": 5 } }
}
]
}
]
}
Symbol Kinds:
| Kind | Value | Kind | Value |
|---|---|---|---|
| File | 1 | Module | 2 |
| Namespace | 3 | Package | 4 |
| Class | 5 | Method | 6 |
| Property | 7 | Field | 8 |
| Constructor | 9 | Enum | 10 |
| Interface | 11 | Function | 12 |
| Variable | 13 | Constant | 14 |
Capabilities Negotiation
Client Capabilities (sent in initialize)
{
"capabilities": {
"textDocument": {
"synchronization": {
"dynamicRegistration": true,
"willSave": true,
"willSaveWaitUntil": true,
"didSave": true
},
"completion": {
"completionItem": {
"snippetSupport": true,
"commitCharactersSupport": true,
"documentationFormat": ["markdown", "plaintext"],
"resolveSupport": {
"properties": ["documentation", "detail"]
}
},
"contextSupport": true
},
"hover": {
"contentFormat": ["markdown", "plaintext"]
},
"definition": {
"linkSupport": true
},
"codeAction": {
"codeActionLiteralSupport": {
"codeActionKind": {
"valueSet": ["quickfix", "refactor", "source"]
}
}
}
},
"workspace": {
"workspaceFolders": true,
"configuration": true,
"didChangeConfiguration": {
"dynamicRegistration": true
}
}
}
}
Server Capabilities (returned in initialize response)
{
"capabilities": {
"textDocumentSync": {
"openClose": true,
"change": 2,
"save": { "includeText": false }
},
"completionProvider": {
"triggerCharacters": [".", ":", "<"],
"resolveProvider": true
},
"hoverProvider": true,
"definitionProvider": true,
"referencesProvider": true,
"documentSymbolProvider": true,
"workspaceSymbolProvider": true,
"codeActionProvider": {
"codeActionKinds": ["quickfix", "refactor.extract", "source.organizeImports"]
},
"documentFormattingProvider": true,
"renameProvider": {
"prepareProvider": true
},
"diagnosticProvider": {
"interFileDependencies": true,
"workspaceDiagnostics": true
}
}
}
Text Document Sync Kinds
| Value | Mode | Description |
|---|---|---|
| 0 | None | No synchronization |
| 1 | Full | Full document on every change |
| 2 | Incremental | Only send changes (preferred) |
Building a Language Server
TypeScript (vscode-languageserver)
import {
createConnection,
TextDocuments,
ProposedFeatures,
InitializeParams,
TextDocumentSyncKind,
InitializeResult,
CompletionItem,
CompletionItemKind,
TextDocumentPositionParams,
Diagnostic,
DiagnosticSeverity
} from 'vscode-languageserver/node';
import { TextDocument } from 'vscode-languageserver-textdocument';
// Create connection using all proposed features
const connection = createConnection(ProposedFeatures.all);
// Create document manager
const documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);
connection.onInitialize((params: InitializeParams): InitializeResult => {
return {
capabilities: {
textDocumentSync: TextDocumentSyncKind.Incremental,
completionProvider: {
resolveProvider: true,
triggerCharacters: ['.']
},
hoverProvider: true,
definitionProvider: true,
referencesProvider: true
}
};
});
// Validate documents on change
documents.onDidChangeContent(change => {
validateTextDocument(change.document);
});
async function validateTextDocument(document: TextDocument): Promise<void> {
const diagnostics: Diagnostic[] = [];
const text = document.getText();
// Example: Find TODO comments
const todoPattern = /\bTODO\b/g;
let match;
while ((match = todoPattern.exec(text))) {
diagnostics.push({
severity: DiagnosticSeverity.Information,
range: {
start: document.positionAt(match.index),
end: document.positionAt(match.index + match[0].length)
},
message: 'TODO comment found',
source: 'my-language-server'
});
}
connection.sendDiagnostics({ uri: document.uri, diagnostics });
}
// Provide completions
connection.onCompletion((params: TextDocumentPositionParams): CompletionItem[] => {
return [
{
label: 'console',
kind: CompletionItemKind.Module,
detail: 'Console object',
documentation: 'The console object provides access to debugging console'
},
{
label: 'console.log',
kind: CompletionItemKind.Function,
detail: '(message: any): void',
insertText: 'console.log($1)',
insertTextFormat: 2 // Snippet
}
];
});
// Provide hover information
connection.onHover((params) => {
const document = documents.get(params.textDocument.uri);
if (!document) return null;
// Get word at position and return hover info
return {
contents: {
kind: 'markdown',
value: '**Symbol Info**\n\nDocumentation here'
}
};
});
// Listen for document events
documents.listen(connection);
// Start the connection
connection.listen();
Python (pygls)
from pygls.server import LanguageServer
from lsprotocol import types as lsp
server = LanguageServer("my-language-server", "v1.0")
@server.feature(lsp.INITIALIZE)
def initialize(params: lsp.InitializeParams) -> lsp.InitializeResult:
return lsp.InitializeResult(
capabilities=lsp.ServerCapabilities(
text_document_sync=lsp.TextDocumentSyncOptions(
open_close=True,
change=lsp.TextDocumentSyncKind.Incremental,
),
completion_provider=lsp.CompletionOptions(
trigger_characters=["."],
resolve_provider=True,
),
hover_provider=True,
definition_provider=True,
)
)
@server.feature(lsp.TEXT_DOCUMENT_DID_OPEN)
def did_open(params: lsp.DidOpenTextDocumentParams):
"""Handle document open."""
validate_document(params.text_document.uri)
@server.feature(lsp.TEXT_DOCUMENT_DID_CHANGE)
def did_change(params: lsp.DidChangeTextDocumentParams):
"""Handle document changes."""
validate_document(params.text_document.uri)
def validate_document(uri: str):
"""Validate document and publish diagnostics."""
document = server.workspace.get_text_document(uri)
diagnostics = []
# Example: Find syntax issues
for i, line in enumerate(document.lines):
if "TODO" in line:
diagnostics.append(lsp.Diagnostic(
range=lsp.Range(
start=lsp.Position(line=i, character=line.index("TODO")),
end=lsp.Position(line=i, character=line.index("TODO") + 4),
),
message="TODO comment found",
severity=lsp.DiagnosticSeverity.Information,
source="my-language-server",
))
server.publish_diagnostics(uri, diagnostics)
@server.feature(lsp.TEXT_DOCUMENT_COMPLETION)
def completions(params: lsp.CompletionParams) -> lsp.CompletionList:
"""Provide completion items."""
return lsp.CompletionList(
is_incomplete=False,
items=[
lsp.CompletionItem(
label="print",
kind=lsp.CompletionItemKind.Function,
detail="print(*args, **kwargs)",
documentation="Print to stdout",
),
lsp.CompletionItem(
label="len",
kind=lsp.CompletionItemKind.Function,
detail="len(obj) -> int",
documentation="Return the length of an object",
),
],
)
@server.feature(lsp.TEXT_DOCUMENT_HOVER)
def hover(params: lsp.HoverParams) -> lsp.Hover | None:
"""Provide hover information."""
document = server.workspace.get_text_document(params.text_document.uri)
# Get word at position and return info
return lsp.Hover(
contents=lsp.MarkupContent(
kind=lsp.MarkupKind.Markdown,
value="**Symbol Info**\n\nDocumentation here",
)
)
if __name__ == "__main__":
server.start_io()
Building an LSP Client
Node.js Client Example
import * as cp from 'child_process';
import * as rpc from 'vscode-jsonrpc/node';
// Spawn the language server
const serverProcess = cp.spawn('node', ['path/to/server.js']);
// Create JSON-RPC connection
const connection = rpc.createMessageConnection(
new rpc.StreamMessageReader(serverProcess.stdout),
new rpc.StreamMessageWriter(serverProcess.stdin)
);
// Listen for notifications from server
connection.onNotification('textDocument/publishDiagnostics', (params) => {
console.log('Diagnostics:', params.diagnostics);
});
// Start connection
connection.listen();
// Send initialize request
const initResult = await connection.sendRequest('initialize', {
processId: process.pid,
rootUri: 'file:///path/to/workspace',
capabilities: {
textDocument: {
completion: { completionItem: { snippetSupport: true } },
hover: { contentFormat: ['markdown'] }
}
}
});
console.log('Server capabilities:', initResult.capabilities);
// Send initialized notification
connection.sendNotification('initialized', {});
// Open a document
connection.sendNotification('textDocument/didOpen', {
textDocument: {
uri: 'file:///path/to/file.ts',
languageId: 'typescript',
version: 1,
text: 'const x = 1;\nconsole.log(x);'
}
});
// Request completion
const completions = await connection.sendRequest('textDocument/completion', {
textDocument: { uri: 'file:///path/to/file.ts' },
position: { line: 1, character: 8 }
});
console.log('Completions:', completions);
// Shutdown
await connection.sendRequest('shutdown');
connection.sendNotification('exit');
SDK Reference
Official and Popular SDKs
| Language | SDK | Repository |
|---|---|---|
| TypeScript | vscode-languageserver | microsoft/vscode-languageserver-node |
| Python | pygls | openlawlibrary/pygls |
| Java | LSP4J | eclipse/lsp4j |
| Rust | tower-lsp | tower-rs/tower-lsp |
| C# | OmniSharp | OmniSharp/csharp-language-server-protocol |
| Go | go-lsp | sourcegraph/go-lsp |
| Haskell | lsp | haskell/lsp |
Popular Language Servers
| Language | Server | Notes |
|---|---|---|
| TypeScript/JavaScript | typescript-language-server | Uses tsserver |
| Python | pyright, pylsp | Static typing / general |
| Rust | rust-analyzer | Official Rust analyzer |
| Go | gopls | Official Go team |
| C/C++ | clangd | LLVM-based |
| Java | Eclipse JDT LS | Used by VS Code Java |
| C# | OmniSharp | .NET ecosystem |
Debugging LSP
Enable Tracing
Most clients support logging LSP messages:
VS Code (settings.json):
{
"myExtension.trace.server": "verbose"
}
Neovim (Lua):
vim.lsp.set_log_level("debug")
-- Logs at: ~/.local/state/nvim/lsp.log
Manual Testing with JSON-RPC
# Start server and send messages manually
echo 'Content-Length: 108\r\n\r\n{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"processId":null,"rootUri":null,"capabilities":{}}}' | node server.js
Common Issues
Server doesn't start:
- Check executable path and permissions
- Verify runtime (Node.js, Python) is installed
- Check stderr for error messages
No completions:
- Verify
completionProvidercapability is advertised - Check trigger characters match
- Ensure document is open (
didOpensent)
Diagnostics not showing:
- Check
textDocumentSynccapability - Verify
publishDiagnosticsnotifications are being sent - Check diagnostic severity levels
Best Practices
For Server Developers
- Use incremental sync - Full sync is expensive for large files
- Debounce validation - Don't validate on every keystroke
- Support cancellation - Long operations should check
$/cancelRequest - Provide resolve - Return minimal completion items, resolve on demand
- Include code actions - Quick fixes improve user experience
- Report progress - Use
$/progressfor long operations
For Client Developers
- Cache capabilities - Don't re-check server capabilities repeatedly
- Batch requests - Combine related requests when possible
- Handle partial results - Some servers support streaming results
- Implement timeout - Don't wait forever for responses
- Support dynamic registration - Allow servers to register/unregister capabilities
Performance Tips
// Debounce document changes
let validationTimeout: NodeJS.Timeout;
documents.onDidChangeContent(change => {
clearTimeout(validationTimeout);
validationTimeout = setTimeout(() => {
validateDocument(change.document);
}, 500);
});
// Support cancellation
connection.onDefinition(async (params, token) => {
// Check cancellation periodically
if (token.isCancellationRequested) {
return null;
}
const result = await findDefinition(params);
if (token.isCancellationRequested) {
return null;
}
return result;
});
LSP 3.17 Features
Inlay Hints
Display inline parameter names, type annotations:
{
"method": "textDocument/inlayHint",
"params": {
"textDocument": { "uri": "file:///project/src/main.ts" },
"range": { "start": { "line": 0, "character": 0 }, "end": { "line": 50, "character": 0 } }
}
}
// Response
{
"result": [
{
"position": { "line": 10, "character": 15 },
"label": ": number",
"kind": 1,
"paddingLeft": true
}
]
}
Type Hierarchy
Navigate type relationships:
// Prepare
{ "method": "textDocument/prepareTypeHierarchy", "params": { "textDocument": {...}, "position": {...} } }
// Get supertypes
{ "method": "typeHierarchy/supertypes", "params": { "item": {...} } }
// Get subtypes
{ "method": "typeHierarchy/subtypes", "params": { "item": {...} } }
Diagnostic Pull Model
Client-initiated diagnostics (alternative to push):
// Request diagnostics for a document
{ "method": "textDocument/diagnostic", "params": { "textDocument": { "uri": "..." } } }
// Request workspace-wide diagnostics
{ "method": "workspace/diagnostic", "params": { "previousResultIds": [...] } }
Development Tools
LSP DevTools
LSP DevTools is a collection of CLI utilities for inspecting and visualizing language server interactions. Essential for debugging protocol issues.
Installation:
pipx install lsp-devtools
Architecture: The LSP Agent acts as a proxy between client and server, forwarding messages while copying them to a monitoring application.
┌────────────────┐ ┌─────────────┐ ┌─────────────────┐
│ Language Client│◄────►│ LSP Agent │◄────►│ Language Server │
└────────────────┘ └──────┬──────┘ └─────────────────┘
│
▼ TCP (localhost:8765)
┌──────────────┐
│ lsp-devtools │
│ record / │
│ inspect │
└──────────────┘
Agent Command
Wrap your language server to enable inspection:
# Basic usage - wrap server command
lsp-devtools agent -- <server-cmd>
# Custom host/port
lsp-devtools agent --host 127.0.0.1 --port 1234 -- python -m my_server
# Example: wrap esbonio server
lsp-devtools agent -- esbonio
Neovim Configuration:
-- In nvim-lspconfig setup
require('lspconfig').esbonio.setup {
cmd = { "lsp-devtools", "agent", "--", "esbonio" }
}
VS Code Configuration:
{
"myLanguage.server.path": "lsp-devtools",
"myLanguage.server.args": ["agent", "--", "my-server"]
}
Record Command
Capture LSP sessions to various destinations:
# Record to console (pretty-printed JSON)
lsp-devtools record
# Record to file (JSON-RPC, one message per line)
lsp-devtools record --to-file session.jsonl
# Record to SQLite database (for analysis)
lsp-devtools record --to-sqlite session.db
# Save console output as HTML/SVG
lsp-devtools record --save-output session.html
Filtering Options:
# Filter by source
lsp-devtools record --message-source client
lsp-devtools record --message-source server
# Filter by message type
lsp-devtools record --include-message-type request
lsp-devtools record --include-message-type notification
# Filter by method
lsp-devtools record --include-method textDocument/completion
lsp-devtools record --exclude-method textDocument/didChange
# Custom format
lsp-devtools record -f "{message.method}: {message.params.position.line}"
Inspect Command
Interactive TUI for real-time LSP message inspection:
# Launch inspector
lsp-devtools inspect
# Custom port
lsp-devtools inspect --port 1234
Features:
- Browse all messages between client and server
- Hierarchical capability display (30+ capability types)
- Real-time message flow visualization
- Detailed message content inspection
pytest-lsp
End-to-end testing framework for language servers, built on pygls.
Installation:
pip install pytest-lsp
Key Features:
- Run language servers in subprocesses
- Communicate via stdio (mimics real clients)
- Language-agnostic (test servers in any language)
- Async test support
Basic Test Setup
import pytest
import pytest_lsp
from pytest_lsp import ClientServerConfig, LanguageClient
from lsprotocol.types import (
InitializeParams,
CompletionParams,
TextDocumentIdentifier,
Position,
)
@pytest_lsp.fixture(
config=ClientServerConfig(
server_command=["python", "-m", "my_server"],
),
)
async def client(lsp_client: LanguageClient):
# Setup: Initialize the LSP session
await lsp_client.initialize_session(
InitializeParams(
capabilities={},
root_uri="file:///workspace",
)
)
yield
# Teardown: Shutdown gracefully
await lsp_client.shutdown_session()
@pytest.mark.asyncio
async def test_completions(client: LanguageClient):
"""Test that completion returns expected items."""
result = await client.text_document_completion_async(
CompletionParams(
text_document=TextDocumentIdentifier(uri="file:///test.py"),
position=Position(line=0, character=0),
)
)
labels = [item.label for item in result.items]
assert "hello" in labels
assert "world" in labels
Parameterized Client Testing
Test against multiple client configurations:
@pytest.fixture(params=["neovim", "vscode", "emacs"])
def client_name(request):
return request.param
@pytest_lsp.fixture(
config=ClientServerConfig(
server_command=["python", "-m", "my_server"],
),
)
async def client(lsp_client: LanguageClient, client_name: str):
# Get capabilities for specific client
capabilities = client_capabilities(client_name)
await lsp_client.initialize_session(
InitializeParams(
capabilities=capabilities,
client_info={"name": client_name},
)
)
yield
await lsp_client.shutdown_session()
Testing Diagnostics
@pytest.mark.asyncio
async def test_diagnostics(client: LanguageClient):
"""Test diagnostic publishing."""
# Open a document with errors
client.text_document_did_open(
TextDocumentItem(
uri="file:///test.py",
language_id="python",
version=1,
text="def foo(\n invalid syntax",
)
)
# Wait for diagnostics
await client.wait_for_notification("textDocument/publishDiagnostics")
# Check diagnostics were received
diagnostics = client.diagnostics["file:///test.py"]
assert len(diagnostics) > 0
assert diagnostics[0].severity == DiagnosticSeverity.Error
Common Pitfall
Servers must explicitly start I/O handling:
# In your server's __main__.py
if __name__ == "__main__":
server = MyLanguageServer()
server.start_io() # Don't forget this!
Monaco Editor Integration
When building browser-based LSP clients with Monaco Editor, use monaco-languageserver-types for bidirectional type conversion.
Installation:
npm install monaco-languageserver-types
Key Concept: Monaco Editor and LSP use different type representations. This library provides from* and to* functions for seamless conversion:
from*- Convert Monaco types → LSP typesto*- Convert LSP types → Monaco types
Type Conversion Examples
Position & Range:
import { fromRange, toRange, fromPosition, toPosition } from 'monaco-languageserver-types';
// Monaco uses 1-based lines, LSP uses 0-based
const monacoRange = {
startLineNumber: 1,
startColumn: 2,
endLineNumber: 3,
endColumn: 4
};
// Convert to LSP format
const lspRange = fromRange(monacoRange);
// { start: { line: 0, character: 1 }, end: { line: 2, character: 3 } }
// Convert back to Monaco format
const backToMonaco = toRange(lspRange);
// { startLineNumber: 1, startColumn: 2, endLineNumber: 3, endColumn: 4 }
Diagnostics:
import { fromMarkerData, toMarkerData, fromMarkerSeverity } from 'monaco-languageserver-types';
// Convert Monaco marker to LSP diagnostic
const monacoMarker = {
severity: monaco.MarkerSeverity.Error,
message: "Unexpected token",
startLineNumber: 5,
startColumn: 10,
endLineNumber: 5,
endColumn: 15
};
const lspDiagnostic = fromMarkerData(monacoMarker);
// Ready to send to language server
// Convert LSP diagnostic to Monaco marker
const marker = toMarkerData(lspDiagnostic);
monaco.editor.setModelMarkers(model, 'lsp', [marker]);
Completion Items:
import { fromCompletionItem, toCompletionItem, toCompletionList } from 'monaco-languageserver-types';
// Handle LSP completion response for Monaco
connection.onCompletion(async (params) => {
const lspCompletions = await languageServer.getCompletions(params);
return lspCompletions;
});
// In Monaco provider
const monacoProvider: monaco.languages.CompletionItemProvider = {
provideCompletionItems: async (model, position) => {
const lspPosition = fromPosition(position);
const lspResult = await client.sendRequest('textDocument/completion', {
textDocument: { uri: model.uri.toString() },
position: lspPosition
});
return toCompletionList(lspResult);
}
};
Hover:
import { fromPosition, toHover } from 'monaco-languageserver-types';
const hoverProvider: monaco.languages.HoverProvider = {
provideHover: async (model, position) => {
const lspHover = await client.sendRequest('textDocument/hover', {
textDocument: { uri: model.uri.toString() },
position: fromPosition(position)
});
return lspHover ? toHover(lspHover) : null;
}
};
Available Conversions
| Category | Functions |
|---|---|
| Structural | fromPosition, toPosition, fromRange, toRange, fromLocation, toLocation |
| Diagnostics | fromMarkerData, toMarkerData, fromMarkerSeverity, toMarkerSeverity |
| Completion | fromCompletionItem, toCompletionItem, toCompletionList, fromCompletionItemKind |
| Code Actions | fromCodeAction, toCodeAction, fromCodeActionContext |
| Navigation | fromDefinition, toDefinition, fromDocumentHighlight, toDocumentHighlight |
| Symbols | fromDocumentSymbol, toDocumentSymbol, fromSymbolKind, toSymbolKind |
| Formatting | fromTextEdit, toTextEdit, fromFormattingOptions |
| Semantic | fromSemanticTokens, toSemanticTokens, fromInlayHint, toInlayHint |
| Workspace | fromWorkspaceEdit, toWorkspaceEdit |
Full Monaco LSP Client Example
import * as monaco from 'monaco-editor';
import {
fromPosition, fromRange, toCompletionList, toHover,
toMarkerData, toDocumentHighlight, toCodeAction
} from 'monaco-languageserver-types';
import { createLanguageClient } from './lsp-client';
// Create LSP client connection
const client = createLanguageClient('ws://localhost:3000/lsp');
// Register Monaco providers that bridge to LSP
monaco.languages.registerCompletionItemProvider('typescript', {
triggerCharacters: ['.', '"', "'", '/'],
provideCompletionItems: async (model, position) => {
const result = await client.textDocumentCompletion({
textDocument: { uri: model.uri.toString() },
position: fromPosition(position)
});
return toCompletionList(result);
}
});
monaco.languages.registerHoverProvider('typescript', {
provideHover: async (model, position) => {
const result = await client.textDocumentHover({
textDocument: { uri: model.uri.toString() },
position: fromPosition(position)
});
return result ? toHover(result) : null;
}
});
// Handle diagnostics from server
client.onNotification('textDocument/publishDiagnostics', (params) => {
const model = monaco.editor.getModel(monaco.Uri.parse(params.uri));
if (model) {
const markers = params.diagnostics.map(toMarkerData);
monaco.editor.setModelMarkers(model, 'lsp', markers);
}
});
Resources
Official Documentation
Implementations
Development Tools
- LSP DevTools - CLI utilities for inspecting LSP interactions
- pytest-lsp - End-to-end testing framework
- monaco-languageserver-types - Type conversion for Monaco Editor
Tutorials
Version History
-
1.2.0 (2026-01-11): Added Monaco Editor integration
- monaco-languageserver-types library documentation
- Bidirectional type conversion (from*/to* functions)
- Position, Range, Diagnostic conversions
- Completion, Hover, Code Action examples
- Full Monaco LSP client example
- Available conversions reference table
-
1.1.0 (2026-01-11): Added Development Tools section
- LSP DevTools integration (agent, record, inspect commands)
- pytest-lsp testing framework with examples
- Architecture diagrams for debugging workflow
- Parameterized client testing patterns
- Diagnostic testing examples
-
1.0.0 (2026-01-10): Initial skill release
- Complete protocol overview (architecture, lifecycle, capabilities)
- All language features documented (completion, diagnostics, navigation, etc.)
- Server development guides (TypeScript, Python)
- Client development guide
- SDK reference table
- LSP 3.17 features (inlay hints, type hierarchy, diagnostic pull)
- Debugging and best practices
