我用一个下午写了个 Swift 的 MCP server。170 行,第一次就连上了 Claude Code,然后用了一周,没发现任何问题。
后来我去读官方的 Swift MCP SDK——13,749 行——本来是想确认一下"SDK 无非是多了些便利功能"。结果在我那 170 行里找出了 5 个 bug。其中 4 个用一条 shell 管道就能复现。最严重的那个会让客户端永久挂住,而我可能用一年都撞不到它。
这篇写的就是这个对比,以及修复过程。如果你也手写过某个协议的小实现、跑通了就上线了,这篇是在劝你:还是去读一遍参考实现。
Tool use 都跑通了,为什么还要 MCP
上一篇里我手写了 agent 循环:5 个基于 App Store Connect API 的工具,直接接进 Anthropic Messages API。这能用,但那些工具只存在于我自己的循环里,别的任何东西都调不到。
MCP 把这件事反过来了。工具变成一个 server,任何 MCP 客户端都能用。具体说:注册之后,我可以在写代码的过程中直接问 Claude Code "Fed? 这周的评论里大家在说什么",它就去查我真实的 App Store 数据——不用切窗口,不用开后台。
让我意外的是这个转换需要的代码有多少。我的工具本来就都符合同一个协议:
public protocol AgentTool {
var spec: AnthropicToolSpec { get }
func execute(input: [String: Any]) async throws -> String
}
MCP server 直接复用这些实现,一行没改。加第 6 个工具就是往数组里追加一行,协议层零改动。这就是第二篇里定义工具抽象、而不是直接内联调 API 的回报——也是我知道的、支持这个抽象最有力的实际论据。
整个协议格式
MCP 的 stdio 传输比它的文档听起来简单得多。它就是按行分隔的 JSON-RPC 2.0:客户端往你的 stdin 每行写一个 JSON 对象,你往 stdout 每行写一个 JSON 对象。没有帧头,没有握手字节,没有长度前缀。
一个能用的 server 需要 4 个方法:initialize、ping、tools/list、tools/call。你可以纯手工驱动它:
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
| ./.build/release/ASCMCPServer
「整个 server 可以用 printf 测完」这件事值得单独说一句:这里没有任何黑盒层。下面所有内容,你都能在终端里十秒钟复现一遍。
有一个字段名值得背下来,因为它让我困惑了半小时:MCP 里叫 inputSchema,而 Anthropic Messages API 里同样的 JSON Schema 叫 input_schema。同一个对象,不同的 key,没有任何报错——工具就是不出现而已。
一上手就撞的两个坑
stdout 是协议通道。我第一版用 print() 打了启动日志。客户端立刻断开连接——因为一行给人看的日志不是合法的 JSON-RPC,而传输层没有任何机制去跳过它。所有日志必须走 stderr:
func log(_ message: String) {
FileHandle.standardError.write(("[asc-mcp] " + message + "\n").data(using: .utf8)!)
}
事后看这很显然,大多数人也就撞一次。但它比普通 bug 更阴的地方在于:server 起不来的时候,print() 正是你第一个会伸手去用的东西——调试工具本身就是 bug。
钥匙串死锁。我的凭证存在 macOS 钥匙串里,读它可能弹出 GUI 授权框。而 MCP server 是被客户端无头启动的,所以在启动阶段读钥匙串会把握手直接锁死:客户端在等 initialize,server 在等一次没人看得见的点击。它不崩溃,就是挂着。
修法分两半。有环境变量时优先用它,这样包装脚本可以完全绕开钥匙串;否则就把这次读取推迟到真正需要凭证的工具被调用时,于是 initialize 和 tools/list 永远答得出来。结果是一个瞬间启动、把工具全部报出来、只在你真的要它干活时才申请权限的 server。
关于 agent 管道的一条通则:任何可能卡在人机交互上的东西——钥匙串、生物识别、OAuth 浏览器跳转、首次运行弹窗——都必须从启动路径里挪出去。无头客户端既没办法满足它,也没办法告诉你它为什么放弃了。
它能用。然后我读了 SDK。
到这一步 server 已经在日常使用了。我当周笔记里写下了对官方 SDK 会多做什么的预测:"多传输支持、resources/prompts、类型安全、协议版本协商"。猜对了一部分,而且漏掉了全部真 bug。
下面是读 modelcontextprotocol/swift-sdk 翻出来的东西,每条都附上在我那 170 行版本上的复现命令:
1. 它声称支持不存在的协议版本
我的 initialize 处理逻辑把客户端要求的版本原样回过去。你跟它要 1999 年的版本,它也答应:
$ echo '{"jsonrpc":"2.0","id":1,"method":"initialize",
"params":{"protocolVersion":"1999-01-01"}}' | ASCMCPServer
{"result":{"protocolVersion":"1999-01-01", ...}}
SDK 维护一个自己真正实现了的版本集合,然后协商:支持就用,不支持就回自己最新的,让客户端决定。13 行代码。照抄不是协商——那是 server 在谎报自己的能力,而代价会在后面某个看起来不相关的地方爆出来。
2. 批量请求被静默丢弃——客户端永久挂住
JSON-RPC 2.0 允许客户端在一条消息里发一数组请求。我的解析是 jsonObject(with:) as? [String: Any]。数组过不了这个转换,撞上我的 continue,然后就没了:
$ echo '[{"jsonrpc":"2.0","id":2,"method":"ping"},
{"jsonrpc":"2.0","id":3,"method":"ping"}]' | ASCMCPServer
(完全没有输出)
就是这一条让我改变了对"读参考实现"的看法。它是静默失败——没有报错、没有日志、不崩溃——而客户端会永远等下去,等一批永远不会来的回复。而我自己永远发现不了,因为 Claude Code 不发批量请求。换个客户端就会莫名其妙地挂住,而我大概会去怪那个客户端。
3. 队头阻塞:一个慢工具冻住全部
我的读循环里直接 await tool.execute(),所以 server 严格一次只处理一个请求。在一个跨多个区域的评分查询正在跑的时候发 ping,这个 ping 就得等它。实测,每行响应都打了时间戳:
17:30:43.018 id 1 initialize → 已答
17:30:45.233 id 2 tools/call → 已答(2.2 秒后)
17:30:45.239 id 3 ping → 已答(整整等了 2.2 秒)
SDK 是每个请求起一个 task。一个会被无关任务阻塞的存活探测,不是存活探测。
4. 握手之前就开始服务
不发 initialize,直接发 tools/list,它会老老实实把完整工具列表给你。SDK 每个方法前面都有 isInitialized 门禁。这条实际危害不大,但它意味着我的 server 会跟一个自己从没了解过能力的客户端聊起来。
5. 它忽略所有通知,包括取消
我循环里有这么一行,我当时还挺得意:
// 通知(没有 id)不能回复。
guard let id = message["id"] else { continue }
对了一半。通知确实不能回复——但仍然必须被处理。真正要紧的是 notifications/cancelled:你在 Claude Code 里按下 esc,发的就是它。我的 server 无视它,继续把一个 30 秒的销售报表下载跑完,然后为一个没人在等的请求写了一份响应。
修它们,以及我自己造出来的第 6 个 bug
修复大约 90 行。4 条是机械劳动。并发那条不是,而它教给我的最多。
并行处理请求意味着 stdout 变成了共享可变状态。两个响应并发写会交错成一行损坏的数据,客户端随即断开。所以写入方变成了 actor:
actor Transport {
func send(_ object: Any) {
guard let data = try? JSONSerialization.data(withJSONObject: object) else { return }
FileHandle.standardOutput.write(data)
FileHandle.standardOutput.write(Data([0x0A]))
}
}
然后测试结果更糟了。握手前的 tools/list 变成什么都不输出——而且只是有时候。原因是:stdin 关闭时,顶层代码跑到末尾、进程退出,而响应还在飞。哪个 task 输了这场竞争,哪个响应就丢了。
我把一个确定性 bug 换成了一个间歇性的,这笔交易比听起来更亏——串行版本压根不可能有这种失败。修法是退出前等在飞的活干完:
/// 等所有已派发的请求写完。
func drain() async {
while let (key, task) = inFlight.first {
await task.value
inFlight[key] = nil
}
}
连跑 20 次,稳定。还有一条我想刻在墙上的规矩:并发不是免费的。让请求并行的那 5 行,配套需要 15 行才能保持正确——共享输出要 actor、在飞请求要登记、退出时要显式等待。如果一个手写 server 永远只跟一个客户端、一次一件事地对话,串行是个合理选择。只是你得知道自己在选它。
修完之后,同样那几条命令:
protocolVersion "1999-01-01" → 回答 "2025-11-25"
两个 ping 的批量请求 → 一个 JSON 数组,两个 id 都在
8 秒工具调用期间发 ping → 3 毫秒答复
initialize 之前 tools/list → 报错 -32600
notifications/cancelled → 请求被取消,不发响应
那另外那 13,000 行是干什么的?
既然真找出了 bug,很容易得出"那就永远用 SDK"的结论。但代码本身说的不是这个。下面是 SDK 里我这个 server 没有的东西,以及我对每一项的诚实评估:
- HTTP/SSE 与 Network 传输,加上一整套 OAuth 实现(PKCE、动态客户端注册、发现、token 存储)。这是最大的一块。如果你的 server 是远程的、多租户的,它很重要。我的是自己笔记本上的一个本地二进制——一行都用不到。
- resources、prompts、completion、logging、progress 通知。确实是我跳过的协议面。
progress是我会后悔的那个:一个 30 秒的报表现在对客户端毫无反馈。 - 客户端实现——跟我无关,Claude Code 就是我的客户端。
- 用带类型的
Codable消息代替[String: Any]。真有价值,也是我最想先移植的一项:我那几个 bug 里有好几个都是静默失败的类型转换,而不是能浮出来的错误。 - 一致性测试。我最明显缺的东西。我的"测试"是手工跑的 shell 管道。
13,749 行里,我真正需要的大概 300 行——而 SDK 对我最大的价值也不是可以 import 的代码,是一份我不知道其存在的协议细节清单。
问题不是"用 SDK 还是手写"。而是"我漏了哪些协议细节"——这个问题靠猜是答不出来的,只能靠读一份正确的实现。我在读之前把自己的预测写下来了,所以我知道猜不出来:我猜中了功能列表,而 5 个 bug 一个没猜到。先把你的预测写下来。差距就是收获。
还会再手写吗?
会,这个场景下会。170 行、我完全理解、零依赖、本地单客户端,这是对的选择——而且如果我直接 import 一个包,协议本身我一点都学不到。错的不是手写。错的是没读参考实现就上线,然后把"跟我的客户端能跑"当成正确性的证据。
这一条超出 MCP 也成立。「在我测过的那一个客户端上能跑」是协议正确性里最弱的一种信心,而绝大多数手写实现靠的正是这一种。
下一篇
下一篇把这个手写 agent 循环和 Claude Agent SDK 做对比——一套 harness 到底买到了什么,在同样的任务上实测,而不是抽象地论证。做完上面这轮之后,我有一个具体的假设:价值不会在循环本身,而在那些我不知道自己漏了的细节里。