# Step 51: 用官方 SDK 重写 MCP 客户端
一句话导读:把 step47-50 手写的 MCP 客户端内部实现整个换成真实 Claude Code 同款的官方
@modelcontextprotocol/sdk,而对外接口一个字符不改——index.ts、命令、工具包装全部零改动,这是「换引擎、不换接口」的教科书级重构。
# 一、这一步做了什么(What)
services/mcp/client.ts 被内部重写:
- 删掉手写的
spawn+ 逐行读 stdout + 手拼 JSON-RPC + 手动按 id 配对那一整套; - 换成官方 SDK 的
new Client()+client.connect(transport)+client.listTools()/callTool(); - 顺手解锁多 transport:
McpServerConfig加了type(stdio/sse/http)和url,现在能连远程 HTTP server; - 带了一个真实可跑的 HTTP server 示例
exampleHttpServer.mjs(监听 :39100,暴露now/upper两个工具)。
关键结果:McpClient 的公开方法(connect / callTool / readResource / getPrompt / close)和导出(mcpToolDefs / MCP_SERVERS)签名全保住,所以上层完全无感知。实测 SDK 版既能连我们手写的 demo server,也能连真实 playwright(23 工具),返回值和手写版逐字一致。
# 二、面试官视角:既然手写版能用,为什么要重写?(Why)
面试题:手写的 MCP 客户端 step50 都已经驱动真实浏览器了,功能没问题,为什么还要推倒重来换 SDK?重写的收益和风险你怎么权衡?
先说为什么当初要手写:step47 手写是为了学协议——亲手写 stdio 分帧、JSON-RPC 收发、initialize 握手,才算真懂 MCP。教学价值已经吃到了。
再说为什么现在要换 SDK,三条实打实的收益:
- 健壮性:握手、协议版本协商、通知路由、错误处理这些细节,手写版只覆盖了 happy path。SDK 是官方维护、被无数 server 打磨过的,边界情况稳得多。
- 解锁远程 transport:手写版死死绑在 stdio(spawn 本地进程)上。SDK 把 stdio / SSE / HTTP 抽象成统一的
Transport接口,一个分支就能连别处长驻的远程 server——这是手写 stdio 版根本做不到的能力升级。 - 和真实一致:真实 Claude Code 就是用这个包。换过来后我们的实现和线上对齐,对照学习更直接。
风险控制的关键在于:接口被钉死。因为公开方法签名一个不动,重写就被限制在一个文件内部,上层零改动 = 回归面极小。这正是「换引擎不换接口」重构安全的根本——把爆炸半径锁在实现层。
# 三、原理:它是怎么工作的(How)
# 手写版 vs SDK 版的一一对应
手写版 SDK 版
────── ──────
spawn + 手写 onData 按行拆 JSON new Client() + new StdioClientTransport()
request("initialize",...) client.connect(transport) // 自动握手
request("tools/list") client.listTools()
request("tools/call",...) client.callTool({name,arguments})
↑ 对外方法名/返回值完全一样,index.ts 一行不动
2
3
4
5
6
7
# 多 transport:手写版做不到的那一步
题眼是这个 makeTransport()——按配置的 type 分支选 transport:
private makeTransport() {
const t = this.cfg.type ?? "stdio";
if (t === "sse") return new SSEClientTransport(new URL(this.cfg.url!));
if (t === "http") return new StreamableHTTPClientTransport(new URL(this.cfg.url!));
// stdio:spawn 本地进程。stderr:'ignore' 避免 server 日志刷屏(真实版用 'pipe' 收进日志)。
return new StdioClientTransport({
command: this.cfg.command!,
args: this.cfg.args ?? [],
stderr: "ignore",
});
}
2
3
4
5
6
7
8
9
10
11
connect 就变得极简,握手交给 SDK:
async connect(): Promise<McpToolDef[]> {
await this.client.connect(this.makeTransport()); // 内部自动 initialize
this.tools = ((await this.client.listTools()).tools ?? []) as McpToolDef[];
// ... listResources / listPrompts 同理
return this.tools;
}
2
3
4
5
6
# stdio vs http 的本质区别
stdio:客户端 spawn 一个【本地子进程】(demo / playwright),用它的 stdin/stdout 通信
—— server 的生命周期由客户端掌控,随客户端起停
http :客户端用 URL 连一个【别处已长驻】的服务(exampleHttpServer.mjs 监听 :39100)
—— server 独立运行,客户端只是接入,这才是「远程 MCP server」
2
3
4
这就是为什么远程 server 必须靠 SDK:手写 stdio 客户端只会 spawn 本地进程,天生够不着一个「在别的机器/端口上跑着」的服务。
# 一个 HTTP 的坑:Accept 头
StreamableHTTP 可以回纯 JSON(enableJsonResponse:true)或 SSE 流(默认,event: message\ndata: {...})。但不管哪种,客户端 POST 必须带 Accept: application/json, text/event-stream——少一个,server 直接回 406。SDK 的 StreamableHTTPClientTransport 两种响应都能解,所以上层 callTool 依旧无感知。
# 四、深入追问(面试常见 follow-up)
Q:「换引擎不换接口」听起来很美,具体靠什么保证接口真没变?
A:靠把公开方法的签名和返回值结构当作契约。callTool 手写版返回 content[].text 拼接的字符串,SDK 版也从 SDK 结果里抠出同样结构返回同样的字符串。上层只依赖这个契约,不依赖内部怎么实现。验证手段就是拿同一批调用(demo 的 add/echo/资源/提示、playwright 的 23 工具)对比新旧返回值逐一相等。
Q:SDK 客户端能连我们手写的** demo server,这说明了什么?**
A:双向合规性验证。SDK 是官方标尺,它能正常和我们手写的 exampleServer.mjs 握手、列工具、调用,反过来就证明我们手写的 server 也是合规 MCP server,不是只能被自己客户端将就着连。协议的互操作性在这里形成了闭环。
Q:stderr 从 inherit 改成 ignore,只是为了不刷屏吗?
A:直接动机确实是 playwright 的日志之前 inherit 到终端刷屏。但更重要的是通道分离的意识:stdout 是协议数据通道(JSON-RPC 消息),stderr 是日志通道,绝不能混。真实 CC 用 'pipe' 把 stderr 收进日志文件——既不刷屏又不丢诊断信息,比我们直接 ignore 更完整。
Q:现在支持 http 了,是不是就该默认走 http? A:不是。本地能力(跑本机浏览器、读本地文件的 server)用 stdio 最自然——client 直接 spawn,无需额外起服务、无需端口。http 是为「server 在别处/需被多客户端共享/跨机」的场景准备的。选 transport 看部署形态,不是越「远程」越好。
# 五、踩坑 / 设计权衡
- 406 陷阱:HTTP transport 忘了带双 Accept 头 → server 直接拒。SDK 帮我们处理了,但自己写 HTTP 客户端时这是高频坑。
- 保留手写版的价值:没有删 step47-50 的手写实现记录。手写版是「我真的懂协议」,SDK 版是「和真实一致 + 能远程」,两者并存,学习路径完整。
- 仍缺的:OAuth 鉴权、断线重连、env 变量展开、从配置文件加载——真实版都有,我们没做,因为它们是工程完备性而非协议本质。
# 六、与真实源码的对照
| 我们的实现 | Claude Code 源码 |
|---|---|
@modelcontextprotocol/sdk Client | 完全相同(真实 CC 就用这个包) |
| Stdio / SSE / HTTP transport | 一致(真实还有 WebSocket / InProcess) |
MCP_SERVERS 写死 | 真实从 .claude.json / .mcp.json 多作用域读 |
stderr ignore | 真实用 'pipe' → 写日志 |
| 还缺:OAuth、断线重连、env 展开、配置文件加载 | 真实版都有 |
# 七、一句话总结
把签名钉死、把内部整个换掉:手写版让我们真懂了 MCP 协议,SDK 版让我们对齐真实实现并解锁远程 transport——「换引擎不换接口」之所以敢重写,是因为公开契约不动、爆炸半径被锁死在一个文件里。
# 下一节预告
MCP 主题告一段落。step52 转向插件系统——用一个目录打包工具、命令、钩子,做成可分发的扩展单元。