MCP 协议拆解:从 JSON-RPC 信封到 Agent 全链路
本文是 Agent 工程化学习(Phase 04)的阶段性总结。核心目标是将对 MCP 协议的理解,从“能用 SDK 跑起来”提升到“能看懂每条消息的具体作用、每层抽象解决什么问题”的层面。内容源自实际课程练习与代码编写过程,并非规范翻译。

这篇文章适合那些已经能运行一个 MCP Server,但还不清楚其内部运作原理的读者。
MCP 解决了什么问题
AI Agent 要调用外部能力(如读文件、查数据库、发请求),每个 Host 应用都需各自实现一套工具接口。同一个工具在不同应用里要重复编写——这就像 USB 标准出现之前,每个外设都配有专属接口。
MCP(Model Context Protocol)正是这个统一接口标准。任何 Host(CatDesk、Cursor、Claude Desktop)都能连接任何 MCP Server。一个 Server 编写一次,到处可用。
它的定位与 HTTP 类似:HTTP 统一了浏览器和服务器之间的通信格式,MCP 统一了 AI 应用与工具提供者之间的通信格式。
三层架构:Host、Client、Server
MCP 将参与方划分为三层,每层各自负责本职:
flowchart LR
subgraph Host["Host (CatDesk / Cursor)"]
UI["UI + 对话管理"]
LLM["LLM 调用"]
C1["MCP Client A"]
C2["MCP Client B"]
end
subgraph Servers["独立进程"]
S1["MCP Server A
(文件系统)"]
S2["MCP Server B
(数据库)"]
end
C1 <-->|"stdio / HTTP"| S1
C2 <-->|"stdio / HTTP"| S2
Host 是用户面对的 AI 应用,负责 UI 展示、对话历史管理以及 LLM 调用,其内部包含一个或多个 Client 实例。
Client 是协议适配层。每个 Client 对应一个 Server 连接,主要承担三件事:管理连接生命周期、转换工具格式(MCP Schema ↔ AI SDK Schema)、将调用请求路由到正确的 Server。
Server 是能力提供者。它作为独立进程运行,通过传输层与 Client 通信,暴露 Tools、Resources、Prompts 这三类能力。
为什么要单独抽取出 Client?核心在于关注点分离。Host 只关心“我有哪些工具可用”,Server 只关心“收到请求该如何执行”,而中间的连接管理、格式转换、传输层适配全部封装在 Client 中。这样一来,更换 LLM 框架只需修改 Client 的翻译逻辑,更换传输方式只需改动 Client 的底层实现,上下两层无需调整。
可以类比一下浏览器:你写前端代码只管发 fetch 和收数据,TCP 连接池、TLS 握手、HTTP/2 多路复用这些,都是浏览器网络栈(Client 层)帮你完成的。
底层通信格式:JSON-RPC 2.0
MCP 中所有消息,无论走 stdio 还是 HTTP,均采用 JSON-RPC 2.0 格式封装。这堪称 MCP 的“血管系统”——理解它,就能明白消息在网络上真正的样子。
JSON-RPC 只有三种信封格式:
Request(请求)——包含 id,期望对方返回 Response:
{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "read_file", "arguments": { "path": "config.json" } } }
Response(响应)——id 与 Request 配对,标明是对哪个请求的回复:
{ "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "文件内容..." }] } }
Notification(通知)——没有 id,不需要回复:
{ "jsonrpc": "2.0", "method": "notifications/initialized" }
这里有几个关键认知点:
id 是异步配对机制。Client 同时发出 3 个请求(id=1,2,3),Server 可以乱序返回,Client 依靠 id 匹配“这条回复对应我哪个请求”。Notification 没有 id,因为没人需要回复它。
错误码是标准化的。-32601 Method not found、-32602 Invalid params 都是 JSON-RPC 规范定义的,Server 返回错误时应使用这些码,不能随便编造数字。
SDK 帮你处理了所有序列化和反序列化。生产环境中你无需手动解析,但理解信封格式能帮助你在 Inspector 中看懂消息流,并在出现问题时知道去哪里排查。
三阶段生命周期
一个 MCP 连接从建立到关闭,会经历三个清晰的阶段:
sequenceDiagram
participant C as Client
participant S as Server
rect rgb(230,245,255)
Note over C,S: Phase 1: Initialize(能力协商)
C->>S: initialize { protocolVersion, capabilities, clientInfo }
S-->>C: result { protocolVersion, capabilities, serverInfo }
C->>S: notifications/initialized
end
rect rgb(230,255,230)
Note over C,S: Phase 2: Operation(正常工作)
C->>S: tools/list
S-->>C: result { tools: [...] }
C->>S: tools/call { name, arguments }
S-->>C: result { content: [...] }
C->>S: resources/read { uri }
S-->>C: result { contents: [...] }
end
rect rgb(255,240,230)
Note over C,S: Phase 3: Shutdown(关闭)
Note over C,S: 传输层信号:EOF / HTTP disconnect
end
Phase 1 的核心是能力协商。Client 告知 Server“我支持 roots 和 sampling”,Server 告知 Client“我有 tools 和 resources”。后续操作不能超出对方声明的能力——比如 Client 未声明 sampling,Server 就不能调用 sampling/createMessage。这是一项硬性约束。
initialized 为什么是 Notification 而非 Request?因为 Server 不需要确认,Client 只是单向通知“我准备好了,可以进入工作阶段”。一个简单的“我好了”信号而已。
Phase 3 没有结构化的 shutdown 方法。关闭由传输层信号完成——stdio 场景下关闭 stdin/stdout,HTTP 场景下 DELETE session。简单但足够。
六大原语
MCP 定义了六种能力原语,分为两组:
Server 暴露给 Client 的(3 个):
- Tools:Agent 主动调用的操作,可能有副作用(写文件、发请求)。LLM 在推理过程中自主决定是否调用。
- Resources:只读数据,用 URI 寻址(如
file://workspace/info)。通常在对话开始前预加载给 LLM 当作背景知识,也可在推理过程中按需读取。 - Prompts:预定义的提示词模板。Host 展示给用户选择,用户触发后注入对话。
Client 暴露给 Server 的(3 个):
- Roots:告知 Server 可访问哪些工作区路径。
- Sampling:Server 请求 Client 侧的 LLM 生成内容(反向调用)。
- Elicitation:Server 向用户提问以获取输入。
日常开发中最常接触的是前三个。其中 Tool 是核心——Agent 的“动手能力”全靠 Tool 实现。
Resource 和 Tool 的区别
有一个容易混淆的点:Resource 的 URI(如 file://workspace/info)看起来像文件路径,但实际上是 Server 自己定义的虚拟地址。当 Client 调用 readResource("file://workspace/info") 时,Server 并非去磁盘找文件,而是执行注册时绑定的 async 函数——可能是读目录拼 JSON、查数据库、调 API,什么都可能。
Tool 和 Resource 的本质区别在于谁触发以及是否有副作用:
- Resource:Host 在对话开始前预加载,为 LLM 提供背景知识。无副作用,类似 GET 请求。
- Tool:LLM 在推理过程中自主决定调用。可能有副作用(写文件、删记录),类似 POST 请求。
Tool Schema 设计原则
Tool 的 Schema 决定了 LLM 能否在正确时机选对正确的工具。下面这四条规则值得参考:
命名使用 snake_case 加动宾结构。 如 read_file、list_directory、search_files——LLM 扫一眼名字就知道工具用途。避免 fileManager、handleData 这类模棱两可的命名。
描述采用两句话公式。 第一句说明使用场景,第二句划定边界:
Use when you need to read the contents of a file at a given path.
Do not use for directories or binary files.
第二句“不要用来做什么”至关重要——LLM 经常在相似工具之间犹豫不决,边界描述能帮它快速排除错误选项。
粒度选 Atomic 而非 Monolithic。 read_file + write_file 优于 file_manager(action: "read"|"write"|"delete")。原因是 LLM 更容易直接选对精确的工具,而不是先选到一个大工具再决定传什么 action 参数。
inputSchema 中每个字段都要有 description。 LLM 需要知道每个参数是什么、什么格式、有哪些约束。用 required 明确必选参数,用 enum 约束取值范围。清晰度直接影响工具调用成功率。
完整调用链路:一条消息的旅程
把所有层级串起来,看一次完整的调用过程:
sequenceDiagram
participant User as 用户
participant Host as Host (CatDesk)
participant LLM as LLM (DeepSeek)
participant Client as MCP Client
participant Server as MCP Server
User->>Host: "帮我读一下 config.json"
Note over Host,LLM: Host 把工具菜单 + 用户消息一起发给 LLM
Host->>LLM: messages + tools 列表
Note over LLM: LLM 自主判断需要调用 read_file
LLM-->>Host: tool_call: read_file(path="config.json")
Note over Host,Client: Host 把 LLM 的决策交给 Client 执行
Host->>Client: 转发 tool_call
Note over Client,Server: Client 翻译成 JSON-RPC 格式发给 Server
Client->>Server: {"method":"tools/call","params":{...}}
Server-->>Client: {"result":{"content":[...]}}
Note over Host,Client: Client 提取结果返回
Client-->>Host: 文件内容
Note over Host,LLM: Host 把工具结果塞回 LLM context
Host->>LLM: tool_result 放入 context
Note over LLM: LLM 基于结果组织自然语言回答
LLM-->>Host: "config.json 的内容是..."
Host-->>User: 展示回答
有几个容易误解的点:
LLM 并非“建议你用工具”,而是直接输出调用指令。整个过程是自动化的 Agent Loop,用户几乎无感知。
Host 拿到工具结果后不是直接展示给用户,而是先塞回 LLM。让 LLM 基于结果组织回答——它可能总结、可能继续调另一个工具,直到认为任务完成才输出文本。
一次对话可能循环多轮。用户说“帮我找所有 .ts 文件然后读 package.json”,LLM 会先调 search_files,看到结果后再调 read_file,两轮工具调用完成后给出最终回答。
如何观测每一层
调试 MCP 应用时,不同层需要不同的观测工具:
| 想看的层 | 观测方式 | 能看到什么 |
|---|---|---|
| LLM 决策(Host 层) | AI SDK 的 onStepFinish 回调 | 每轮选了什么工具、传了什么参数、finishReason 是 tool-calls 还是 stop |
| Client ↔ Server 通信 | Client 的 verbose 模式或 JSON-RPC 追踪 | 工具调用耗时、返回内容预览、错误信息 |
| JSON-RPC 原始信封 | traceJsonRpc 模式或 MCP Inspector | 每条消息的完整 JSON:id、method、params、result |
MCP Inspector 是官方提供的图形化调试工具,使用方法很简单:
npx @modelcontextprotocol/inspector npx tsx src/04-tools-mcp/03-file-server/index.ts .
它能让你手动扮演 Client 角色:点击 tools/list 查看已注册的工具,点击 tools/call 填写参数测试执行。Inspector 能观察到 Client↔Server 之间的所有 JSON-RPC 消息,但无法看到 LLM 决策——那部分需要在 Host 代码中添加 onStepFinish 回调。
写一个 MCP Server 的标准流程
五步即可完成:
1. 声明身份——创建 McpServer 实例,赋予名称和版本号。
2. 注册能力——使用 registerTool / registerResource / registerPrompt 挂载能力。每个 Tool 需定义 name、description、inputSchema 和 handler。
3. 连接传输层——new StdioServerTransport() 加 server.connect(transport)。SDK 会自动处理 initialize 握手。
4. Inspector 调试——无需编写 Client 代码即可验证 Server 是否正确。手动测试每个 Tool 的输入输出。
5. 接入 Host——将 Server 配置到 CatDesk / Cursor 的 MCP 配置中,Agent 即可使用。
生产环境中,你编写的代码 90% 都在做两件事:定义 Schema(告诉 LLM 我能干什么)和实现 Handler(真正干活的逻辑)。握手、路由、序列化这些全部交给 SDK 处理。
收束
MCP 的核心设计哲学很清晰:Server 声明自己能做什么(Schema),Client 负责连接和翻译,Host 让 LLM 自主决定调用什么。每层只负责自己的事,依靠 JSON-RPC 信封在中间传递。
理解到这个层次后,后续的 Inspector 实操和自定义 Server 开发,就是在这个骨架上填充血肉了。
