Skip to content

Instantly share code, notes, and snippets.

@icanhasjonas
Last active January 8, 2026 15:38
Show Gist options
  • Select an option

  • Save icanhasjonas/4c04328b8c886277399ad23f41549571 to your computer and use it in GitHub Desktop.

Select an option

Save icanhasjonas/4c04328b8c886277399ad23f41549571 to your computer and use it in GitHub Desktop.
Bun adapter for csharp-ls to work with Claude Code LSP (fixes workDoneProgress/registerCapability issues)
#!/usr/bin/env bun
/**
* LSP Adapter for csharp-ls
*
* Proxies LSP messages between Claude Code and csharp-ls,
* handling unsupported methods that crash csharp-ls.
*
* Intercepts (responds with null to csharp-ls, doesn't forward to Claude):
* - window/workDoneProgress/create -> progress token creation
* - window/workDoneProgress/cancel -> progress cancellation
* - client/registerCapability -> dynamic capability registration
*
* Passes through (Claude Code handles these):
* - workspace/configuration -> Claude Code returns config
* - Everything else
*/
import { spawn } from "bun";
import { dirname, join } from "path";
const DEBUG = process.env.LSP_ADAPTER_DEBUG === "1";
// Find the original csharp-ls binary (sibling to this script)
const SCRIPT_DIR = dirname(Bun.main);
const CSHARP_LS_ORIGINAL = join(SCRIPT_DIR, "csharp-ls-original");
function log(...args: any[]) {
if (DEBUG) {
console.error("[ADAPTER]", ...args);
}
}
// LSP Message parser state
class LspParser {
private buffer = Buffer.alloc(0);
private contentLength = -1;
parse(chunk: Buffer): Array<{ header: string; body: any }> {
this.buffer = Buffer.concat([this.buffer, chunk]);
const messages: Array<{ header: string; body: any }> = [];
while (true) {
if (this.contentLength === -1) {
// Looking for Content-Length header
const headerEnd = this.buffer.indexOf("\r\n\r\n");
if (headerEnd === -1) break;
const header = this.buffer.subarray(0, headerEnd).toString();
const match = header.match(/Content-Length:\s*(\d+)/i);
if (!match) {
log("Invalid header:", header);
break;
}
this.contentLength = parseInt(match[1], 10);
this.buffer = this.buffer.subarray(headerEnd + 4);
}
if (this.buffer.length < this.contentLength) break;
const bodyStr = this.buffer.subarray(0, this.contentLength).toString();
this.buffer = this.buffer.subarray(this.contentLength);
this.contentLength = -1;
try {
const body = JSON.parse(bodyStr);
messages.push({
header: `Content-Length: ${bodyStr.length}\r\n\r\n`,
body
});
} catch (e) {
log("Failed to parse JSON:", bodyStr);
}
}
return messages;
}
}
function encodeMessage(body: any): Buffer {
const content = JSON.stringify(body);
const header = `Content-Length: ${Buffer.byteLength(content)}\r\n\r\n`;
return Buffer.from(header + content);
}
// Methods we intercept and handle ourselves (respond with null, don't forward to Claude)
const INTERCEPTED_METHODS = new Set([
"window/workDoneProgress/create",
"window/workDoneProgress/cancel",
"client/registerCapability",
]);
async function main() {
log("Starting csharp-ls adapter...");
// Spawn the real csharp-ls (original binary renamed, sibling to this script)
const csharpLs = spawn([CSHARP_LS_ORIGINAL], {
stdin: "pipe",
stdout: "pipe",
stderr: "inherit",
});
log("Spawned csharp-ls, pid:", csharpLs.pid);
const clientParser = new LspParser(); // Claude Code -> Adapter
const serverParser = new LspParser(); // csharp-ls -> Adapter
// Pending requests from server that we need to respond to
const pendingServerRequests = new Map<number | string, string>();
// Forward stdin (from Claude Code) to csharp-ls
(async () => {
const reader = Bun.stdin.stream().getReader();
while (true) {
const { done, value } = await reader.read();
if (done) {
log("Client stdin closed");
csharpLs.stdin.end();
break;
}
const messages = clientParser.parse(Buffer.from(value));
for (const msg of messages) {
log("CLIENT ->", msg.body.method || msg.body.id);
// Pass all client messages through unchanged
csharpLs.stdin.write(encodeMessage(msg.body));
}
if (messages.length === 0 && value.length > 0) {
// Partial message, buffer is handling it
}
}
})();
// Forward stdout (from csharp-ls) to Claude Code, intercepting specific methods
(async () => {
const reader = csharpLs.stdout.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) {
log("Server stdout closed");
break;
}
const messages = serverParser.parse(Buffer.from(value));
for (const msg of messages) {
const { body } = msg;
// Check if this is a request we should intercept
if (body.method && INTERCEPTED_METHODS.has(body.method)) {
log("INTERCEPT <-", body.method, "id:", body.id);
// Send success response back to csharp-ls
if (body.id !== undefined) {
const response = {
jsonrpc: "2.0",
id: body.id,
result: null
};
csharpLs.stdin.write(encodeMessage(response));
log("INTERCEPT ->", "response for", body.method);
}
// Don't forward to client
continue;
}
log("SERVER ->", body.method || `response:${body.id}`);
// Forward to Claude Code
process.stdout.write(encodeMessage(body));
}
}
})();
// Handle process termination
process.on("SIGINT", () => {
log("SIGINT received, killing csharp-ls");
csharpLs.kill();
process.exit(0);
});
process.on("SIGTERM", () => {
log("SIGTERM received, killing csharp-ls");
csharpLs.kill();
process.exit(0);
});
// Wait for csharp-ls to exit
const exitCode = await csharpLs.exited;
log("csharp-ls exited with code:", exitCode);
process.exit(exitCode);
}
main().catch((err) => {
console.error("[ADAPTER] Fatal error:", err);
process.exit(1);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment