修复 Claude Code LSP 在 Windows 上返回空结果的问题

修复 Claude Code LSP 在 Windows 上返回空结果的问题

Windows 上 Claude Code 的 LSP 工具(hover、findReferences、goToDefinition 等)返回空结果,根本原因有两个:

  1. Claude Code 只识别 .exe 后缀的语言服务器,而 npm 全局安装的是 .cmd 文件
  2. Claude Code 的 LSP 客户端在发起请求前不发送 textDocument/didOpen,导致服务器返回空

修复步骤:用 Chocolatey 的 shimgen 创建 .exe shim,再写一个 Node.js 代理自动注入 didOpen

相关 issue:

  • #17312 --- 空结果问题(核心)
  • #15914 --- Windows .cmd 不识别问题
  • #29858 --- 插件初始化竞态条件

背景

Claude Code 提供了 LSP 工具,可以在终端中直接使用 hover 查看类型、goToDefinition 跳转定义、findReferences 查找引用等能力。要使用这个功能,需要:

  1. 安装 Claude Code 官方插件市场(如果还没装的话)
  2. 安装 TypeScript LSP 插件:在 Claude Code 中运行 /plugins,搜索并安装 typescript-lsp@claude-plugins-official
  3. 全局安装语言服务器:npm install -g typescript-language-server typescript

安装完成后,在 Windows 上会遇到两个问题:

  1. LSP 服务器完全无法启动(spawn ENOENT)
  2. 即使服务器启动了,所有文档级操作都返回空结果

排查过程

工具准备

排查过程中用到了 GitHub CLI(gh)来查看 issue 详情。

安装:winget install GitHub.cli 或从 cli.github.com/ 下载。

安装后需要登录:

bash 复制代码
gh auth login

登录时会让你选择认证协议。建议选 HTTPS ,选 SSH 可能会导致 API 请求报错 Post "https://api.github.com/graphql": EOFgh issue viewgh search issues 等命令都查不到内容。

bash 复制代码
# 如果之前选了 SSH,可以重新登录切换成 HTTPS 就正常了
gh auth login -p https

定位问题

通过搜索 GitHub issues,找到了上面三个相关 issue。核心结论:

问题一:.cmd 不被识别

Claude Code 在 Windows 上通过 spawn() 启动语言服务器时,只能找到 .exe.com 后缀的可执行文件。而通过 npm 全局安装的包(包括 typescript-language-serverpyrightvtsls 等所有 LSP 服务器)在 Windows 上实际上都是 .cmd 文件(Windows 批处理脚本),spawn() 不带 shell: true 无法执行。

也就是说,通过 npm 安装的 LSP 服务器,在 Windows 上很可能遇到这个问题,不限于 TypeScript。

问题二:缺少 didOpen 通知

LSP 协议规定,客户端在对某个文件发起 hover、references 等请求之前,必须先发送 textDocument/didOpen 通知,告知服务器该文件的完整内容。Claude Code 的 LSP 客户端在 Windows 上没有发送这个通知,导致服务器对所有文档级请求返回空。

修复方案

前置条件

  • 已安装 Chocolatey(需要其 shimgen.exe 工具,安装 Chocolatey 后自带)
  • 已全局安装 typescript-language-server(通过 npm、volta 等)
bash 复制代码
# 如果还没装语言服务器
npm install -g typescript-language-server typescript

确认 shimgen 存在:

bash 复制代码
ls "C:/ProgramData/chocolatey/tools/shimgen.exe"

确认 typescript-language-server 的实际入口文件路径:

bash 复制代码
# 如果用 volta
find "$LOCALAPPDATA/Volta" -name "cli.mjs" -path "*typescript-language-server*"
# 如果用 npm
find "$APPDATA/npm/node_modules" -name "cli.mjs" -path "*typescript-language-server*"

记下这个路径,后面要用。

第一步:创建 LSP 代理

创建目录和代理脚本:

bash 复制代码
mkdir -p ~/.local/bin/lsp-proxy

将以下内容保存为 ~/.local/bin/lsp-proxy/lsp-proxy.js

javascript 复制代码
/**
 * LSP Proxy for Windows
 * Fixes Claude Code LSP issues:
 * 1. Normalizes file:// URIs (backslash → forward slash)
 * 2. Auto-injects textDocument/didOpen for unopened files
 */
const { spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
const { fileURLToPath } = require('url');

// --- Config ---
const args = process.argv.slice(2);
const serverNameArg = args.find(a => a.startsWith('--server-name='));
const serverName = serverNameArg ? serverNameArg.split('=')[1] : 'typescript-language-server';
const filteredArgs = args.filter(a => !a.startsWith('--server-name='));

// Real server paths (full path to avoid circular exe resolution)
const SERVER_MAP = {
  'typescript-language-server': path.join(
    process.env.LOCALAPPDATA,
    'Volta/tools/image/packages/typescript-language-server/node_modules/typescript-language-server/lib/cli.mjs'
  ),
};

const cliPath = SERVER_MAP[serverName];
if (!cliPath) {
  process.stderr.write(`Unknown server: ${serverName}\n`);
  process.exit(1);
}

const lsp = spawn(process.execPath, [cliPath, ...filteredArgs], {
  stdio: ['pipe', 'pipe', 'pipe'],
});

const DOC_STATE = {
  PROXY_OPENED: 'proxy-opened',
  CLIENT_OPENED: 'client-opened',
};

const openedDocs = new Map();

// --- Helpers ---
function normalizeUri(uri) {
  if (!uri || !uri.startsWith('file:')) return uri;
  return uri.replace(/^file:\/\/([A-Za-z]):/, 'file:///$1:').replace(/\\/g, '/');
}

function deepNormalizeUris(obj) {
  if (typeof obj !== 'object' || obj === null) return obj;
  if (Array.isArray(obj)) return obj.map(deepNormalizeUris);
  const out = {};
  for (const [k, v] of Object.entries(obj)) {
    if ((k === 'uri' || k === 'targetUri') && typeof v === 'string') out[k] = normalizeUri(v);
    else if (typeof v === 'object') out[k] = deepNormalizeUris(v);
    else out[k] = v;
  }
  return out;
}

function encodeMessage(msg) {
  const json = JSON.stringify(msg);
  const len = Buffer.byteLength(json, 'utf8');
  return Buffer.from(`Content-Length: ${len}\r\n\r\n${json}`, 'utf8');
}

function getLangId(uri) {
  let ext = '';
  try {
    ext = path.extname(fileURLToPath(uri)).toLowerCase();
  } catch {
    ext = path.extname(uri).toLowerCase();
  }
  return (
    {
      '.ts': 'typescript',
      '.tsx': 'typescriptreact',
      '.js': 'javascript',
      '.jsx': 'javascriptreact',
      '.vue': 'vue',
    }[ext] || 'plaintext'
  );
}

function uriToPath(uri) {
  return fileURLToPath(uri);
}

function shouldEnsureDidOpen(msg) {
  if (!msg || typeof msg !== 'object') return false;
  if (typeof msg.method !== 'string') return false;
  if (!msg.method.startsWith('textDocument/')) return false;
  if (msg.method === 'textDocument/didOpen' || msg.method === 'textDocument/didClose') return false;
  return typeof msg.params?.textDocument?.uri === 'string';
}

function ensureDidOpen(msg) {
  const uri = msg.params?.textDocument?.uri;
  if (!uri || openedDocs.has(uri)) return;

  let text;
  try {
    text = fs.readFileSync(uriToPath(uri), 'utf8');
  } catch {
    return;
  }

  lsp.stdin.write(
    encodeMessage({
      jsonrpc: '2.0',
      method: 'textDocument/didOpen',
      params: {
        textDocument: { uri, languageId: getLangId(uri), version: 1, text },
      },
    })
  );
  openedDocs.set(uri, DOC_STATE.PROXY_OPENED);
}

function createDidChangeFromDidOpen(msg) {
  const textDocument = msg.params?.textDocument;
  if (!textDocument?.uri || typeof textDocument.text !== 'string') return null;

  return {
    jsonrpc: msg.jsonrpc || '2.0',
    method: 'textDocument/didChange',
    params: {
      textDocument: {
        uri: textDocument.uri,
        version: textDocument.version ?? 1,
      },
      contentChanges: [{ text: textDocument.text }],
    },
  };
}

function prepareClientMessage(msg) {
  if (shouldEnsureDidOpen(msg)) {
    ensureDidOpen(msg);
  }

  const method = msg.method;
  const uri = msg.params?.textDocument?.uri;
  if (!uri) return msg;

  if (method === 'textDocument/didOpen') {
    const state = openedDocs.get(uri);
    if (!state) {
      openedDocs.set(uri, DOC_STATE.CLIENT_OPENED);
      return msg;
    }

    if (state === DOC_STATE.PROXY_OPENED) {
      openedDocs.set(uri, DOC_STATE.CLIENT_OPENED);
      return createDidChangeFromDidOpen(msg);
    }

    process.stderr.write(`Proxy warning: duplicate didOpen ignored for ${uri}\n`);
    return null;
  }

  if (method === 'textDocument/didClose') {
    if (!openedDocs.has(uri)) {
      process.stderr.write(`Proxy warning: stray didClose ignored for ${uri}\n`);
      return null;
    }

    openedDocs.delete(uri);
    return msg;
  }

  return msg;
}

// --- Message stream parser ---
function createParser(onMessage) {
  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 header = buf.slice(0, headerEnd).toString('ascii');
      const m = header.match(/Content-Length:\s*(\d+)/i);
      if (!m) {
        buf = buf.slice(headerEnd + 4);
        continue;
      }
      const len = parseInt(m[1], 10);
      const start = headerEnd + 4;
      if (buf.length < start + len) break;
      const body = buf.slice(start, start + len).toString('utf8');
      buf = buf.slice(start + len);
      try {
        onMessage(JSON.parse(body));
      } catch (err) {
        process.stderr.write(`Proxy parse error: ${err.message}\n`);
      }
    }
  };
}

// --- Pipes ---
// Client → Proxy → Server
process.stdin.on('data',
  createParser((msg) => {
    const normalized = deepNormalizeUris(msg);
    const prepared = prepareClientMessage(normalized);
    if (prepared) {
      lsp.stdin.write(encodeMessage(prepared));
    }
  })
);

process.stdin.on('end', () => {
  lsp.stdin.end();
});

// Server → Proxy → Client
lsp.stdout.on('data',
  createParser((msg) => {
    process.stdout.write(encodeMessage(msg));
  })
);

// Stderr passthrough
lsp.stderr.pipe(process.stderr);

// Lifecycle
lsp.on('exit', (code) => process.exit(code ?? 0));
lsp.on('error', (e) => {
  process.stderr.write(`Proxy error: ${e.message}\n`);
  process.exit(1);
});

function shutdown(signal) {
  if (!lsp.stdin.destroyed) {
    lsp.stdin.end();
  }
  if (!lsp.killed) {
    lsp.kill(signal);
  }
}

process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));

第二步:创建 .exe shim

使用 Chocolatey 的 shimgen 创建一个 .exe,让 Claude Code 能识别并启动代理:

bash 复制代码
# 找到你的 typescript-language-server 所在目录
where typescript-language-server
# 比如输出:C:\Users\<user>\AppData\Local\Volta\bin\typescript-language-server.cmd

# 找到 node.exe 路径
where node
# 比如输出:C:\Users\<user>\AppData\Local\Volta\tools\image\node\22.20.0\node.exe

# 在同目录下创建 .exe shim(路径替换为你自己的)
"C:/ProgramData/chocolatey/tools/shimgen.exe" \
  -o="C:/Users/<user>/AppData/Local/Volta/bin/typescript-language-server.exe" \
  -p="C:/Users/<user>/AppData/Local/Volta/tools/image/node/22.20.0/node.exe" \
  -c="\"C:/Users/<user>/.local/bin/lsp-proxy/lsp-proxy.js\" --server-name=typescript-language-server"

第三步:重启 Claude Code

重启后 LSP 工具应该能正常工作。调用链路为:

scss 复制代码
Claude Code
  -> typescript-language-server.exe (shimgen shim)
    -> node lsp-proxy.js --server-name=typescript-language-server --stdio
      -> node cli.mjs --stdio (真实的语言服务器)

代理在中间自动完成:

  • Windows 文件 URI 格式标准化(file://C:\ -> file:///C:/
  • 在每个文档级请求前注入 textDocument/didOpen

验证

在 Claude Code 中测试:

scss 复制代码
LSP hover on line 7, character 18 of src/views/manage/userManage/type.ts
LSP findReferences on the same position

如果返回了类型信息和引用列表,说明修复成功。

注意:创建 shim 后,typescript-language-server.exe --version 不会输出版本号,这是正常的。因为代理只转发 LSP 协议消息(带 Content-Length 头的 JSON-RPC),而 --version 是纯文本输出,不会被代理转发。不影响实际使用。如果需要查看版本号,用 .cmd 即可:

bash 复制代码
typescript-language-server.cmd --version
# 5.1.3

回退

如果想恢复原状,直接删除 .exe 文件即可:

bash 复制代码
rm "$(where typescript-language-server.exe)"

删除后 Claude Code 会回退到找不到 .exe 的状态(即 LSP 不可用),但不影响其他任何功能。

备注

  • 这是社区 workaround,不是官方修复。相关 issue 截至 2026 年 3 月仍为 Open 状态
  • 如果 lsp-proxy.js 中的 SERVER_MAP 路径不对,需要手动修改为你本地的实际路径
  • 如果使用 npm 而非 volta 管理包,路径格式会不同,注意调整
  • gh 工具登录时选 HTTPS 协议,不要选 SSH,否则查 issue 可能会失败
相关推荐
chaors3 小时前
Langchain入门到精通0x05:预制链
人工智能·langchain·ai编程
chaors3 小时前
Langchain入门到精通0x04:大模型怎么记忆?
人工智能·langchain·ai编程
NikoAI编程3 小时前
软件开发生命周期已死?AI 编码智能体如何颠覆 SDLC
ai编程·claude
chaors3 小时前
Langchain入门到精通0x03:Langchain的服务部署
人工智能·langchain·ai编程
梨儿超4 小时前
AI 写代码靠谱吗?7 轮 QA 告诉你真相
ai编程
ZoeLandia4 小时前
Claude 结合 spec-kit 使用指南
ai编程·claude·spec-kit
甲枫叶4 小时前
【openclaw】我用 OpenClaw 自动化了这些工作
java·python·自动化·ai编程