修复 Claude Code LSP 在 Windows 上返回空结果的问题
Windows 上 Claude Code 的 LSP 工具(hover、findReferences、goToDefinition 等)返回空结果,根本原因有两个:
- Claude Code 只识别
.exe后缀的语言服务器,而 npm 全局安装的是.cmd文件 - Claude Code 的 LSP 客户端在发起请求前不发送
textDocument/didOpen,导致服务器返回空
修复步骤:用 Chocolatey 的 shimgen 创建 .exe shim,再写一个 Node.js 代理自动注入 didOpen。
相关 issue:
背景
Claude Code 提供了 LSP 工具,可以在终端中直接使用 hover 查看类型、goToDefinition 跳转定义、findReferences 查找引用等能力。要使用这个功能,需要:
- 安装 Claude Code 官方插件市场(如果还没装的话)
- 安装 TypeScript LSP 插件:在 Claude Code 中运行
/plugins,搜索并安装typescript-lsp@claude-plugins-official - 全局安装语言服务器:
npm install -g typescript-language-server typescript
安装完成后,在 Windows 上会遇到两个问题:
- LSP 服务器完全无法启动(spawn ENOENT)
- 即使服务器启动了,所有文档级操作都返回空结果
排查过程
工具准备
排查过程中用到了 GitHub CLI(gh)来查看 issue 详情。
安装:winget install GitHub.cli 或从 cli.github.com/ 下载。
安装后需要登录:
gh auth login
登录时会让你选择认证协议。建议选 HTTPS,选 SSH 可能会导致 API 请求报错 Post "https://api.github.com/graphql": EOF,gh issue view、gh search issues 等命令都查不到内容。
# 如果之前选了 SSH,可以重新登录切换成 HTTPS 就正常了
gh auth login -p https
定位问题
通过搜索 GitHub issues,找到了上面三个相关 issue。核心结论:
问题一:.cmd 不被识别
Claude Code 在 Windows 上通过 spawn() 启动语言服务器时,只能找到 .exe 和 .com 后缀的可执行文件。而通过 npm 全局安装的包(包括 typescript-language-server、pyright、vtsls 等所有 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 等)
# 如果还没装语言服务器
npm install -g typescript-language-server typescript
确认 shimgen 存在:
ls "C:/ProgramData/chocolatey/tools/shimgen.exe"
确认 typescript-language-server 的实际入口文件路径:
# 如果用 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 代理
创建目录和代理脚本:
mkdir -p ~/.local/bin/lsp-proxy
将以下内容保存为 ~/.local/bin/lsp-proxy/lsp-proxy.js:
/**
* 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 能识别并启动代理:
# 找到你的 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 工具应该能正常工作。调用链路为:
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 中测试:
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 即可:
typescript-language-server.cmd --version
# 5.1.3
回退
如果想恢复原状,直接删除 .exe 文件即可:
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 可能会失败