8.2 两层结构:Data layer 与 Transport layer
一句话结论:MCP 被明确切成两层——数据层管"说什么"(JSON-RPC 语义),传输层管"怎么送"(stdio / HTTP)。这个分层的好处是:换传输方式不改变协议语义。
读这篇之前
需要先读 8.1(MCP 解决什么问题)。这一篇是 MCP 的技术骨架。
一、官方定义
MCP 由两层组成:[核验:MCP 官方架构]
| 层 | 管什么 |
|---|---|
| Data layer(数据层) | 定义基于 JSON-RPC 2.0 的客户端-服务端协议,包括能力与版本发现,以及核心原语 tools / resources / prompts / notifications |
| Transport layer(传输层) | 管理客户端与服务端之间的通信通道与认证 |
这个分层的价值可以用一句话说清:
数据层抽象了"语义",传输层抽象了"通信细节"——所以同一套 JSON-RPC 消息可以跑在不同的传输上。
二、数据层:三个核心原语
数据层定义了三类"服务端能提供的东西":
| 原语 | 用途 | 类比 |
|---|---|---|
| tools(工具) | 让 AI 执行动作 | "能力" |
| resources(资源) | 提供上下文数据 | "资料" |
| prompts(提示模板) | 提供交互模板 | "话术" |
tools:和 Function Calling 的对应关系
这是最常用的一个。它和 4.1 讲的 Function Calling 在形态上几乎一致:
- 有 name、description、参数 schema
- 调用后返回结果
区别在"谁提供":Function Calling 里工具由你的应用定义;MCP 里工具由独立运行的服务端提供。
resources:为什么需要"数据"这个原语
这原语容易和 tools 混。区别:
| tools | resources | |
|---|---|---|
| 是否改变状态 | 可能 | 不改变 |
| 谁发起 | 模型决定调用 | 客户端可主动读取 |
| 形态 | 动作 | 数据(文件、记录、日志) |
resources 的价值:把"只读的上下文数据"和"可执行的工具"分开。这样客户端可以在需要时主动拉取数据,而不必让模型专门发一次工具调用。
prompts:模板化交互
服务端可以预置一些"提示模板",客户端选用。这让"这个数据源的最佳提问方式"能被封装起来。
第二个关键:能力与版本发现
数据层还定义了客户端如何查询服务端的协议版本、能力与身份(通过 server/discover 请求)。[核验:官方架构]
这个设计解决了一个实际问题:客户端不需要预先知道服务端支持什么,可以动态发现。
三、传输层:两种机制
MCP 支持两种传输方式:[核验:官方文档]
| 传输 | 机制 | 特点 | 典型场景 |
|---|---|---|---|
| Stdio | 标准输入/输出流,直接进程通信 | 无网络开销,性能最佳 | 本地进程,通常服务单个客户端 |
| Streamable HTTP | HTTP POST + 可选 SSE 流式 | 支持多客户端、可远程 | 远程服务,服务多个客户端 |
选型判据很简单:
| 你的场景 | 用哪个 |
|---|---|
| 工具和 AI 应用在同一台机器 | Stdio(简单、快、无需认证) |
| 工具在服务器上,多客户端共用 | Streamable HTTP(需要认证) |
| 需要部署成公共服务 | Streamable HTTP |
一个具体例子:官方的 Sentry MCP 服务端跑在 Sentry 平台上,用的就是 Streamable HTTP(因为它要服务很多客户端)。
四、为什么这个分层重要
三层好处,从浅到深:
好处一:换传输不改语义
同一套 tools/resources/prompts 定义,可以今天用 stdio 跑在本地,明天改成 HTTP 部署到服务器——协议语义完全不变。
好处二:测试更容易
你可以在本地用 stdio 测试协议逻辑,不需要搭 HTTP 服务。传输的复杂度和业务逻辑解耦。
好处三:安全边界清晰
| 层 | 安全问题 |
|---|---|
| 数据层 | 参数校验、权限(谁能调什么工具) |
| 传输层 | 认证、加密、网络隔离 |
分开之后,两层的防护可以独立设计。 这是 2.5 讲的"执行层防线"在协议层面的体现。
五、一次完整的调用长什么样
把两层串起来:
① 客户端通过 Transport 建立连接(stdio 或 HTTP)
② 客户端发 server/discover → 得到:协议版本、支持的能力、工具清单
③ 用户在应用里提问
④ 模型决定调用某工具 → 输出 tool_calls(4.1)
⑤ 客户端把 tool_calls 转成 JSON-RPC 请求,经 Transport 发给服务端
⑥ 服务端执行,返回 JSON-RPC 响应
⑦ 客户端把结果作为 tool 消息放回上下文(1.3)
⑧ 模型继续推理
注意第 ⑤⑥ 步:这是 MCP 相对"本地函数调用"多出来的部分——多了一次网络/进程通信,换来了解耦与复用。
六、动手:一次 stdio 调用的完整报文
协议讲得再多,不如看一遍真实报文。下面是一次本地文件系统 MCP Server 的完整交互(简化)。
① 初始化与能力发现
→ {"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}
← {"jsonrpc":"2.0","id":1,"result":{
"protocolVersion":"2026-07-28",
"capabilities":{"tools":{},"resources":{}},
"serverInfo":{"name":"filesystem","version":"1.2.0"}
}}
注意:客户端第一件事不是调工具,而是问对方有什么(8.4 详述)。
② 列出工具
→ {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
← {"jsonrpc":"2.0","id":2,"result":{"tools":[
{"name":"read_file",
"description":"读取项目内的文本文件。只读,不修改。",
"inputSchema":{"type":"object",
"properties":{"path":{"type":"string","description":"相对项目根目录的路径"}},
"required":["path"]}}
]}}
看 description:它写着"只读,不修改"——这是 4.2 讲的"副作用声明",让模型知道风险。
③ 调用工具
→ {"jsonrpc":"2.0","id":3,"method":"tools/call",
"params":{"name":"read_file","arguments":{"path":"src/app.ts"}}}
← {"jsonrpc":"2.0","id":3,"result":{
"content":[{"type":"text","text":"(文件内容)"}],
"isError":false
}}
④ 错误怎么返回
← {"jsonrpc":"2.0","id":3,"result":{
"content":[{"type":"text","text":"文件不存在:src/app.ts。"}],
"isError":true
}}
关键设计:工具执行失败用 isError: true 放在 result 里,而不是用 JSON-RPC 的 error 字段。
| 层 | 用哪个 | 例子 |
|---|---|---|
| 协议错误 | error 字段 |
方法不存在、参数格式错 |
| 业务错误 | result.isError |
文件不存在、权限不足 |
为什么要分开? 因为业务错误要交给模型看(4.3 的失败回填),而协议错误是客户端代码的问题。
七、两种传输的选型:一个具体对比
| 维度 | Stdio | Streamable HTTP |
|---|---|---|
| 启动方式 | 父进程 spawn 子进程 | 连接已有服务 |
| 延迟 | 最低(无网络) | 有网络往返 |
| 多客户端 | ❌ 通常单一 | ✅ 支持 |
| 认证 | 不需要(同机) | 需要 |
| 部署 | 跟着应用走 | 独立部署 |
| 故障影响 | 进程死则连接断 | 可重连、可负载均衡 |
| 调试 | 本地直接看 stderr | 需要看服务端日志 |
一个真实的选型判断
你的工具需要访问用户的本地文件吗?
├─ 是 → Stdio(只有本机进程能访问用户文件)
└─ 否 → 会被多个应用共用吗?
├─ 是 → Streamable HTTP
└─ 否 → Stdio 更简单
第一条是硬约束:远程服务看不到用户本地文件,所以本地能力必须用 stdio。
八、分层设计的三个实际收益(展开)
原文提了三点,这里给出具体例子。
收益一:换传输不改语义
一个真实的迁移场景:
阶段一:本地开发,用 stdio,工具逻辑写在 server.ts 里
阶段二:团队共用,改成 HTTP 部署
要改的:只有传输层的启动代码(约 20 行)。
不用改的:所有 tool/resource/prompt 的定义与实现。
如果协议没有分层,你就要重写整个消息处理逻辑——这就是分层的价值。
收益二:测试更容易
用 stdio 测协议逻辑:起一个子进程,喂 JSON,看返回
↓ 不需要 HTTP 服务、不需要端口、不需要认证
这让人能在 10 秒内写一个协议层单测,而不是搭一套测试环境。
收益三:安全边界清晰
| 层 | 谁负责 | 具体措施 |
|---|---|---|
| 数据层 | 工具作者 | 参数校验、路径白名单(14.4) |
| 传输层 | 部署者 | TLS、认证、网络隔离 |
分开之后,两层的加固可以独立进行——这是 14.4 讲的"纵深防御"在协议层的体现。
九、动手:写一个最小 MCP Server
知道协议后,最快的学习方式是写一个 60 行的 Server。
骨架(伪代码)
启动 → 从 stdin 逐行读 JSON
→ 解析 method
→ 分发:
server/discover → 返回 capabilities
tools/list → 返回工具定义数组
tools/call → 执行 → 返回 result
→ 写到 stdout(每行一个 JSON)
四个实现要点:
| 要点 | 说明 |
|---|---|
| 一行一个 JSON | stdio 用换行分隔消息,不能多行 |
| stdout 只放协议消息 | 日志必须走 stderr,否则客户端解析失败 |
| 不要在 stdout 打印 | 这是新手最常见的坑 |
| 错误也要是合法 JSON | 崩溃时返回 JSON-RPC error,不要直接退出 |
第三个坑的具体表现
错:
print("debug: 收到请求") ← 打到 stdout
print(json.dumps(response))
→ 客户端解析第一行失败
对:
print("debug: 收到请求", file=sys.stderr)
print(json.dumps(response)) ← stdout 只有协议消息
这是 stdio 传输最常见的事故,而且现象很误导(客户端报"JSON 解析失败",看起来像协议实现错了)。
一个最小可行的工具实现
tools/call: read_file
1. 校验 path 是否在白名单目录内(14.4)
2. 规范化路径(防 ../ 穿越)
3. 读文件,超长则截断
4. 返回 content + isError
四步缺一不可——尤其第 1、2 步,缺了就是任意文件读取漏洞。
十、常见误区
误区七:业务错误也应该用 JSON-RPC 的 error。
会让模型看不到可读的错误信息。用 result.isError + 文本。
误区八:stdio 已经过时了,应该都用 HTTP。
本地能力(访问用户文件)只能用 stdio。
误区九:分层只是代码组织。
它决定了"换传输要不要重写"。
误区十:一个 Server 只能有一种传输。
协议允许,实现上需分别处理(多数实现只支持一种)。
原有误区
误区一:MCP 就是 JSON-RPC。
JSON-RPC 只是数据层的编码方式。还有传输层、能力发现、原语定义。
误区二:远程 MCP 一定更好。
远程带来网络延迟与认证复杂度。同机部署用 stdio 更快。
误区三:三层原语里只有 tools 有用。
resources 解决"只读数据供给",prompts 解决"模板复用",各有位置。
误区四:能力发现只是形式。
它让客户端能适配未知服务端,是生态能成立的前提之一。
误区五:分层是学术洁癖。
分层的实际收益是:测试更容易、换传输不改语义、安全边界清晰。
误区六:MCP 调用比本地函数调用快。
会慢(多一次通信)。换来的是解耦与复用,不是性能。
十一、动手:一次真实的 stdio 报文往返
8.2 讲了分层,这里看完整报文,把"数据层"落到眼前。
① 发现能力
{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}
回应:
{"jsonrpc":"2.0","id":1,"result":{
"protocolVersion":"2026-07-28",
"capabilities":{"tools":{},"resources":{}},
"serverInfo":{"name":"filesystem","version":"1.2.0"}
}}
② 列工具
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
③ 调工具
{"jsonrpc":"2.0","id":3,"method":"tools/call",
"params":{"name":"read_file","arguments":{"path":"src/app.ts"}}}
④ 两种错误怎么区分
| 错误类型 | 放哪 | 例子 |
|---|---|---|
| 协议错误 | JSON-RPC error 字段 |
方法不存在、参数格式非法 |
| 业务错误 | result.isError: true |
文件不存在、权限不足 |
为什么必须分开? 因为业务错误要交给模型看(4.3 的失败回填),而协议错误是客户端代码的问题。混在一起,模型会看到一堆它读不懂的技术报错。
一个实现细节:一行一个 JSON
stdio 传输用换行分隔消息。所以:
错:print("debug: 收到请求") ← 打到 stdout,破坏协议流
对:print("debug: 收到请求", file=sys.stderr)
这是 stdio Server 最常见的故障,而且现象很容易误导:客户端报"JSON 解析失败",看起来像协议实现错了。
十二、一个补充:传输层怎么选——决策表
8.2 讲了两种传输。实际选型时用这张表:
| 你的情况 | 选 |
|---|---|
| 工具要访问用户本地文件 | stdio(远程看不到用户磁盘) |
| 工具是多台机器共用的服务 | Streamable HTTP |
| 只是自己本机用 | stdio(更简单、更快) |
| 需要跨网络、跨组织 | Streamable HTTP(要认证) |
| 需要多客户端并发 | Streamable HTTP |
| 需要部署到容器/K8s | Streamable HTTP |
第一行是硬约束:远程服务访问不到用户的本地文件,所以"读本地代码库"这类能力只能用 stdio。
一个混合的常见形态
用户的 IDE 插件
├─ stdio → 本地 MCP Server(读项目文件、跑命令)
└─ HTTP → 远程 MCP Server(查公司知识库、调内部 API)
两者并存是常态,不冲突。这也是 8.5 讲的"同一 Agent 既是 Client 也是 Server"的基础。
十三、最后一条
传输层可以换,语义不能换。 这就是分层的价值——你为 stdio 写的工具定义,换成 HTTP 后一行都不用改。
判断一个 MCP 实现是否规范:看它换传输时要不要改工具定义。要改,说明分层没做对。
十四、下一篇
数据层定义了三类原语,其中 tools 最常用,但 resources 和 prompts 常被误解。
下一篇专门讲这三个原语——它们各自的语义、适用场景,以及为什么不能混用。
一句补充
分层的成本是一次性的:写对一次,之后换传输几乎零成本。 不分层的成本是持续的:每次传输变更都要重写一遍。
这就是为什么"多写一个抽象层"在协议实现里总是值得的。
记住这一点,就不会把协议写死在某一种传输上。