Background Monitor: Implementation Mechanism for Claude to Proactively Respond to Log and State Changes
Chapter 54: LSP Plugin Development: Teaching Claude Code to Understand Your Domain Language
54.1 Why LSP Plugins Are Necessary
What happens when you ask Claude Code to analyze a .sol file (Solidity smart contract), a .tf file (Terraform config), or your company's internal DSL?
By default, Claude relies on knowledge from its training data to understand these languages. For relatively mainstream languages like Solidity, Claude's understanding is acceptable. But for:
- New language features released after Claude's training cutoff
- Company-internal DSLs
- Proprietary configuration formats
- Obscure framework-specific syntax
Claude's understanding has noticeable blind spots. LSP Plugins exist precisely to fill these gaps.
54.2 Language Server Protocol Fundamentals
The N×M Problem
LSP (Language Server Protocol) was proposed by Microsoft in 2016 to solve the N×M integration problem between editors and language services. Before LSP, every editor (VS Code, Vim, Emacs) needed to implement language intelligence features separately for every language (Python, Java, Go): N editors × M languages = N×M integrations.
LSP turns this into N+M: each language implements one LSP Server, each editor implements one LSP Client.
Claude Code's LSP Plugin system inherits this design, allowing language services to provide Claude with precise language understanding capabilities.
LSP Communication Format
LSP is built on JSON-RPC 2.0, with messages transported via stdin/stdout or HTTP:
{
"jsonrpc": "2.0",
"id": 1,
"method": "textDocument/hover",
"params": {
"textDocument": {
"uri": "file:///project/contracts/Token.sol"
},
"position": {
"line": 42,
"character": 15
}
}
}
LSP Capabilities in Claude Code
| LSP Method | Purpose | How Claude Code Uses It |
|---|---|---|
textDocument/diagnostics |
Errors and warnings | Injected into Claude's context to identify problems |
textDocument/hover |
Symbol documentation at cursor | Claude retrieves symbol details when analyzing code |
textDocument/definition |
Jump to definition | Claude traces code reference chains |
textDocument/references |
Find all references | Claude analyzes usage scope of variables/functions |
textDocument/documentSymbol |
All symbols in a file | Claude quickly understands file structure |
workspace/symbol |
Global symbol search | Claude locates symbols in large projects |
textDocument/completion |
Code completion suggestions | Claude references domain rules when generating code |
textDocument/codeAction |
Quick fix suggestions | Claude references LSP suggestions when proposing fixes |
54.3 Implementing an LSP Server
Setup
We'll implement a Solidity LSP Server as our example. Solidity is Ethereum's smart contract language with unique security requirements that benefit from real-time analysis.
mkdir solidity-lsp-plugin
cd solidity-lsp-plugin
claude-plugin init --type lsp
npm install vscode-languageserver vscode-languageserver-textdocument
npm install -D typescript @types/node
LSP Server Skeleton
// lsp/server.ts
import {
createConnection,
TextDocuments,
ProposedFeatures,
InitializeParams,
TextDocumentSyncKind,
InitializeResult,
Diagnostic,
DiagnosticSeverity,
CompletionItem,
CompletionItemKind,
HoverParams,
Hover,
MarkupKind,
} from "vscode-languageserver/node";
import { TextDocument } from "vscode-languageserver-textdocument";
const connection = createConnection(ProposedFeatures.all);
const documents = new TextDocuments(TextDocument);
connection.onInitialize((params: InitializeParams): InitializeResult => {
return {
capabilities: {
textDocumentSync: TextDocumentSyncKind.Incremental,
completionProvider: {
triggerCharacters: [".", "("],
resolveProvider: true,
},
hoverProvider: true,
diagnosticProvider: {
interFileDependencies: false,
workspaceDiagnostics: false,
},
},
serverInfo: {
name: "Solidity Language Server",
version: "1.0.0",
},
};
});
documents.listen(connection);
connection.listen();
Implementing Diagnostics
// lsp/diagnostics/solidity.ts
interface DiagnosticRule {
id: string;
pattern: RegExp;
message: string;
severity: DiagnosticSeverity;
suggestion?: string;
}
const SOLIDITY_RULES: DiagnosticRule[] = [
{
id: "SWC-107",
pattern: /\.call\{value:/g,
message: "Potential reentrancy vulnerability: external call with value transfer",
severity: DiagnosticSeverity.Warning,
suggestion: "Use the checks-effects-interactions pattern. Update state before external calls.",
},
{
id: "SWC-115",
pattern: /tx\.origin/g,
message: "Using tx.origin for authorization is dangerous",
severity: DiagnosticSeverity.Error,
suggestion: "Use msg.sender instead of tx.origin for authorization checks.",
},
{
id: "SWC-131",
pattern: /selfdestruct/g,
message: "selfdestruct is deprecated since EIP-6049",
severity: DiagnosticSeverity.Warning,
suggestion: "Avoid selfdestruct in new contracts. Its behavior may change in future hard forks.",
},
];
export function analyzeSolidity(document: TextDocument): Diagnostic[] {
const text = document.getText();
const diagnostics: Diagnostic[] = [];
const lines = text.split("\n");
for (const rule of SOLIDITY_RULES) {
lines.forEach((line, lineIndex) => {
// Skip comment lines
if (line.trimStart().startsWith("//") || line.trimStart().startsWith("*")) return;
rule.pattern.lastIndex = 0;
let match;
while ((match = rule.pattern.exec(line)) !== null) {
diagnostics.push({
range: {
start: { line: lineIndex, character: match.index },
end: { line: lineIndex, character: match.index + match[0].length },
},
severity: rule.severity,
code: rule.id,
source: "solidity-lsp",
message: rule.message,
data: rule.suggestion,
});
}
});
}
return diagnostics;
}
Register the diagnostic handler in server.ts:
import { analyzeSolidity } from "./diagnostics/solidity.js";
// Debounced analysis — wait 300ms after last change
const debounceTimers = new Map<string, NodeJS.Timeout>();
documents.onDidChangeContent((change) => {
const uri = change.document.uri;
if (!uri.endsWith(".sol")) return;
const existing = debounceTimers.get(uri);
if (existing) clearTimeout(existing);
const timer = setTimeout(() => {
const doc = documents.get(uri);
if (doc) {
connection.sendDiagnostics({ uri, diagnostics: analyzeSolidity(doc) });
}
debounceTimers.delete(uri);
}, 300);
debounceTimers.set(uri, timer);
});
Implementing Hover Documentation
const SOLIDITY_DOCS: Record<string, string> = {
"msg.sender": `**msg.sender** (address)
The address of the account that called this function.
**Security Note**: Never use \`tx.origin\` as a substitute for \`msg.sender\`.
\`tx.origin\` is the original external transaction sender and can be exploited in phishing attacks.
\`\`\`solidity
require(msg.sender == owner, "Not authorized");
\`\`\``,
"selfdestruct": `**selfdestruct** *(deprecated since EIP-6049)*
Destroys the current contract and sends its Ether to a recipient.
⚠️ Avoid using in new contracts. May be removed or have altered behavior in future hard forks.`,
"delegatecall": `**delegatecall** → (bool success, bytes returndata)
Executes the target contract's code in the context of the calling contract.
Storage, \`msg.sender\`, and \`msg.value\` remain those of the calling contract.
⚠️ **Security Risk**: Improper use is a critical vulnerability. Never delegatecall to user-provided addresses.`,
};
connection.onHover((params: HoverParams): Hover | null => {
const document = documents.get(params.textDocument.uri);
if (!document) return null;
const wordRange = getWordRangeAtPosition(document, params.position);
if (!wordRange) return null;
const word = document.getText(wordRange);
const docs = SOLIDITY_DOCS[word];
if (!docs) return null;
return {
contents: { kind: MarkupKind.Markdown, value: docs },
range: wordRange,
};
});
Implementing Completion
connection.onCompletion((_params): CompletionItem[] => {
return [
{
label: "require",
kind: CompletionItemKind.Keyword,
detail: "require(condition, message)",
documentation: {
kind: MarkupKind.Markdown,
value: "Validates a condition. Reverts with `message` if false.",
},
insertText: 'require(${1:condition}, "${2:error message}");',
insertTextFormat: 2, // Snippet
},
{
label: "ReentrancyGuard",
kind: CompletionItemKind.Class,
detail: "OpenZeppelin ReentrancyGuard",
documentation: {
kind: MarkupKind.Markdown,
value: "```solidity\ncontract MyContract is ReentrancyGuard {\n function withdraw() external nonReentrant { ... }\n}\n```",
},
},
{
label: "SPDX-License-Identifier",
kind: CompletionItemKind.Snippet,
insertText: "// SPDX-License-Identifier: ${1|MIT,Apache-2.0,GPL-3.0,UNLICENSED|}",
insertTextFormat: 2,
},
];
});
54.4 Integrating the LSP Server into the Plugin
plugin.json Configuration
{
"name": "solidity-lsp-plugin",
"version": "1.0.0",
"description": "Solidity language support with security analysis for Claude Code",
"lsp": {
"languages": ["solidity"],
"server": {
"command": "node",
"args": ["./dist/lsp/server.js"],
"transport": "stdio"
},
"documentSelector": [
{ "language": "solidity", "pattern": "**/*.sol" }
],
"initializationOptions": {
"solcVersion": "0.8.24",
"enableSecurityAnalysis": true
}
},
"engines": { "claude-code": ">=1.0.0" }
}
How Claude Code Uses LSP Data
When Claude analyzes a .sol file, Claude Code automatically:
- Starts the LSP Server if not already running
- Notifies the LSP Server the file was opened (
textDocument/didOpen) - Requests diagnostics (
textDocument/diagnostic) - Appends diagnostic results to Claude's context:
[LSP Diagnostics for Token.sol]
Line 47: WARNING (SWC-107) - Potential reentrancy: external call with value transfer
Suggestion: Use checks-effects-interactions pattern.
Line 52: ERROR (SWC-115) - tx.origin for authorization is dangerous
Suggestion: Use msg.sender instead.
With this context injected, Claude's code analysis is equipped with the domain expertise provided by the LSP Server.
54.5 Building an LSP Server for an Internal DSL
Example: Workflow DSL
Suppose your company uses a .flow internal workflow definition language:
workflow OrderProcessing {
trigger: order.created
step ValidateOrder {
action: validate_order
on_failure: NotifyTeam
}
step ChargePayment {
action: charge_stripe
retry: 3
depends_on: ValidateOrder
}
}
The key diagnostic for this language is detecting references to undefined steps:
// lsp/diagnostics/flow.ts
export function analyzeFlowDocument(document: TextDocument): Diagnostic[] {
const text = document.getText();
const diagnostics: Diagnostic[] = [];
// Collect all declared step names
const declaredSteps = new Set<string>();
const stepRegex = /step\s+(\w+)\s*\{/g;
let match;
while ((match = stepRegex.exec(text)) !== null) {
declaredSteps.add(match[1]);
}
// Check all depends_on references
const dependsRegex = /depends_on:\s*(\w+)/g;
const lines = text.split("\n");
lines.forEach((line, lineIndex) => {
const depMatch = line.match(/depends_on:\s*(\w+)/);
if (depMatch) {
const referencedStep = depMatch[1];
if (!declaredSteps.has(referencedStep)) {
diagnostics.push({
range: {
start: { line: lineIndex, character: line.indexOf(referencedStep) },
end: { line: lineIndex, character: line.indexOf(referencedStep) + referencedStep.length },
},
severity: DiagnosticSeverity.Error,
source: "flow-lsp",
message: `Undefined step: "${referencedStep}"`,
code: "FLOW-001",
});
}
}
});
return diagnostics;
}
54.6 LSP Performance Optimization
Incremental Updates and Caching
// Cache parsed ASTs to avoid redundant parsing
const astCache = new Map<string, { version: number; result: unknown }>();
function getCachedParse(document: TextDocument) {
const cached = astCache.get(document.uri);
if (cached?.version === document.version) return cached.result;
const result = parseDocument(document.getText());
astCache.set(document.uri, { version: document.version, result });
return result;
}
For large files, consider offloading analysis to a worker thread to avoid blocking the LSP Server's event loop.
54.7 Debugging LSP Servers
# Start LSP Server in debug mode (logs all communication)
LSP_DEBUG=1 node dist/lsp/server.js 2>lsp-debug.log
# View LSP data received by Claude Code
CLAUDE_LSP_DEBUG=1 claude
# Manual protocol testing with LSP Inspector
npx @vscode/lsp-inspector
Common Issues
LSP Server doesn't recognize the file type: Verify the documentSelector pattern correctly matches your file paths. Test with an absolute path.
Diagnostics not appearing in Claude's context: Check that your Claude Code version supports the LSP capabilities you're using. Verify via engines.claude-code.
Completion suggestions not appearing: Confirm capabilities.completionProvider declares the correct trigger characters.
Server crashes on large files: Add error boundaries around parsing logic. LSP Servers should never crash — they should return empty results on errors and log the issue.
Summary
LSP Plugins give Claude precise domain language understanding through the Language Server Protocol: diagnostics expose code problems, hover docs explain symbols, and completion suggestions help generate domain-conformant code. The core implementation steps are: establish a JSON-RPC communication connection, register onInitialize, implement specific LSP capability handlers, and configure language bindings in plugin.json. For enterprise environments, providing LSP support for internal DSLs is one of the most effective ways to improve Claude's accuracy in specific codebases. The next chapter covers monitoring and observability Plugins.