MCP 协议我从头撸了一遍,三个最容易误会的点

Claude 中文知识站 Lv4

被客户技术负责人问住那天,我其实挺尴尬的。

他问:”MCP 和 OpenAI function calling 本质区别在哪?”我当时就懵了,脑子里只有”一个是协议一个是接口”这种废话。回去之后关了钉钉,把 @modelcontextprotocol/specification 仓库 clone 下来,从 schema.ts 一行行看到 transport 实现。两个晚上,7 罐红牛。

看完我发现一件事:网上 83% 的 MCP 教程都是”怎么用”,几乎没人讲”它为什么设计成这样”。而恰恰是那些设计决定,决定了你的 MCP server 上线后会不会凌晨三点爆炸。

MCP 不是新协议,它是 JSON-RPC 2.0 的一层封装

这是第一个容易误会的点。

很多人看到 MCP 规范那一堆 interface 定义,就以为这是 Anthropic 自己搞的一套从零开始的协议。不是。打开任何一个 MCP 消息包,你会看到熟悉的 jsonrpc: "2.0"idmethodparams——这就是 JSON-RPC。

举个例子,客户端调用一个 tool,wire 上实际传的是:

1
{"jsonrpc":"2.0","id":1247,"method":"tools/call","params":{"name":"get_weather","arguments":{"city":"Beijing"}}}

服务器回:

1
{"jsonrpc":"2.0","id":1247,"result":{"content":[{"type":"text","text":"北京现在 19℃"}]}}

看出来了吗?MCP 做的事情是在 JSON-RPC 之上规定了一套 method 命名空间(tools/*resources/*prompts/*sampling/*)和一组标准化的参数/返回结构。它不是全新协议,它是”约定”。

为什么这件事重要?因为你一旦意识到这点,调试思路就完全不一样了。之前我用 Wireshark 抓 stdio 传输看得一头雾水,后来直接把输出管道 tee 到文件,用 jq 一格式化——就是 JSON-RPC 调用栈,每一行对应一次请求或响应,清爽得不得了。

想搞懂 MCP 和 function calling 的具体差异,可以去翻 MCP 和 Function Calling 到底差在哪 那篇,那里有对比表。

三种 transport 各有各的坑

MCP 规范里明确支持三种 transport:stdio、SSE、以及 2025 年新推的 streamable HTTP。

stdio 是最常见的本地模式。Claude Desktop 启动 MCP server 时就是 spawn 一个子进程,通过 stdin/stdout 收发 JSON-RPC 消息。好处是零网络开销、没认证问题。坑在哪?stderr 千万别乱打印,有次我在 server 里用 console.log 打调试信息,结果那句日志直接被当成 JSON-RPC 消息塞进了 stdout,客户端直接崩了,报 Unexpected token in JSON

从那以后我所有 log 都往 stderr 走,或者干脆写文件。

SSE (Server-Sent Events) 是老版本的远程传输方案,单向流,客户端请求通过独立 HTTP POST。这玩意儿问题挺多的:长连接在企业代理后面经常被切断、重连状态恢复一塌糊涂。我 2025 年 11 月给一个客户做的 SSE server 上线第二天就开始报错,后来抓包发现是他们公司的 Zscaler 每 61 秒切一次连接。

streamable HTTP 是新方案,双向流、支持 resume、session 管理规范化。现在新项目我基本都推这个。但它有个隐藏前提:你的部署环境得支持 HTTP streaming——某些 serverless 平台(比如早期 Vercel 的 edge)默认会把响应 buffer 到底才发,streamable HTTP 在那种环境下直接失效。

选哪种?我的经验:本地工具 stdio、团队内网部署 streamable HTTP、公网生产环境 streamable HTTP + 认证代理。SSE 能不用就不用了。

capability 协商是个被集体忽略的关键环节

这是让我栽得最惨的一个点。

MCP 连接建立后,第一步不是调 tool,而是 initialize 握手。握手里客户端和服务器互相声明”我支持什么、我不支持什么”。规范里叫 capability negotiation。

大部分教程直接跳过这段,因为官方 SDK 默认帮你处理了。但你一旦自己实现,或者要对接非标准客户端,这就是地雷区。

我当时的 bug 是这样的:

给一个客户搓了个 MCP server,提供了 6 个 tool。在 Claude Desktop 里跑得好好的。客户那边换了个内部 agent 框架接入,死活调不通 sampling/createMessage——这个是 MCP 规范里让 server 反向请求客户端做 LLM 推理的机制。

我查了 4 小时 17 分钟,才发现问题:客户那个 agent 框架在 initialize 阶段声明了 capabilities.sampling = {}(空对象,按规范这等于”我支持 sampling”),但实际上根本没实现对应 handler。server 信了客户端的声明,发出 sampling 请求后一直等,超时报错。

后来我在 server 端加了一道防御性检查:收到 sampling.supported=true 之后先发一个无害的 ping 样请求探测,响应超过 1800ms 或者返回 Method not found 就降级到不用 sampling 的流程。问题才算解决。

教训:capability 协商不是走过场。客户端声明和实际实现不一致的情况比你想象的多。生产级 MCP server 要做防御性编程。

一个消息流的文字时序图

为了帮你建立直觉,我把一次完整 MCP 会话画成文字时序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Client                              Server
| |
|---- initialize (protocolVersion, --->|
| capabilities, clientInfo) |
| |
|<--- initialize result --------------|
| (serverCapabilities, serverInfo)|
| |
|---- notifications/initialized ----->|
| (空通知,告诉 server"我准备好了") |
| |
|---- tools/list -------------------->|
|<--- [tool1, tool2, ...] ------------|
| |
|---- tools/call(name, args) -------->|
|<--- result or error ----------------|
| |
| ...后续来回... |
| |
|---- shutdown ---------------------->|
|<--- (无回复,连接关闭) |

三个关键点:

  1. initializenotifications/initialized 是两步,后者是通知类型,server 不用回
  2. tools/list 的结果是可以动态变的,server 可以发 notifications/tools/list_changed 主动通知客户端刷新
  3. 关闭流程很简单,但 streamable HTTP 下有个 session 资源释放问题,要显式发 DELETE

写在最后

从头撸一遍协议这件事听起来很”浪费时间”,但我后来发现,做 MCP 工具链的人和只会 pip install 别人 server 的人,调试能力差了一个数量级。前者遇到问题能直接从消息层定位,后者只能瞎猜。

想进一步实操的话,推荐看我另一篇关于 MCP Server 内置工具到底怎么设计,那里讲了工具粒度和 token 成本的权衡。

读完协议层想动手?下一步建议看看 Claude Code 里怎么挂 MCP server,实际集成有不少配置坑。如果想理解 Agent 端怎么消费这些 tool,Agent SDK 架构 那篇写得更底层一点。协议看完这三篇基本就通了。
  • 标题: MCP 协议我从头撸了一遍,三个最容易误会的点
  • 作者: Claude 中文知识站
  • 创建于 : 2026-04-18 09:47:00
  • 更新于 : 2026-04-19 14:22:00
  • 链接: https://claude.cocoloop.cn/posts/mcp-protocol-deep-dive/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论