Skip to content

Instantly share code, notes, and snippets.

@thorn0
Last active March 9, 2026 22:05
Show Gist options
  • Select an option

  • Save thorn0/1d8df18a0aee9c128447407659fa15e3 to your computer and use it in GitHub Desktop.

Select an option

Save thorn0/1d8df18a0aee9c128447407659fa15e3 to your computer and use it in GitHub Desktop.
Workaround: Make TypeScript LSP work with Claude Code on Windows (supports both official typescript-lsp and vtsls plugins)

LSP proxy workaround for Claude Code on Windows

Makes TypeScript LSP servers work with Claude Code on Windows. Supports both the official typescript-lsp plugin (uses typescript-language-server) and the third-party vtsls plugin from claude-code-lsps (uses vtsls).

Related issues:

  • #16751 — Node.js spawn() can't execute .cmd wrappers on Windows
  • #17136 — LSP on Windows

The problems

  1. .cmd spawn failure: Claude Code uses Node.js child_process.spawn() without shell: true, which can't execute npm's .cmd/shell script wrappers on Windows (ENOENT/uv_spawn errors).

  2. Inconsistent file URIs: Claude Code's LSP client sends different URI formats for the same file:

    • textDocument/didOpen: file://D:\path\to\file.ts (backslashes, double slash — malformed)
    • textDocument/hover etc.: file:///D:/path/to/file.ts (forward slashes, triple slash — correct)

    The server registers the file under the didOpen URI but can't find it when queried with the other format, so all type-aware operations (hover, findReferences, documentSymbol) return empty results. Only parsing-based operations (workspaceSymbol) work.

  3. Large message corruption: A string-based LSP message parser breaks on large messages (like didOpen for big files) because Content-Length counts bytes but string slicing counts characters.

The fix

A lightweight Node.js proxy that sits between Claude Code and the language server:

  • Normalizes all file:// URIs to consistent file:///D:/... format
  • Uses Buffer-based message framing (byte-accurate)
  • A compiled .exe shim bypasses the .cmd spawn issue
  • One shim binary works for both servers — it detects which to launch from its own filename

Setup

Prerequisites

  • Node.js on PATH
  • Claude Code with a TypeScript LSP plugin installed (official or vtsls)
  • PowerShell (for compiling the C# shim)

Install the language server(s) you want to use globally:

# For the official typescript-lsp plugin:
npm i -g typescript-language-server typescript

# For the vtsls plugin:
npm i -g @vtsls/language-server

Install

  1. Save lsp-proxy.mjs and lsp-shim.cs to %USERPROFILE%\.claude\

  2. Compile the exe shim for whichever server(s) you use:

# For vtsls plugin:
Add-Type -OutputType ConsoleApplication `
  -OutputAssembly "$env:APPDATA\npm\vtsls.exe" `
  -Path "$env:USERPROFILE\.claude\lsp-shim.cs"

# For official typescript-lsp plugin:
Add-Type -OutputType ConsoleApplication `
  -OutputAssembly "$env:APPDATA\npm\typescript-language-server.exe" `
  -Path "$env:USERPROFILE\.claude\lsp-shim.cs"

The same source produces both — the exe detects which server to launch from its own filename.

  1. Restart Claude Code.

Debug logging

Set LSP_PROXY_LOG=1 environment variable to write all LSP messages to lsp-proxy.log in the current working directory.

How it works

Claude Code  →  vtsls.exe (shim)  →  node lsp-proxy.mjs node vtsls.js --stdio  →  vtsls
Claude Code  →  typescript-language-server.exe (shim)  →  node lsp-proxy.mjs node cli.mjs --stdio  →  tsserver
                                                           ↕ normalize URIs
                                                           ↕ buffer-based framing

The exe shim exists solely to work around the .cmd spawn issue. The proxy does the actual work. Both shims are compiled from the same lsp-shim.cs — the binary inspects argv[0] to decide which server to launch.

// Claude Code LSP proxy for Windows.
// Fixes two issues with Claude Code's LSP client on Windows:
// 1. URI normalization — Claude Code sends inconsistent file:// URIs
// (didOpen uses file://D:\..., queries use file:///D:/...). This proxy
// normalizes all URIs to file:///D:/... format.
// 2. Buffer-based message framing — Content-Length is in bytes, so we must
// work with Buffers, not strings, to avoid corruption on large messages.
//
// Usage: node lsp-proxy.mjs <command> [args...]
// Example: node lsp-proxy.mjs typescript-language-server --stdio
// Example: node lsp-proxy.mjs node /path/to/vtsls.js --stdio
//
// Set LSP_PROXY_LOG=1 to enable logging to lsp-proxy.log in cwd.
import { spawn } from 'child_process';
import { createWriteStream } from 'fs';
import { join } from 'path';
const [command, ...args] = process.argv.slice(2);
if (!command) {
process.stderr.write('Usage: node lsp-proxy.mjs <command> [args...]\n');
process.exit(1);
}
const logging = process.env.LSP_PROXY_LOG === '1';
const log = logging
? createWriteStream(join(process.cwd(), 'lsp-proxy.log'), { flags: 'w' })
: { write() {} };
const server = spawn(command, args, {
stdio: ['pipe', 'pipe', 'pipe'],
});
function normalizeUri(uri) {
if (typeof uri !== 'string' || !uri.startsWith('file://')) return uri;
let path = uri.replace(/^file:\/\/\/?/, '');
path = path.replace(/\\/g, '/');
path = path.replace(/\/$/, '');
path = path.replace(/^([a-z]):/, (_, letter) => letter.toUpperCase() + ':');
return 'file:///' + path;
}
function normalizeUrisInObject(obj) {
if (obj === null || obj === undefined || typeof obj === 'string') return obj;
if (Array.isArray(obj)) {
for (let i = 0; i < obj.length; i++) obj[i] = normalizeUrisInObject(obj[i]);
return obj;
}
if (typeof obj === 'object') {
for (const key of Object.keys(obj)) {
if ((key === 'uri' || key === 'rootUri') && typeof obj[key] === 'string') {
obj[key] = normalizeUri(obj[key]);
} else {
obj[key] = normalizeUrisInObject(obj[key]);
}
}
}
return obj;
}
function createParser(label, output, transform) {
let buf = Buffer.alloc(0);
return (chunk) => {
buf = Buffer.concat([buf, chunk]);
while (true) {
const headerEnd = buf.indexOf('\r\n\r\n');
if (headerEnd === -1) break;
const headerStr = buf.subarray(0, headerEnd).toString('ascii');
const match = headerStr.match(/Content-Length:\s*(\d+)/i);
if (!match) break;
const contentLength = parseInt(match[1], 10);
const bodyStart = headerEnd + 4;
if (buf.length < bodyStart + contentLength) break;
const bodyBuf = buf.subarray(bodyStart, bodyStart + contentLength);
buf = buf.subarray(bodyStart + contentLength);
try {
const msg = JSON.parse(bodyBuf.toString('utf8'));
log.write(`\n=== ${label} ${msg.method || msg.id || '?'} ===\n`);
const transformed = transform ? transform(msg) : msg;
const outBody = JSON.stringify(transformed);
const outLen = Buffer.byteLength(outBody, 'utf8');
output.write(`Content-Length: ${outLen}\r\n\r\n`);
output.write(outBody);
} catch {
// Skip broken messages
}
}
};
}
function normalizeClientMessage(msg) {
normalizeUrisInObject(msg.params);
return msg;
}
function normalizeServerMessage(msg) {
normalizeUrisInObject(msg.result);
normalizeUrisInObject(msg.params);
return msg;
}
process.stdin.on('data', createParser('CLIENT->', server.stdin, normalizeClientMessage));
server.stdout.on('data', createParser('SERVER->', process.stdout, normalizeServerMessage));
server.stderr.on('data', (d) => { log.write(`STDERR: ${d}\n`); process.stderr.write(d); });
server.on('close', (code) => { log.write(`\nServer exited: ${code}\n`); process.exit(code ?? 0); });
process.on('SIGTERM', () => server.kill());
process.on('SIGINT', () => server.kill());
using System;
using System.Diagnostics;
using System.IO;
class P {
static int Main(string[] args) {
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var proxy = Path.Combine(home, ".claude", "lsp-proxy.mjs");
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var npmModules = Path.Combine(appData, "npm", "node_modules");
// Determine which server to launch based on exe name
var exe = Path.GetFileNameWithoutExtension(
Environment.GetCommandLineArgs()[0]).ToLowerInvariant();
string serverCmd;
string serverArgs;
switch (exe) {
case "vtsls":
serverCmd = "node";
serverArgs = "\"" + Path.Combine(npmModules,
"@vtsls", "language-server", "bin", "vtsls.js") + "\" --stdio";
break;
case "typescript-language-server":
serverCmd = "node";
serverArgs = "\"" + Path.Combine(npmModules,
"typescript-language-server", "lib", "cli.mjs") + "\" --stdio";
break;
default:
Console.Error.WriteLine("Unknown LSP server: " + exe);
return 1;
}
var psi = new ProcessStartInfo();
psi.FileName = "node";
psi.Arguments = "\"" + proxy + "\" " + serverCmd + " " + serverArgs;
psi.UseShellExecute = false;
var p = Process.Start(psi);
p.WaitForExit();
return p.ExitCode;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment