# Step 53: LSP 集成

一句话导读:接入 LSP(语言服务器协议),让 AI 改完代码后能拿到客观的编译诊断并自我纠错——而它的客户端骨架几乎就是 step47 手写 MCP 的复制品,唯一实质差异只有「消息分帧」这一处。


# 一、这一步做了什么(What)

接入 LSP(Language Server Protocol),把主观的「我觉得这代码对」换成客观的「编译器说它真的过」。三个新文件加一套接线:

  1. services/lsp/client.ts:手写 LSP 客户端——长度前缀分帧 + initialize 握手 + didOpen + 收诊断 + 按扩展名路由;
  2. services/lsp/exampleServer.mjs:自带的玩具 LSP server(跑正则:var→警告、;;→错误、TODO→提示),永远能跑;
  3. services/lsp/passiveFeedback.ts:被动反馈闭环——Write 文件后自动诊断,把报错追加进工具结果喂回模型;
  4. tools/diagnostics.ts 的 getDiagnostics 工具(对照真实的 mcp__ide__getDiagnostics)+ /lsp 命令。

LSP_SERVERS 里除了玩具 server,还默认启用了真实的 typescript-language-server。启动时工具数 37→38。


# 二、面试官视角:为什么 LSP 排在 MCP 之后,而不是从头写?(Why)

面试题:LSP 和 MCP 是两个完全不同的协议(一个管语言智能、一个管工具调用),为什么你说手写 MCP 客户端的代码「几乎能直接搬过来」写 LSP?它们到底共享了什么?

因为二者是同一套骨架的两个应用。把它们并排看:

MCP(step47) LSP(本步)
协议 JSON-RPC 2.0 JSON-RPC 2.0
通信 spawn 子进程 + stdio spawn 子进程 + stdio
握手 initialize → initialized initialize → initialized
消息分帧 一行一个 JSON(\n 分隔) Content-Length: N\r\n\r\n + JSON(长度前缀)

唯一实质差异是分帧方式。 请求-响应按 id 配对、通知无 id、握手时序——全都一样。所以把 LSP 排在 MCP 之后是有意的教学顺序:一旦你手写过一个 JSON-RPC-over-stdio 客户端,第二个只需要换掉分帧那一小块。这也揭示一个更深的点:「协议骨架」是可复用的资产——真正让每个协议不同的,往往只是外层的封帧和内层的消息语义,中间的传输机制高度同构。

那为什么 LSP 偏要用长度前缀而不是像 MCP 那样换行分帧?因为 LSP 的 JSON 里可能塞着整段源代码(didOpen 要把文件内容发过去),源代码自带换行,用 \n 分帧会把一条消息从中间切断。长度前缀 Content-Length: N 明确告诉你「接下来 N 个字节是一条完整消息」,内容里有多少换行都不影响。


# 三、原理:它是怎么工作的(How)

# 反直觉点:诊断是「推」来的,不是「问」来的

最容易想错的地方:你不是「问服务器某文件有没有错」,而是把文件内容 didOpen 发过去,服务器分析完主动 publishDiagnostics 推回来。

客户端 ──textDocument/didOpen(把文件内容也发过去)──→ 语言服务器
客户端 ←──textDocument/publishDiagnostics(分析完主动推回)── 语言服务器
1
2

所以 openAndDiagnose() 的本质是「打开文档 → 等服务器把诊断推回来」,而不是发一个请求同步拿结果。客户端要缓存服务器推来的诊断,并「等」它到达:

this.notifyHandlers.set("textDocument/publishDiagnostics", (p) => {
  const key = this.normUri(p.uri);
  this.diagnostics.set(key, p.diagnostics ?? []);
  this.lastDiagAt.set(key, Date.now());
});
1
2
3
4
5

# 长度前缀分帧(读端)

题眼代码——从字节流里按 Content-Length 精确切出整条消息(一条可能分几次到达,也可能一次到好几条):

private onData(chunk: Buffer): void {
  this.buf = Buffer.concat([this.buf, chunk]);
  while (true) {
    const sep = this.buf.indexOf("\r\n\r\n");    // 头体分界
    if (sep === -1) return;                       // 头还没收全
    const header = this.buf.subarray(0, sep).toString("ascii");
    const len = parseInt(/Content-Length:\s*(\d+)/i.exec(header)![1], 10);
    const start = sep + 4;
    if (this.buf.length < start + len) return;    // 体还没收全,等更多数据
    const body = this.buf.subarray(start, start + len).toString("utf-8");
    this.buf = this.buf.subarray(start + len);    // 消费掉这条,留剩余给下一轮
    this.dispatch(JSON.parse(body));
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 闭环自纠:主动 vs 被动

LSP 对 AI 的价值不是审美,是把「能写代码」变成「能交付能跑的代码」。两种触发方式:

主动(getDiagnostics 工具) 被动(passiveFeedback 钩子)
谁发起 模型自己决定调 系统在 Write 后自动跑
机制 一个 ToolDef step36 的 PostToolUse 钩子返回 { result } 追加文本
效果 「让我查一下有没有错」 「你刚写的文件有这几个错,去修」直接塞回结果

被动反馈复用了扩展名路由 + 严重级过滤(只报错误/警告,跳过 info/hint):

hooks.onPostToolUse("lsp-passive-feedback", async (e) => {
  if (e.tool !== "Write" || e.isError) return;              // 只在写文件成功后
  const languageId = languageIdFor(e.input.file_path);
  const targets = getClients().filter((c) => c.handles(languageId)); // 扩展名路由
  // ... openAndDiagnose 拿诊断,只留 severity ≤ 2 的
  return { result: e.result + `\n\n⚠ 语言诊断...` + problems.join("\n") };
});
1
2
3
4
5
6
7

实测:写 const port: number = "8080" 会自动把 Type 'string' is not assignable to type 'number'(code 2322)追加进 Write 结果,模型下一轮就看到并去修。


# 四、深入追问(面试常见 follow-up)

Q:诊断是「推」的,你怎么知道「推完了」,什么时候该返回?如果服务器先推一条空的呢? A:这正是最大的坑。真实服务器在 didOpen 瞬间常先推一条空诊断,语义分析完再推带错误的。如果「收到第一条就返回」就会拿到空的假结论。解法是 waitDiagnostics 用防抖:收到诊断后再等一个 700ms 的「安静期」,期间没有新推送才算稳定,取最后一次;到硬上限(8s)无论如何返回当前所有。

private waitDiagnostics(uri, waitMs, quietMs = 700) {
  // 收到诊断后等 quietMs 安静期没新推送 → resolve 最后一次;超 waitMs → 兜底返回
}
1
2
3

Q:你说连玩具 server 一次就通,连真实 typescript-language-server 却拿到 0 条诊断,为什么? A:URI 不匹配。真实服务器回推诊断时会把 uri 重规范化(d:→d%3A、中文百分号编码),和我们 pathToFileURL 生成的字符串不逐字相等。我们按原始字符串当 map 的 key 去查,永远查不到 → 0 条。修法是 key 统一 decodeURIComponent 归一(normUri)后再比对。玩具 server 原样返回 uri,所以永远碰不到这个坑——这就是「连真实服务器」的教学价值:协议骨架很快通,真正的坑全在边界细节。

Q:一个 .py 文件,为什么不能发给 TypeScript 语言服务器让它自己判断? A:会白等——TS 服务器对 python 无能为力,还占着 8s 超时。所以每个 server 声明 languages,用扩展名 → languageId → 匹配 server 做精确路由。handles() 是判据:含 *(玩具 catch-all)来者不拒,否则只处理声明过的语言。.ts 发给 demo() + ts,.py 只发给 demo(),ts 被排除。

Q:主动工具和被动钩子,为什么两个都要?留一个不行吗? A:覆盖不同场景。主动 getDiagnostics 让模型在想确认时自查(「我改完了,查一下」);被动钩子保证即使模型忘了查,写完文件也会被强制喂回报错。前者靠模型自觉,后者是系统兜底——两者叠加才形成可靠的「写→查→修」闭环。


# 五、踩坑 / 设计权衡

两个「连真实 server 才暴露」的边界 bug(玩具 server 碰不到):

Bug 根因 修法
URI 不匹配 服务器回推时重规范化 uri(d:→d%3A),和 pathToFileURL 不逐字相等 key 统一 decodeURIComponent 归一
空诊断竞速 didOpen 瞬间先推空,语义分析完再推带错的 waitDiagnostics 改防抖,等 700ms 安静期取最后一次

教训:玩具 server 一次推完、uri 原样返回,永远碰不到这些。协议骨架(分帧/握手/didOpen)几小时就通,真正吃时间的是 URI 规范化、异步推送时序这类真实世界的边界。


# 六、与真实源码的对照

我们的实现 Claude Code 源码
手写 Content-Length 分帧 LSPClient.ts 用 vscode-jsonrpc 库做同样的事
玩具 server + 真实 typescript-language-server 连各语言服务器(pyright/gopls/jdtls…)
按 languages 扩展名路由 config.ts 里每个 server 声明支持的语言/文件模式
只做诊断 还有跳转/引用/悬停/补全,及崩溃重启
LSP_SERVERS 写死配置 LSP server 只能由插件贡献 + 按 scope 合并

# 七、一句话总结

LSP = 复用 MCP 的 JSON-RPC 骨架,只换分帧(长度前缀),做一件事:让编译器主动把诊断推回来喂给 AI;主动工具 + 被动钩子叠成「写→查→修」闭环,而真正的难点全在 URI 归一和防抖这类连玩具 server 碰不到的边界细节。

# 下一节预告

下一步(跳过 step54 OAuth)进入 step55 本地 REPL Bridge——把正在跑的会话「桥」给第二个前端,让另一个终端也能看到实时输出并发指令。