[BUG] Claude Code - LSP Tool Returns Empty Results Despite Server Responding Correctly (Windows)

Status Fixed / completed
Reported on v2.1.3
Maintainer reply None cached
Activity 15 comments · opened Jan 10, 2026 · closed May 10, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

Two issues with LSP Tool on Windows:

Issue #1 - ENOENT Error: Claude Code cannot spawn LSP servers because Node.js spawn() without shell: true cannot find .cmd or .ps1 files on Windows. Error: spawn typescript-language-server ENOENT

Issue #2 - Empty Results Bug: After creating .exe shims to fix Issue #1, the LSP tool returns "No symbols found", "No hover information", etc. for ALL document-level operations, even though the LSP server responds correctly with valid data. Only workspaceSymbol works.

This affects ALL tested LSP servers including the official typescript-lsp@claude-plugins-official plugin.

What Should Happen?

  1. LSP servers should spawn correctly on Windows (find .cmd files or use shell: true)
  2. Document-level operations (documentSymbol, hover, findReferences, goToDefinition, etc.) should return the data that the LSP server provides

Direct test results (same file, same LSP server):
| Operation | Direct Test | Claude Code |
|-----------|------------|-------------|
| documentSymbol | ✅ 6 items | ❌ "No symbols found" |
| hover | ✅ "class UserService" | ❌ "No hover info" |
| references | ✅ 7 items | ❌ "No references" |
| workspaceSymbol | ✅ 256 items | ✅ 13 items |

Error Messages/Logs

**Issue #1 - ENOENT (before .exe shim fix):**

[ERROR] LSP server plugin:typescript-lsp:typescript failed to start: spawn typescript-language-server ENOENT
[ERROR] LSP server plugin:vtsls:typescript failed to start: spawn vtsls ENOENT


**Issue #2 - Server responds but Claude Code shows empty results:**

[DEBUG] [LSP PROTOCOL plugin:vtsls:typescript] Sending notification 'textDocument/didOpen'.
[DEBUG] LSP: Sent didOpen for C:\...\test-lsp.ts (languageId: typescript)
[DEBUG] [LSP PROTOCOL plugin:vtsls:typescript] Sending request 'textDocument/hover - (1)'.
[DEBUG] [LSP PROTOCOL plugin:vtsls:typescript] Received response 'textDocument/hover - (1)' in 4ms. ✅
[DEBUG] [LSP PROTOCOL plugin:vtsls:typescript] Sending request 'textDocument/documentSymbol - (2)'.
[DEBUG] [LSP PROTOCOL plugin:vtsls:typescript] Received response 'textDocument/documentSymbol - (2)' in 32ms. ✅
[DEBUG] [LSP PROTOCOL plugin:vtsls:typescript] Sending request 'textDocument/references - (3)'.
[DEBUG] [LSP PROTOCOL plugin:vtsls:typescript] Received response 'textDocument/references - (3)' in 61ms. ✅


**User sees:** "No symbols found", "No hover information available", "No references found"

Steps to Reproduce

Issue #1 (ENOENT):

  1. Fresh Windows 10/11 install
  2. Install Node.js and run: npm install -g typescript-language-server typescript
  3. In Claude Code settings, add: "env": { "ENABLE_LSP_TOOL": "1" }
  4. Enable typescript-lsp@claude-plugins-official plugin
  5. Restart Claude Code
  6. Check debug logs at ~/.claude/debug/*.txt
  7. Observe: spawn typescript-language-server ENOENT

Issue #2 (Empty Results - after fixing Issue #1):

  1. Create .exe shims for LSP servers using PowerShell:
$code = @"
using System;
using System.Diagnostics;
public class Shim {
    public static int Main(string[] args) {
        ProcessStartInfo info = new ProcessStartInfo();
        info.FileName = "cmd.exe";
        info.Arguments = "/c typescript-language-server.cmd " + string.Join(" ", args);
        info.UseShellExecute = false;
        Process p = Process.Start(info);
        p.WaitForExit();
        return p.ExitCode;
    }
}
"@
Add-Type -TypeDefinition $code -OutputAssembly "C:\Users\USERNAME\AppData\Roaming\npm\typescript-language-server.exe" -OutputType ConsoleApplication
  1. Create test file test-lsp.ts:
interface User {
  id: number;
  name: string;
}

class UserService {
  private users: User[] = [];
  addUser(user: User): void {
    this.users.push(user);
  }
}

function greet(user: User): string {
  return `Hello, ${user.name}!`;
}

const service = new UserService();
  1. Restart Claude Code
  2. Use LSP tool: documentSymbol on test-lsp.ts
  3. Observe: "No symbols found in document"
  4. Use LSP tool: workspaceSymbol on test-lsp.ts
  5. Observe: Returns symbols correctly (this one works!)
  6. Check debug logs: Server received response in 32ms but user sees empty results

---

Claude Model

Opus

Is this a regression?

I don't know

Last Working Version

_No response_

Claude Code Version

2.1.3

Platform

Anthropic API

Operating System

Windows

Terminal/Shell

Windows Terminal

Additional Information

_No response_

View original on GitHub ↗

14 Comments

ChanghoSong · 7 months ago

Additional Findings (Windows + npm installation)

### Working vs Non-Working LSP Methods

After further testing on Windows with npm-installed Claude Code, I found that only specific LSP methods are working:

| Method | Type | Status |
|--------|------|--------|
| workspace/symbol | Request/Response | ✅ Working |
| textDocument/publishDiagnostics | Server → Client Push | ✅ Working |
| textDocument/hover | Request/Response | ❌ Not working |
| textDocument/references | Request/Response | ❌ Not working |
| textDocument/definition | Request/Response | ❌ Not working |
| textDocument/documentSymbol | Request/Response | ❌ Not working |
| callHierarchy/incomingCalls | Request/Response | ❌ Not working |
| callHierarchy/outgoingCalls | Request/Response | ❌ Not working |

### Key Observations

  1. Not a JS vs TS issue: Same behavior on both .js and .ts files
  2. Not a jsconfig/tsconfig issue: Adding jsconfig.json didn't change results
  3. Partial implementation: Only workspaceSymbol and diagnostics appear to be implemented/working
  4. Both request types fail: The issue isn't push vs request - workspaceSymbol is also request/response but works fine

### Environment

  • Platform: Windows
  • Installation: npm (npm install -g @anthropic-ai/claude-code)
  • TypeScript LSP plugin: enabled
AsysKevin · 6 months ago

I can confirm this issue on Windows 11 (Build 10.0.26200.7628) with Claude Code 2.1.31.

Test Results:

| Operation | macOS | Windows |
|-----------|-------|---------|
| workspaceSymbol | ✅ Works | ✅ Works |
| documentSymbol | ✅ Works | ❌ No symbols found |
| goToDefinition | ✅ Works | ❌ No definition found |
| hover | ✅ Works | ⚠️ No output |
| findReferences | ✅ Works | ❌ No references found |
| goToImplementation | ✅ Works | ❌ No definition found |
| incomingCalls | ✅ Works | ❌ No call hierarchy item found |
| outgoingCalls | ✅ Works | ❌ No call hierarchy item found |

Observations:

  • workspaceSymbol is the only operation that works correctly on Windows
  • The LSP server process (typescript-language-serve) is confirmed running via Task Manager
  • The project has valid tsconfig.json and node_modules installed
  • Same codebase works perfectly on macOS

Possible Causes:

  1. Path format issues (backslashes vs forward slashes)
  2. File URI handling differences on Windows
  3. TypeScript project initialization not properly loading tsconfig.json
pdebuitlear · 6 months ago

Still reproduces as of 2.1.45

ChanghoSong · 6 months ago

Root Cause Found & Full Workaround (TypeScript + Python LSP)

After building a JSON-RPC logging proxy between Claude Code and the LSP server, I identified the root cause of Issue #2 (empty results):

Claude Code's LSP client does NOT send textDocument/didOpen before document-level requests on Windows.

Per the LSP specification, the server must receive textDocument/didOpen (containing the full source text) before it can respond to hover, definition, references, documentSymbol, etc. Without it, the server returns empty results with Unexpected resource errors internally. This is why workspaceSymbol works — it doesn't require a specific document to be opened.

---

How the Proxy Fixes It

The proxy sits between Claude Code and the real LSP server, doing two things:

  1. Normalizes Windows file URIs — Claude Code sends file://C:\Users\... (backslash, 2 slashes). The proxy rewrites all URIs to file:///C:/Users/... (forward slash, 3 slashes) per the URI spec.
  2. Auto-injects textDocument/didOpen — When a document-level request (hover, definition, etc.) arrives for a file not yet opened, the proxy reads the file from disk and sends didOpen to the server before forwarding the original request.

Architecture & Flow

Claude Code
  → typescript-language-server.exe    (C# shim, resolves ENOENT issue)
    → node lsp-proxy.js              (Node.js proxy, URI fix + didOpen injection)
      → node cli.mjs                 (real typescript-language-server)

Claude Code
  → pyright-langserver.exe           (same C# shim, different filename)
    → node lsp-proxy.js              (same proxy, selects backend by --server-name)
      → node langserver.index.js     (real pyright)

Why a C# .exe shim? Claude Code uses Node.js spawn() without shell: true, so it cannot find .cmd/.ps1 files on Windows (Issue #1). A native .exe in PATH solves the ENOENT problem. The shim extracts its own filename to pass --server-name to the proxy, so one source file builds all shims.

File Structure

%USERPROFILE%\.local\bin\                        ← must be FIRST in system PATH
  ├── typescript-language-server.exe              ← compiled C# shim
  ├── pyright-langserver.exe                      ← compiled C# shim (same source)
  └── lsp-proxy\
      ├── lsp-proxy.js                            ← proxy logic
      └── typescript-language-server.cs            ← shim source (builds both .exe)

Test Results

TypeScript (typescript-language-server):

| Operation | Before Proxy | After Proxy |
|-----------|:---:|:---:|
| hover | ❌ empty | ✅ type info |
| goToDefinition | ❌ empty | ✅ correct file:line |
| findReferences | ❌ empty | ✅ all refs found |
| documentSymbol | ❌ empty | ✅ full symbol tree |
| diagnostics | ✅ | ✅ |
| workspaceSymbol | ✅ | ✅ |

Python (pyright-langserver):

| Operation | Before Proxy | After Proxy |
|-----------|:---:|:---:|
| hover | ❌ empty | ✅ function signature + docstring |
| goToDefinition | ❌ empty | ✅ jumps to typeshed/source |
| findReferences | ❌ empty | ✅ all refs found |
| documentSymbol | ❌ empty | ✅ functions + variables |

---

Setup Instructions

Step 0: PATH Configuration (Critical)

Open Start → Run → sysdm.cpl → Advanced → Environment Variables → User variables → Path, and move %USERPROFILE%\.local\bin to the top of the list. This ensures Claude Code finds our shim .exe files before any .cmd wrappers in npm/bun directories.

%USERPROFILE%\.local\bin        ← MUST be first
%USERPROFILE%\.bun\bin
%APPDATA%\npm
...
Step 1: Create directory structure
mkdir "$env:USERPROFILE\.local\bin\lsp-proxy" -Force
Step 2: Create %USERPROFILE%\.local\bin\lsp-proxy\typescript-language-server.cs

This is the universal shim source. It detects its own .exe filename and passes it as --server-name to the proxy.

using System;
using System.Diagnostics;
using System.IO;
using System.Threading;

public class LspProxyShim {
    public static int Main(string[] args) {
        string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
        string exeName = System.IO.Path.GetFileNameWithoutExtension(
            System.Reflection.Assembly.GetExecutingAssembly().Location);

        ProcessStartInfo info = new ProcessStartInfo();
        info.FileName = "node";
        info.Arguments = "\"" + home + "\\.local\\bin\\lsp-proxy\\lsp-proxy.js\" --server-name=" + exeName + " " + string.Join(" ", args);
        info.UseShellExecute = false;
        info.RedirectStandardInput = true;
        info.RedirectStandardOutput = true;
        info.RedirectStandardError = true;
        info.CreateNoWindow = true;

        Process p = Process.Start(info);

        Thread stdinThread = new Thread(() => {
            try {
                using (Stream src = Console.OpenStandardInput())
                using (Stream dst = p.StandardInput.BaseStream) {
                    byte[] buf = new byte[4096]; int n;
                    while ((n = src.Read(buf, 0, buf.Length)) > 0) { dst.Write(buf, 0, n); dst.Flush(); }
                }
            } catch {}
        });
        stdinThread.IsBackground = true;
        stdinThread.Start();

        Thread stdoutThread = new Thread(() => {
            try {
                using (Stream src = p.StandardOutput.BaseStream)
                using (Stream dst = Console.OpenStandardOutput()) {
                    byte[] buf = new byte[4096]; int n;
                    while ((n = src.Read(buf, 0, buf.Length)) > 0) { dst.Write(buf, 0, n); dst.Flush(); }
                }
            } catch {}
        });
        stdoutThread.IsBackground = true;
        stdoutThread.Start();

        Thread stderrThread = new Thread(() => {
            try {
                using (Stream src = p.StandardError.BaseStream)
                using (Stream dst = Console.OpenStandardError()) {
                    byte[] buf = new byte[4096]; int n;
                    while ((n = src.Read(buf, 0, buf.Length)) > 0) { dst.Write(buf, 0, n); dst.Flush(); }
                }
            } catch {}
        });
        stderrThread.IsBackground = true;
        stderrThread.Start();

        p.WaitForExit();
        stdoutThread.Join(2000);
        stderrThread.Join(2000);
        return p.ExitCode;
    }
}
Step 3: Create %USERPROFILE%\.local\bin\lsp-proxy\lsp-proxy.js

<details>
<summary>Click to expand — lsp-proxy.js (full source)</summary>

#!/usr/bin/env node
/**
 * LSP Proxy for Windows
 * Fixes Claude Code LSP issues on Windows:
 * 1. Normalizes file:// URIs (backslash -> forward slash)
 * 2. Auto-injects textDocument/didOpen for unopened files
 * Supports multiple LSP servers via --server-name argument
 */
const { spawn } = require('child_process');
const fs = require('fs');
const path = require('path');

// Log file - daily rotation
const logDir = path.join(process.env.USERPROFILE || process.env.HOME, '.local', 'bin', 'lsp-proxy');
const logFile = path.join(logDir, `lsp-proxy-${new Date().toISOString().slice(0, 10)}.jsonl`);
try {
  if (!fs.existsSync(logDir)) fs.mkdirSync(logDir, { recursive: true });
  fs.appendFileSync(logFile, JSON.stringify({ time: new Date().toISOString(), event: 'PROXY_START', args: process.argv.slice(2) }) + '\n');
} catch (e) { process.stderr.write('LSP-PROXY LOG ERROR: ' + e.message + '\n'); }
const logStream = fs.createWriteStream(logFile, { flags: 'a' });

// --- URI Normalization ---

function normalizeFileUri(uri) {
  if (typeof uri !== 'string' || !uri.startsWith('file://')) return uri;
  let filePath = uri.slice(7);
  filePath = filePath.replace(/\\/g, '/');
  if (/^[A-Za-z]:\//.test(filePath)) return 'file:///' + filePath;
  if (filePath.startsWith('/')) return 'file://' + filePath;
  return 'file:///' + filePath;
}

function normalizeUrisInObject(obj) {
  if (typeof obj === 'string') return obj.startsWith('file://') ? normalizeFileUri(obj) : obj;
  if (Array.isArray(obj)) return obj.map(normalizeUrisInObject);
  if (obj && typeof obj === 'object') {
    const result = {};
    for (const key of Object.keys(obj)) result[key] = normalizeUrisInObject(obj[key]);
    return result;
  }
  return obj;
}

function rewriteClientMessage(data) {
  const text = data.toString('utf8');
  const match = text.match(/^(Content-Length: \d+\r\n(?:[\w-]+: [^\r]*\r\n)*\r\n)([\s\S]*)$/);
  if (!match) return data;
  try {
    const json = JSON.parse(match[2]);
    const rewritten = normalizeUrisInObject(json);
    const body = JSON.stringify(rewritten);
    return Buffer.from('Content-Length: ' + Buffer.byteLength(body) + '\r\n\r\n' + body);
  } catch (e) { return data; }
}

// --- Logging ---

function log(direction, data) {
  const entry = { time: new Date().toISOString(), dir: direction, raw: data.toString('utf8').slice(0, 5000) };
  const text = data.toString('utf8');
  const match = text.match(/Content-Length: \d+\r\n\r\n([\s\S]*)/);
  if (match) {
    try {
      const json = JSON.parse(match[1]);
      entry.method = json.method || '(response)';
      entry.id = json.id;
      if (json.params?.textDocument?.uri) entry.uri = json.params.textDocument.uri;
    } catch (e) {}
  }
  logStream.write(JSON.stringify(entry) + '\n');
}

// --- Auto didOpen Injection ---

const openedFiles = new Set();
const NEEDS_OPEN = new Set([
  'textDocument/hover', 'textDocument/definition', 'textDocument/references',
  'textDocument/documentSymbol', 'textDocument/implementation',
  'textDocument/signatureHelp', 'textDocument/completion',
  'textDocument/codeAction', 'textDocument/rename', 'textDocument/prepareRename',
  'textDocument/formatting', 'textDocument/rangeFormatting',
  'textDocument/foldingRange', 'textDocument/selectionRange',
  'textDocument/documentHighlight', 'textDocument/codeLens',
  'textDocument/inlayHint', 'textDocument/semanticTokens/full',
  'callHierarchy/incomingCalls', 'callHierarchy/outgoingCalls',
  'textDocument/prepareCallHierarchy',
]);

const LANG_MAP = {
  '.ts': 'typescript', '.tsx': 'typescriptreact',
  '.js': 'javascript', '.jsx': 'javascriptreact',
  '.mjs': 'javascript', '.mts': 'typescript',
  '.cjs': 'javascript', '.cts': 'typescript',
  '.py': 'python', '.pyi': 'python',
};

function ensureDidOpen(uri, callback) {
  if (openedFiles.has(uri)) return callback();
  let filePath = uri;
  if (filePath.startsWith('file:///')) filePath = filePath.slice(8);
  else if (filePath.startsWith('file://')) filePath = filePath.slice(7);
  filePath = filePath.replace(/\//g, '\\');
  let text;
  try { text = fs.readFileSync(filePath, 'utf8'); } catch (e) { return callback(); }
  const ext = path.extname(filePath).toLowerCase();
  const languageId = LANG_MAP[ext] || 'plaintext';
  const didOpenMsg = { jsonrpc: '2.0', method: 'textDocument/didOpen',
    params: { textDocument: { uri, languageId, version: 1, text } } };
  const body = JSON.stringify(didOpenMsg);
  const header = 'Content-Length: ' + Buffer.byteLength(body) + '\r\n\r\n';
  openedFiles.add(uri);
  server.stdin.write(Buffer.from(header + body), callback);
}

function trackDidOpen(json) {
  if (json?.method === 'textDocument/didOpen') {
    const uri = json.params?.textDocument?.uri;
    if (uri) openedFiles.add(normalizeFileUri(uri));
  }
  if (json?.method === 'textDocument/didClose') {
    const uri = json.params?.textDocument?.uri;
    if (uri) openedFiles.delete(normalizeFileUri(uri));
  }
}

// --- Server Selection ---
// The shim passes --server-name=<exe-filename> so the proxy knows which backend to spawn.

let serverName = 'typescript-language-server';
const filteredArgs = [];
for (const arg of process.argv.slice(2)) {
  if (arg.startsWith('--server-name=')) serverName = arg.split('=')[1];
  else filteredArgs.push(arg);
}

// Update these paths to match YOUR installation
const home = process.env.USERPROFILE || process.env.HOME;
const SERVER_MAP = {
  'typescript-language-server': path.join(home,
    '.bun', 'install', 'global', 'node_modules',
    'typescript-language-server', 'lib', 'cli.mjs'),
  'pyright-langserver': path.join(home,
    '.bun', 'install', 'global', 'node_modules',
    'pyright', 'langserver.index.js'),
  // npm global paths (uncomment if using npm instead of bun):
  // 'typescript-language-server': path.join(process.env.APPDATA,
  //   'npm', 'node_modules', 'typescript-language-server', 'lib', 'cli.mjs'),
  // 'pyright-langserver': path.join(process.env.APPDATA,
  //   'npm', 'node_modules', 'pyright', 'langserver.index.js'),
};

const realServerScript = process.env.LSP_REAL_SERVER || SERVER_MAP[serverName];
if (!realServerScript) {
  process.stderr.write('LSP-PROXY: Unknown server "' + serverName + '"\n');
  process.exit(1);
}

const server = spawn('node', [realServerScript, ...filteredArgs], {
  stdio: ['pipe', 'pipe', 'pipe'],
  env: { ...process.env, LSP_PROXY_ACTIVE: '1' }
});

// --- Pipe: Claude Code <-> Proxy <-> Server ---

process.stdin.on('data', (data) => {
  log('C->S', data);
  const rewritten = rewriteClientMessage(data);
  const text = rewritten.toString('utf8');
  const bodyMatch = text.match(/Content-Length: \d+\r\n\r\n([\s\S]*)/);
  if (bodyMatch) {
    try {
      const json = JSON.parse(bodyMatch[1]);
      trackDidOpen(json);
      const uri = json.params?.textDocument?.uri;
      if (uri && NEEDS_OPEN.has(json.method) && !openedFiles.has(uri)) {
        ensureDidOpen(uri, () => { server.stdin.write(rewritten); });
        return;
      }
    } catch (e) {}
  }
  server.stdin.write(rewritten);
});

process.stdin.on('end', () => { server.stdin.end(); });
server.stdout.on('data', (data) => { log('S->C', data); process.stdout.write(data); });
server.stderr.on('data', (data) => { process.stderr.write(data); });
server.on('close', (code) => { logStream.end(); process.exit(code || 0); });
server.on('error', (err) => { logStream.end(); process.exit(1); });
process.on('SIGTERM', () => server.kill('SIGTERM'));
process.on('SIGINT', () => server.kill('SIGINT'));

</details>

Step 4: Compile the shims

Both .exe files are built from the same .cs source — only the output filename differs:

cd "$env:USERPROFILE\.local\bin\lsp-proxy"

# Build typescript-language-server shim
C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe /out:..\typescript-language-server.exe /target:exe typescript-language-server.cs

# Build pyright-langserver shim (same source, different output name)
C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe /out:..\pyright-langserver.exe /target:exe typescript-language-server.cs
Step 5: Update SERVER_MAP paths in lsp-proxy.js

The SERVER_MAP object maps server names to the real entry script. Update paths based on your package manager:

# bun global install:
%USERPROFILE%\.bun\install\global\node_modules\typescript-language-server\lib\cli.mjs
%USERPROFILE%\.bun\install\global\node_modules\pyright\langserver.index.js

# npm global install:
%APPDATA%\npm\node_modules\typescript-language-server\lib\cli.mjs
%APPDATA%\npm\node_modules\pyright\langserver.index.js

Or override per-server with environment variable: set LSP_REAL_SERVER=C:\path\to\server.js

Step 6: Restart Claude Code

The proxy starts automatically when Claude Code spawns an LSP server. You should see the shim processes in Task Manager.

Logs are written to %USERPROFILE%\.local\bin\lsp-proxy\lsp-proxy-YYYY-MM-DD.jsonl.

---

Note for the Claude Code team

The proper fix should be straightforward: send textDocument/didOpen before any document-level LSP request on Windows. The proxy workaround confirms this is the only missing piece — once didOpen is sent, all LSP operations work correctly. Additionally, normalizing file:// URIs for Windows paths would prevent the Unexpected resource errors.

Tested on Windows 10 Pro (10.0.19045) with Claude Code 2.1.47+, typescript-language-server 5.1.3, and pyright 1.1.x.

pdebuitlear · 6 months ago

Still reproduces as of 2.1.50

sstklen · 6 months ago

Hey! I ran into a similar pattern in our bug knowledge base and thought this might help.

What's happening: Two distinct Windows-specific bugs in Claude Code's LSP client: (1) Node.js spawn() is called without shell:true, so it cannot locate .cmd/.ps1 shims that npm/yarn create for LSP server binaries on Windows (e.g., typescript-language-server.cmd). (2) After resolving the spawn issue, Claude Code's LSP client does NOT send textDocument/didOpen notifications before issuing document-level requests (hover, documentSymbol, references, goToDefinition). Per the LSP specification, the server MUST receive didOpen for a document before it can respond to any document-scoped requests. workspace/symbol works because it is not document-scoped. This is a Windows-only regression because on macOS/Linux the LSP binary is found directly and the didOpen lifecycle apparently works correctly.

What worked for us:

The proposed fix correctly identifies the need for textDocument/didClose notifications to prevent memory leaks in the LSP server, but the provided code snippet for the fix does not include this. An improved fix would explicitly show how and when to send textDocument/didClose for a document no longer in use, perhaps when a tab is closed or the editor session ends for that file. Additionally, for getLanguageId(filePath), a common implementation would involve a map of file extensions to LSP language IDs (e.g., .ts -> typescript, .js -> javascript, .py -> python).

// Fix #1: Windows spawn
const spawnOptions = process.platform === 'win32' ? { shell: true, ...opts } : opts;
const proc = spawn(command, args, spawnOptions);

// Fix #2: Ensure didOpen before document-level requests
const openedDocs = new Set<string>();

async function ensureDocumentOpen(client, uri: string) {
  if (openedDocs.has(uri)) return;
  const filePath = URI.parse(uri).fsPath;
  const text = await fs.readFile(filePath, 'utf-8');
  const languageId = getLanguageId(filePath); // e.g., 'typescript' for .ts
  client.sendNotification('textDocument/didOpen', {
    textDocument: { uri, languageId, version: 1, text }
  });
  openedDocs.add(uri);
}

// Before any document-level request:
await ensureDocumentOpen(client, params.textDocument.uri);
const result = await client.sendRequest(method, params);

📊 _We found 5 similar cases in our knowledge base with the same pattern — this gives us high confidence in this analysis._

Hope this helps! Let me know if it doesn't match your case — happy to dig deeper. 🦞

_Disclosure: This analysis is from Confucius Debug, an AI-powered community KB for agent bugs. Please verify before applying._

---
<sub>🦞 Confucius Debug — community knowledge base for AI agent bugs. Free to search via MCP.</sub>

pdebuitlear · 5 months ago

still reproduces as of v2.1.71

pdebuitlear · 5 months ago

Still reproducing on v2.1.87

pdebuitlear · 4 months ago

still reproducing in v2.1.97

vino24 · 3 months ago

resolved in v2.1.132

ChanghoSong · 3 months ago

최신 버전에서 수정 확인됨. proxy shim 없이 LSP hover/definition/references 정상 동작 확인.

ChanghoSong · 3 months ago

Confirmed Fixed in v2.1.132+

Can confirm the fix works on my end. Tested on Windows 10 Pro (10.0.19045) with Claude Code v2.1.132+.

What I tested

After the fix landed, I removed the proxy shim workaround I had built (the C# .exe shims + lsp-proxy.js that auto-injected textDocument/didOpen) and retested all three LSP servers from scratch:

| Server | Operation | Before Fix | After Fix |
|--------|-----------|:---:|:---:|
| typescript-language-server | documentSymbol | ❌ empty | ✅ full symbol tree |
| typescript-language-server | hover | ❌ empty | ✅ type info |
| typescript-language-server | goToDefinition | ❌ empty | ✅ correct file:line |
| typescript-language-server | findReferences | ❌ empty | ✅ all refs found |
| pyright-langserver | documentSymbol | ❌ empty | ✅ functions + variables |
| pyright-langserver | hover | ❌ empty | ✅ signature + docstring |
| pyright-langserver | goToDefinition | ❌ empty | ✅ jumps to typeshed |
| pyright-langserver | findReferences | ❌ empty | ✅ all refs found |
| rust-analyzer | documentSymbol | ❌ (not tested before) | ✅ full symbol tree |

All document-level LSP methods now work correctly without any workaround. The proxy shim is no longer needed and has been removed.

Root cause recap

The issue was that Claude Code's LSP client was not sending textDocument/didOpen before document-level requests on Windows, causing the LSP server to return empty results with Unexpected resource errors internally. The workspaceSymbol method worked because it doesn't require a document to be opened first. The fix in v2.1.132 ensures didOpen is properly sent, making all document-scoped operations functional.

Thanks to the team for the quick fix, and to @vino24 for flagging the version!

pdebuitlear · 3 months ago

This is not fixed for workspaceSymbol using jdtls on windows, all other LSP tools work in jdtls using Claude Code v2.1.138 except workspaceSymbol.

muckybuzzwoo · 2 months ago

Still getting ENOENT in v2.1.161 with official marketplace plugins

Environment: Windows 11 Home (10.0.26200), Claude Code v2.1.161, both LSP servers installed via npm globally.

While the textDocument/didOpen fix (Issue #2) is confirmed working, the ENOENT spawn issue (Issue #1) still affects users of the official marketplace plugins (php-lsp@claude-plugins-official, typescript-lsp@claude-plugins-official):

Error performing documentSymbol: ENOENT: no such file or directory, uv_spawn 'intelephense'
Error performing documentSymbol: ENOENT: no such file or directory, uv_spawn 'typescript-language-server'

Root cause: the official marketplace.json still specifies bare command names without the .cmd extension:

"intelephense": { "command": "intelephense", ... }
"typescript":   { "command": "typescript-language-server", ... }

Manually patching these to "intelephense.cmd" / "typescript-language-server.cmd" fixes the spawn immediately. So the underlying LSP implementation is fine — the marketplace plugin config just hasn't been updated for Windows.

Could the official plugin definitions be updated to use .cmd on Windows, or could Claude Code fall back to .cmd automatically when a bare command returns ENOENT on Windows?

Showing cached comments. Read the full discussion on GitHub ↗