MCP 协议设计与实现
第21章 设计模式与架构决策
好的协议不是发明出来的,是被迫发现的——当一个系统面对多个根本性张力时,它会收敛到一组几乎唯一的设计选择。
本章要点
- 从本专栏全部内容中提炼六大核心设计模式,每个模式对应一对根本张力
- 理解每个模式在 MCP 不同章节中的具体体现,建立横向视图
- 掌握模式间的协同关系与互相强化,它们不是孤立的条目而是一套咬合系统
- 认识 MCP 设计决策对未来协议演进的约束与启示
- 建立可迁移到其他系统设计的架构决策框架——协议设计的"六脉神剑"
21.1 模式的发现
21.1.1 为什么"从现象总结"比"空谈原理"更有力
本章是本专栏的收官之章。我不打算从理论出发罗列"好协议的 10 条标准"——那种文章网上太多。我要做的是反向操作:带你回看前 20 章的具体决策,从中反向归纳出 MCP 作者群最底层的思考模型。
为什么这样做?
- 空谈原理时人人点头,落地时人人走样。抽象的原则很难内化
- 反之,从具体 API 反推"为什么这里要三个参数而不是两个",就能在大脑里建立可复用的模式匹配器
- 下次你自己设计协议时,遇到类似张力,这些模式会自动触发,替你做出更好的决策
21.1.2 本专栏脉络回顾
回顾这 21 章的旅程——从 MCP 的诞生动机(第 1 章)到架构全景(第 2 章),到 JSON-RPC 基础(第 3 章),到生命周期管理(第 4 章),到三大原语的深入分析(第 5-7 章),到两大 SDK 的实现解读(第 8-11 章),到传输层的多重选择(第 12-14 章),到安全与授权(第 15 章),到发现与注册(第 16 章),到反向通道与辅助机制(第 17-18 章),到真实产品中的落地(第 19 章),再到亲手构建 Server(第 20 章)——我们积累了足够的材料来做一件更深层的事:
识别贯穿整个协议的设计模式。
这些模式不是凭空创造的规则,而是从源码、规范和实践中反复出现的决策逻辑中提炼出来的。它们回答的不是"MCP 做了什么",而是"MCP 为什么这样做,而不是别样做"。
mindmap
root((MCP 六大设计模式))
协议层
能力协商<br/>解决<兼容性>张力
版本演进策略<br/>解决<稳定与演进>张力
传输抽象<br/>解决<解耦与实用>张力
语义层
三原语模型<br/>解决<灵活与标准>张力
控制平面分离
安全层
安全即默认<br/>解决<开放与安全>张力
工程层
渐进式特性暴露<br/>解决<强大与易用>张力
21.1.3 阅读本章的方式
每个模式按以下结构解析:
- 根本张力——该模式试图调和哪两个对立目标
- 模式描述——具体是怎么做的
- 在 MCP 中的体现——本专栏哪些章节、哪些 API 是它的实例
- 可迁移的洞察——离开 MCP 后,这个模式还能用到哪里
这个四段式让你可以跳读:只看"张力"部分就能快速把握;想深入就读"体现"章节;想迁移到自己项目就看"洞察"。
21.2 模式一:能力协商(Capability Negotiation)
21.2.1 解决的张力
协议需要稳定(Client 和 Server 可能由不同团队在不同时间开发),但功能需要演进(新特性不断被添加)。如何让新旧版本、功能残缺版和功能齐全版在同一张网里共存?
如果用简单的"版本号"来解决——1.0、1.1、2.0——立刻会撞上三堵墙:
- 维度坍塌:Server A 实现了 Tools 和 Resources 但不支持 Completion,Server B 实现了 Tools 和 Completion 但不支持 Resources。他们都是 "1.0 兼容"还是"1.1 兼容"?版本号没法描述这种部分实现。
- 升级锁步:任何新特性需要升版本号,所有 Client/Server 都要跟着升——在一个由无数独立团队各自维护实现的生态里这是灾难。
- 回归恐怖:一个 Client 想用版本 2.0 的新特性,但只能和支持 2.0 的 Server 通信,用户就骂"为什么我的老 Server 不兼容"。
21.2.2 模式描述
MCP 的初始化握手不仅是"我是谁"的自我介绍,更是"我能做什么"的能力宣告。Client 和 Server 各自声明自己支持的特性集合,双方在交集内工作:
graph LR
subgraph "Client 能力"
CR["roots"]
CE["elicitation<br/>(form + url)"]
CS["sampling"]
end
subgraph "Server 能力"
ST["tools"]
SR["resources<br/>(subscribe)"]
SP["prompts"]
SL["logging"]
SC["completions"]
end
subgraph "协商结果"
N1["Client 提供 roots 信息 → Server 可查询"]
N2["Client 支持 elicitation → Server 可提问"]
N3["Server 提供 tools → Client 可调用"]
N4["Server 支持 logging → Client 可设置级别"]
end
CR --> N1
CE --> N2
ST --> N3
SL --> N4
style CR fill:#2563eb,color:#fff,stroke:none
style CE fill:#2563eb,color:#fff,stroke:none
style CS fill:#2563eb,color:#fff,stroke:none
style ST fill:#10b981,color:#333,stroke:none
style SR fill:#10b981,color:#333,stroke:none
style SP fill:#10b981,color:#333,stroke:none
style SL fill:#10b981,color:#333,stroke:none
style SC fill:#10b981,color:#333,stroke:none
21.2.3 在 MCP 中的体现
这个模式贯穿本专栏多个章节:
- 第 4 章(生命周期):
initialize请求和响应中的capabilities字段是整个协商的起点(这是本专栏锚定的 2025-11-25 版;2026-07-28 版去掉了initialize握手,Client 能力改由每个请求的_meta携带,Server 则通过新增的server/discover公布能力) - 第 5 章(Tools):Server 声明
tools能力后,Client 才能调用tools/list和tools/call - 第 6 章(Resources):
resources.subscribe是可选的子能力,Client 只在 Server 声明时才订阅 - 第 17 章(Sampling):Server 必须检查 Client 是否声明了
sampling能力 - 第 18 章(Elicitation):精细到区分
form和url两种子模式的声明
SDK 中 enforceStrictCapabilities 选项控制是否严格执行能力约束,但它的默认值是 false(Client 构造时 options?.enforceStrictCapabilities ?? false),而且判断写的是 === true——必须显式打开,能力检查才会生效。
关掉时本地不拦截,但不同方法的表现不一样:listTools()、listPrompts()、listResources()、listResourceTemplates() 这类列表方法在服务端没声明对应能力时静默降级——直接返回空列表(如 { tools: [] }),不发请求,只打一行 console.debug;callTool()、readResource() 等其余请求则照常发出,由对端回 JSON-RPC 错误(实测为 -32601 Method not found)。打开后,这些调用都会在本地先抛 SdkError(code CapabilityNotSupported)。反方向上,Server 的 elicitInput() 和带 tools 的 createMessage() 无论开关如何都会先在本地检查 Client 能力(详见 §2.4.3 与 §9.3)。所以这个开关真正的含义是"能力缺失该在本地当场炸出来,还是交给对端、甚至静悄悄地当没有"——集成调试时建议打开,否则一个拼错的能力声明会以"工具列表是空的"这种最难查的形式表现出来。
21.2.4 能力协商的三层嵌套
MCP 的能力协商不止一层,而是能力 → 子能力 → 参数的三层嵌套:
{
"capabilities": {
"elicitation": { // 顶层能力
"form": {}, // 子模式:表单
"url": {} // 子模式:跳转 URL
},
"tasks": { // 顶层能力
"list": {},
"cancel": {},
"requests": { // 第二层:哪些请求可以被任务化
"sampling": {
"createMessage": {} // 第三层:具体到方法
},
"elicitation": {
"create": {}
}
}
}
}
}
注意每一层的值都是空对象而不是配置项——协议里这些字段只回答"支不支持",不携带参数(elicitation 的类型就是 { form?: object; url?: object })。想传阈值、超时这类参数,得走请求参数或 _meta,不能塞进能力声明。
这种嵌套让协议能力粒度极细——同一个顶层能力下,可以逐步添加子特性而不破坏协商;tasks.requests.sampling.createMessage 甚至细到"我支持把这一个方法变成异步任务"。
21.2.5 可迁移的洞察
能力协商模式适用于任何需要渐进式兼容的系统。它的核心智慧是:不要假设对方的能力,问了再用。这比版本号更灵活——版本号是线性的,能力集合是多维的。
对比可见的同类实现:
| 系统 | 能力协商机制 |
|---|---|
| HTTP/2 | SETTINGS 帧:窗口大小、头表大小、最大并发流 |
| TLS 1.3 | ClientHello 的 supported_versions / signature_algorithms 扩展 |
| WebSocket | Sec-WebSocket-Protocol、Sec-WebSocket-Extensions |
| LSP(语言服务器) | initialize 响应中的 capabilities(类型为 ServerCapabilities) |
| OAuth 2.0 Metadata Discovery | .well-known/oauth-authorization-server 中的 grant_types_supported |
| gRPC | Deadline、Metadata、Compression 多层协商 |
| MCP | initialize 的 capabilities 字段(三层嵌套) |
能看到,几乎所有成功的协议都选择了能力协商而非硬版本号。这不是偶然。
21.3 模式二:传输抽象(Transport Abstraction)
21.3.1 解决的张力
协议逻辑应该与通信方式解耦(同样的 Tool 定义,不应因为从 stdio 切换到 HTTP 而改变),但不同部署场景需要不同的传输特性(本地进程用 stdio 更简单,远程服务需要 HTTP 的鉴权和重连)。
21.3.2 模式描述
MCP 定义了一个极简的 Transport 接口——启动、发送、关闭三个方法加三个回调——然后在此之上构建了所有协议逻辑。具体的传输实现是可插拔的:
// TypeScript SDK 的 Transport 接口(packages/core/src/shared/transport.ts,简化)
interface Transport {
start(): Promise<void>;
send(message: JSONRPCMessage, options?: TransportSendOptions): Promise<void>;
close(): Promise<void>;
onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void;
onclose?: () => void;
onerror?: (error: Error) => void;
sessionId?: string; // 可选
setProtocolVersion?: (version: string) => void; // 可选
}
两个"可选"字段值得注意:它们是给需要的传输留的口子,而不是所有传输的义务。sessionId 只有 Streamable HTTP 这类需要跨请求认领会话的传输才有;setProtocolVersion 让协商结果能回流到传输层(Client 侧的 Streamable HTTP 与 SSE 传输实现了它,Streamable HTTP 传输据此在后续请求头里带上 mcp-protocol-version)。用 ?: 而不是让每个传输都实现一遍空方法——抽象的边界靠"可选"来伸缩,而不是靠给基类塞默认实现。
任何实现了这个接口的对象都可以作为传输层——这意味着你可以在 Electron IPC、gRPC、甚至蓝牙上运行 MCP 协议,而无需修改任何上层代码。Protocol 类只调用 transport.send() 和监听 transport.onmessage,完全不知道消息是通过 stdin 还是 HTTP 传输的。
21.3.3 在 MCP 中的体现
- 第 12 章(stdio):最简单的传输,一行命令启动 Server,通过标准输入输出通信
- 第 13 章(Streamable HTTP):支持无状态部署、会话管理、SSE 推送的现代 HTTP 传输
- 第 14 章(SSE + WebSocket):两种已退场的传输——HTTP+SSE 已被取代,WebSocket 从未进入规范
- 第 20 章(构建 Server):同一套 Server 代码通过切换传输层支持本地和远程两种部署模式
21.3.4 传输特性差异矩阵
各传输层的实际差异:
先说清楚这四者的地位并不对等:2025-11-25 规范里定义的传输只有 stdio 和 Streamable HTTP 两种。独立的 HTTP+SSE 是 2024-11-05 的老传输,规范只在开头说明它已被取代、并在"向后兼容"一节里提到它(2026-07-28 版按新的特性生命周期政策把它正式归为 Deprecated,SEP-2596);WebSocket 从来不是规范定义的传输——TypeScript SDK 2.0(pin 版为 2.0.0-alpha)干脆移除了 WebSocketClientTransport,理由写在 CHANGELOG 里:"WebSocket is not a spec-defined transport; use stdio or Streamable HTTP." 但 Transport 接口仍然导出,任何人都可以自己实现一个。(Python SDK 的 pin 版仍带着 WebSocket 传输,此后才删除,见第 14 章。)
| 维度 | stdio | Streamable HTTP | HTTP+SSE(已弃用) | WebSocket(非规范) |
|---|---|---|---|---|
| 规范地位 | 规范定义 | 规范定义 | 2024-11-05 遗留,仅向后兼容 | 无(TS 需自建 Transport) |
| 部署形态 | 本地子进程 | 远程 HTTP 服务 | 远程 HTTP 服务 | 远程 HTTP 服务 |
| 启动成本 | 一次进程创建 | 一次 TCP(+TLS)握手 | 同 HTTP | 同 HTTP |
| 会话维持 | 进程生命周期 | 显式 session id | 单连接 | 单连接 |
| Server → Client 推送 | 原生(stdout) | SSE 子通道 | 原生 | 原生 |
| 扩展性 | 单实例 | 可水平扩展(配合会话存储与 EventStore) | 较差(每连接绑实例) | 较差 |
| 鉴权 | 进程继承环境 | OAuth 2.1(规范中为可选) | 同 HTTP | 同 HTTP |
| 调试工具 | 简单(终端) | 成熟(curl/Postman) | 浏览器 DevTools | 专用工具 |
| 延迟量级 | 本机管道,可忽略 | 一次网络往返 | 一次网络往返 | 一次网络往返 |
(这张表里没有具体毫秒数:启动成本和延迟完全取决于机器、网络和 TLS 会话复用情况,任何写死的数字都会在别人的环境里失真。要数字请在你自己的部署里量。)
同一套 Server 代码可以对接这些传输,只要传输实现了 Transport 接口。这是分层纪律的胜利——也正因为这条纪律,把 WebSocket 从 SDK 里删掉才只是删掉一个文件,而不是一次协议层手术。
21.3.5 可迁移的洞察
传输抽象的核心启示是分层的纪律:协议层只依赖抽象的消息收发接口,永远不引用具体的传输细节。这需要在设计之初就明确分层边界,之后严格执行。
一旦协议层"泄露"了对特定传输的假设(比如依赖 HTTP 的 Header 机制),抽象就会被破坏。LSP(Language Server Protocol)采用了同样的设计,这不是巧合——MCP 规范开篇就写明自己从 LSP 汲取了灵感。
反面教训:SMB/CIFS 协议早期紧耦合于 NetBIOS,想在 TCP/IP 网络上跑,得先垫一层 NBT(NetBIOS over TCP/IP)适配层,直到 Windows 2000 才能直接跑在 TCP 445 端口上——NBT 那条老路至今仍作为兼容选项背在身上。MCP 从第一天就定义清楚 Transport 接口,避免了这种"历史债务"。
21.4 模式三:三原语模型(Three-Primitive Model)
21.4.1 解决的张力
AI Agent 需要灵活地与外部世界交互(读数据、执行操作、获取指导),但交互模式必须标准化(否则每个 Server 都自创接口,生态无法形成)。
更深层的张力是:同样是"获取数据",由谁决定何时获取,安全模型完全不同。
21.4.2 模式描述
MCP 将 Server 能提供的一切抽象为三种原语,区分标准不是数据类型,而是谁控制何时使用:
| 原语 | 隐喻 | 控制方 | 典型交互 | 安全模型 |
|---|---|---|---|---|
| Tools | 手(执行能力) | LLM 决定调用 | tools/call |
需要权限检查 + 用户确认 |
| Resources | 眼(感知能力) | 应用程序控制 | resources/read |
应用已经决定加载,不需额外权限 |
| Prompts | 脑(知识模板) | 用户选择 | prompts/get |
用户主动触发,最高信任 |
控制方的不同直接决定了安全模型:
- Tools 需要权限检查——LLM 的决策是概率性的,可能误调用
- Resources 不需要额外权限——应用程序自己选择加载什么
- Prompts 也不需要额外确认——它本身就是用户主动触发的,确认动作已经发生在"用户选了这个模板"那一刻
21.4.3 在 MCP 中的体现
- 第 5 章(Tools):工具是 MCP 最活跃的原语,从简单的 API 调用到复杂的多步操作
- 第 6 章(Resources):资源提供了声明式的数据访问,支持订阅和变更通知
- 第 7 章(Prompts):提示模板定义了可复用的对话结构,引导 LLM 的行为
- 第 20 章(构建 Server):实战中三者的协同——Prompt 引导 LLM,LLM 决定调用 Tool,Tool 执行中读取 Resource
21.4.4 三原语的"否"问题
经常有人问:"为什么不能只要 Tool,不要 Resource 和 Prompt?"
从纯功能上说确实可以——Resource 可以用"只读 Tool"实现,Prompt 可以用"带参数的 Tool"实现。但这样做会破坏三件事:
- 安全模型统一化:如果一切都是 Tool,Client 必须对每次调用都弹用户确认,体验崩坏
- LLM 的选择效率:LLM 在数十个 Tool 里选哪个调用已经很困难,再混入数百个"只读 Tool"会把选择空间炸穿
- 语义透明度:Client UI 无法再给用户展示"这个 Server 提供 3 个只读资源 + 5 个可调用工具"这样的清晰视图
所以三原语的存在不是"冗余",而是把控制权维度显式化。不区分它们,代价不仅在协议层,更在用户体验和 LLM 使用效率层。
21.4.5 可迁移的洞察
三原语模型的深层智慧是按控制权分类,而非按数据类型分类。在设计任何 Agent 交互框架时,区分"谁决定何时使用"比区分"这是什么类型的数据"更有架构价值。数据结构可以相同(三个原语都返回 content 数组),但控制语义的差异决定了完全不同的安全和交互模型。
这个思路可以推广到所有需要"受控暴露能力"的系统:
- 浏览器:Permission(用户授权)、Capability(安全上下文)、API(开发者控制)三层
- Android:前台 Activity(用户控制)、Service(应用控制)、ContentProvider(跨应用约定)三类组件
- 数据库:DDL(DBA 控制)、DML(应用控制)、DQL(查询方发起)三层
"按控制权而非数据类型分层"是一种成熟的架构品味。
21.5 模式四:渐进式特性暴露(Progressive Feature Disclosure)
21.5.1 解决的张力
协议功能丰富(Elicitation、Sampling、Progress、Completion 等),但入门门槛应该低(一个最简单的 Server 应该几十行代码就能写出来)。
21.5.2 模式描述
MCP 的每个特性都是可选的。一个 Server 可以只暴露一个 Tool,不支持 Resources、不支持 Prompts、不支持 Completion、不支持 Logging——它仍然是一个完全合法的 MCP Server。随着需求增长,开发者可以逐步添加更多特性:
graph TB
L1["Level 1: 最小可用<br/>一个 Tool + stdio 传输"] --> L2["Level 2: 丰富交互<br/>+ Resources + Prompts"]
L2 --> L3["Level 3: 智能补全<br/>+ Completion + Tool Annotations"]
L3 --> L4["Level 4: 操作反馈<br/>+ Progress + Logging"]
L4 --> L5["Level 5: 反向通道<br/>+ Elicitation + Sampling"]
L5 --> L6["Level 6: 远程部署<br/>+ HTTP 传输 + OAuth"]
style L1 fill:#10b981,color:#333,stroke:none
style L2 fill:#2563eb,color:#fff,stroke:none
style L3 fill:#6366f1,color:#fff,stroke:none
style L4 fill:#7c3aed,color:#fff,stroke:none
style L5 fill:#db2777,color:#fff,stroke:none
style L6 fill:#f59e0b,color:#333,stroke:none
每一级都是可生产的——你不必一路爬到顶再发布。实际上很多 MCP Server 停留在 Level 2-3 就已经很有价值。
21.5.3 在 MCP 中的体现
- 第 8 章(TypeScript Server SDK):
McpServer高级 API 让创建基本 Server 只需十几行代码 - 第 10 章(Python Server SDK):
@server.tool()装饰器让工具定义简洁到极致 - 第 18 章(Elicitation/Roots):这些高级特性只在 Server 需要时才涉及
- 第 20 章(构建 Server):从最简开始,逐步添加 Completion、Progress 等特性
21.5.4 渐进式暴露 vs 钩子膨胀
有一个反模式叫"钩子膨胀":框架为了"灵活",暴露了几十个 Hook(before/after/aroundXYZ),开发者被迫理解全部钩子才能写一个最简单的 Server。早期的 Enterprise Java、某些重型 IoC 容器都患有这个病。
渐进式特性暴露的正确姿势:
- 有一个"最小心智模型":10 分钟就能读完文档入门
- 高级特性在自己的文档章节:不看不影响用
- API 的默认行为即合理行为:不用配置就能跑
- 每个特性独立可开关:不产生横向耦合
MCP 做到了这四点。对比一下:SOAP 协议把所有特性堆在一个 WSDL 里,写一个 Hello World 都要先啃一大段 XML——这就是渐进式暴露的反例。
21.5.5 可迁移的洞察
渐进式暴露的关键是正交性——每个特性应该独立于其他特性工作。如果添加 Completion 支持要求同时实现 Resources,那就不是渐进式的。MCP 做到了几乎完全的正交性:任何特性组合都是合法的。
这对协议和框架设计的启示是:在添加新特性时,始终问"这个特性是否可以独立于其他所有特性使用?" 如果答案是否,就要重新设计依赖关系。
21.6 模式五:安全即默认(Security by Default)
21.6.1 解决的张力
开放性是生态繁荣的前提(任何人都能写 Server),但安全是用户信任的基础(恶意或有缺陷的 Server 不应该损害用户)。
21.6.2 模式描述
MCP 把多层安全机制内建进协议与官方 SDK,而不是把安全作为"可选的最佳实践"留给实现者(下面 1-5 条写在规范里,第 6 条是 SDK 的默认行为):
- 人在回路(Human-in-the-loop):工具调用应当有人在回路、能拒绝调用(规范用 SHOULD),Elicitation 的 URL 必须经用户明确同意才能打开
- 最小权限原则:能力协商确保只有声明支持的特性才会被使用
- 安全分级:Form Mode 禁止收集密码和 API 密钥,URL Mode 确保敏感数据不经过 Client
- 传输安全:授权本身是可选的,但 HTTP 传输一旦支持授权就基于 OAuth 2.1 且必须实现 PKCE;Streamable HTTP 服务端必须校验
Origin头以防 DNS 重绑定 - 信息隔离:Server 默认看不到对话历史,不能访问其他 Server 的数据
- 环境隔离:这一条是 SDK 层而非协议层的默认——TypeScript SDK 的
StdioClientTransport用DEFAULT_INHERITED_ENV_VARS白名单派生子进程环境(类 Unix 上只有HOME/LOGNAME/PATH/SHELL/TERM/USER,这份名单直接借鉴了sudo),而不是把父进程的整个process.env倒过去,避免凭证顺着环境变量泄露给第三方 Server
21.6.3 在 MCP 中的体现
- 第 5 章(Tools):Tool Annotations(
readOnlyHint、destructiveHint)帮助 Client 做权限决策 - 第 15 章(OAuth):授权可选;HTTP 传输一旦支持授权就基于 OAuth 2.1、必须实现 PKCE,OAuth 2.1 本身已不再包含隐式授权流
- 第 18 章(Elicitation):Form Mode 禁止收集敏感信息,URL Mode 必须以 Client 和 LLM 都看不到页面内容与用户输入的安全方式打开
- 第 19 章(Claude Code):MCP 工具与内置工具冲突时内置优先,防止第三方覆盖核心功能
21.6.4 "默认安全"的四个等级
根据"让用户做正确的事有多容易"的不同,安全机制可以分四级:
| 等级 | 名字 | 特征 | MCP 中的例子 |
|---|---|---|---|
| L1 | 不安全是默认 | 需要配置才能安全 | (MCP 没有这种设计) |
| L2 | 安全是可选 | 默认不安全,文档提醒你开 | TS SDK transport 上的 DNS rebinding 防护选项(enableDnsRebindingProtection 默认关闭,第 13 章 §13.3) |
| L3 | 安全是默认 | 默认安全,可以手动关闭 | stdio 环境变量白名单(SDK 默认,可用 env 覆盖) |
| L4 | 安全不可关 | 规范以 MUST / MUST NOT 写死,没有开关可关(靠实现遵守) | URL Mode 禁止预认证 URL |
MCP 规范层面的安全要求大多数落在 L3-L4——默认就是安全的,且绕过它要付出代价;SDK 实现里仍有 L2 的地方,部署时要自己补上。
21.6.5 可迁移的洞察
安全即默认意味着不安全的行为应该比安全的行为更难实现。在 MCP 中,发送敏感数据通过 Form Mode 是被规范禁止的,你必须使用更复杂但更安全的 URL Mode——增加的摩擦是有意为之的。
这个理念可以这样概括:让做正确的事比做错误的事更容易(Make the right thing easy, and the wrong thing hard)。
现实中同类思想:
- Rust 的借用检查器——默认安全,需要
unsafe才能突破 - 浏览器的 HTTPS-Only 模式——默认走 HTTPS,访问纯 HTTP 要点"继续"
- CSP(Content Security Policy)——严格模式下内联脚本默认禁止
- iOS 沙箱——App 默认只能访问自己的数据,跨 App 交换数据要走 URL Scheme、App Extension 这类系统机制
这些都是"安全即默认"的模式实例。
21.7 模式六:协议版本策略(Protocol Versioning Strategy)
21.7.1 解决的张力
协议需要演进(新功能、修复设计缺陷),但已部署的实现不能被破坏(大量独立维护的 Client 和 Server 不可能同时升级)。
21.7.2 模式描述
MCP 使用日期格式的版本标识(如 2025-06-18、2025-11-25),而非语义化版本号。初始化时 Client 提出它想用的版本,Server 不会因为不认识就报错——它在自己支持的列表里挑:认识就用 Client 提的,不认识就回落到自己支持的最新版,把这个版本写进 InitializeResult 返回。
决定权因此落在客户端:规范的原话是"如果客户端不支持服务端响应里的版本,它 SHOULD 断开连接"。服务端尽量兼容,客户端负责叫停——这个分工比"任一方发现不匹配就报错"更能救回那些只是版本号新一点的连接,同时守住底线:宁可断开,也不要在双方对协议理解不一致的状态下继续跑。
TypeScript SDK 里这套逻辑就在 Server._oninitialize(packages/server/src/server/server.ts:426-444),三行:
this._supportedProtocolVersions.includes(requestedVersion) ? requestedVersion : (this._supportedProtocolVersions[0] ?? LATEST_PROTOCOL_VERSION)。
以上是本专栏锚定的 2025-11-25 版的做法。2026-07-28 版去掉了 initialize 握手:每个请求都在 _meta 里带上协议版本,Server 不支持就回 UnsupportedProtocolVersionError;Client 也可以先调新增的 server/discover 查询 Server 支持哪些版本。
21.7.3 日期版本 vs SemVer 的对比
| SemVer(如 1.2.3) | 日期版本(如 2025-11-25) | |
|---|---|---|
| 可比性 | 需要约定比较规则 | 天然按时间序 |
| "补丁"概念 | 有 | 无(每版本都是完整规范) |
| 什么叫 Breaking | 主版本+1 | 直接发新日期 |
| 争论焦点 | "这算 major 还是 minor" | 无——所有变更都是新版本 |
| 实现同步 | 需要 major 严格锁步 | 能力协商解决兼容 |
| 对开发者直观度 | 需要理解 major/minor | 直接看"2025 年 6 月版" |
MCP 协议没有"补丁"的概念——每个版本都是完整规范。这决定了日期版本比 SemVer 更合适。
21.7.4 与能力协商的配合
版本号 + 能力协商构成了双重兼容性策略:
- 版本号处理宏观的协议演进(消息格式变化、新方法添加)
- 能力协商处理微观的特性差异(同一版本内,不同实现支持不同特性)
这就像 HTTP 版本(1.1、2、3)与 HTTP Headers 的关系:版本决定了基本的通信规则,Headers 协商具体的行为。两个层次各司其职,互不干扰。
21.8 模式间的协同
这六个模式不是孤立的。它们形成了一个互相强化的系统:
graph TB
CN["能力协商"] --> |"决定可用特性"| PFD["渐进式暴露"]
CN --> |"确保安全特性生效"| SBD["安全即默认"]
TA["传输抽象"] --> |"任何传输上工作"| TPM["三原语模型"]
TA --> |"HTTP 传输启用"| SBD
TPM --> |"按控制权分层"| PFD
PFD --> |"每层独立演进"| PVS["版本策略"]
SBD --> |"安全约束纳入规范"| PVS
PVS --> |"新版本添加新能力"| CN
style CN fill:#2563eb,color:#fff,stroke:none
style TA fill:#7c3aed,color:#fff,stroke:none
style TPM fill:#db2777,color:#fff,stroke:none
style PFD fill:#10b981,color:#333,stroke:none
style SBD fill:#f59e0b,color:#333,stroke:none
style PVS fill:#6366f1,color:#fff,stroke:none
考虑一个具体的例子:当 MCP 在 2025-11-25 版本中引入 URL Mode Elicitation 时——
- 版本策略让只懂旧版本的 Client 仍按旧版本连接,不必与 Server 锁步升级
- 能力协商让新 Client 可以声明是否支持
url模式 - 安全即默认要求敏感数据必须通过 URL Mode,不能走 Form Mode
- 渐进式暴露确保不需要 URL Mode 的 Server 完全不受影响
- 传输抽象保证这个特性在 stdio 和 HTTP 上都能工作
- 三原语模型中,URL Elicitation 自然地嵌入到 Tool 执行流程中
六个模式像齿轮一样咬合,每个都让其他模式更有效。这就是成熟协议的标志——不是"有多少特性",而是"特性之间有多融洽"。
21.9 MCP 的设计权衡
没有完美的设计。每个模式背后都有权衡,诚实地记录这些权衡是本章的责任。
21.9.1 能力协商的代价
初始化握手增加了首次连接的延迟(2026-07-28 版去掉了握手,省掉了这次往返,代价是每个请求都要在 _meta 里携带版本与能力)。每个 Client 和 Server 都需要维护能力映射表。但这个代价换来了无需版本锁步升级的自由度——对于一个开放生态来说,这是值得的。
代价的量级:initialize 请求/响应的一个 RTT,加上一份不大的能力声明数据。RTT 取决于传输——stdio 是本机管道,量级在毫秒以内;HTTP 取决于网络往返,同城几十毫秒、跨区上百毫秒都正常。这里给的是数量级判断,不是实测数据,真实数字请在你自己的部署环境里量。对于长连接场景可以忽略,对于冷启动场景可用预热或 keep-alive 缓解。
21.9.2 三原语的边界模糊
在实践中,Tool 和 Resource 的边界有时不清晰。"获取文件内容"是 Resource 还是 Tool?
MCP 规范的回答是看控制方——如果是应用程序自动加载上下文,用 Resource;如果是 LLM 决定需要获取,用 Tool。这个区分在概念上清晰,但在实现中需要开发者做出判断。
实践建议:
| 场景 | 选 Resource | 选 Tool |
|---|---|---|
| 数据集合固定、提前知道 URI | ✓ | |
| 数据依赖 LLM 推理后才能确定 URI 或参数 | ✓ | |
| Client 要展示"所有可用的 X" | ✓ | |
| 操作有副作用(哪怕是读缓存也 mutate) | ✓ |
21.9.3 安全的摩擦
安全机制增加了开发复杂度。一个简单的 Server 如果需要收集 API 密钥,必须实现 URL Mode Elicitation 的完整流程——包括 HTTPS 页面、用户身份验证、回调处理。对于小型项目来说,这可能显得过重。但协议设计者做出了明确的取舍:宁可让少数开发者多写代码,也不让多数用户面临安全风险。
这条"摩擦线"画在哪里,体现了协议作者的价值判断——MCP 明显站在用户一侧。
21.9.4 JSON-RPC 的局限
MCP 选择 JSON-RPC 2.0 作为基础,获得了简单性和广泛的语言支持,但也继承了它的限制:
- 没有原生的流式传输——需要 SSE 补充
- 没有二进制消息——大文件传输效率较低(base64 膨胀 33%)
- 靠
id而不是靠帧头做多路复用——同一条连接上并发请求是可以的(响应按id配对),但没有 HTTP/2 那样的流控与优先级,背压得靠传输层或应用层自己做
这些限制通过传输层的扩展来弥补,但增加了整体复杂度。如果重新设计,是否会选 Protocol Buffers + gRPC?本专栏的判断是:JSON 的人类可读性 + 开箱即用的调试能力在 AI Agent 场景(本来就以文本为主)中胜过了性能考量。
21.10 本专栏架构决策卡片
把前 20 章分散的设计决策汇总成一张表,作为本专栏的"架构决策卡片":
| 决策 | 选择 | 放弃的方案 | 理由 | 相关章节 |
|---|---|---|---|---|
| 消息格式 | JSON-RPC 2.0 | REST, gRPC, 自定义 | 简单、双向、人类可读 | 第 3 章 |
| 版本号格式 | 日期 (2025-11-25) |
semver | 避免兼容性歧义 | 第 4 章 |
| 交互原语 | Tool/Resource/Prompt | 统一 Action 模型 | 按控制权分类更精确 | 第 5-7 章 |
| 本地传输 | stdio | Unix Socket, Named Pipe | 跨平台、零配置 | 第 12 章 |
| 远程传输 | Streamable HTTP | REST+WebSocket | 单一端点、渐进增强 | 第 13 章 |
| 认证(HTTP 传输,可选) | OAuth 2.1 + PKCE | API Key, mTLS | 无需预存关系、支持作用域 | 第 15 章 |
| Schema 验证 | JSON Schema 2020-12 | TypeScript 类型、Protobuf | 语言无关、工具丰富 | 第 5 章 |
| Server 发现 | 配置文件 + 官方 MCP Registry(preview)+ OAuth 授权发现 | DNS-SD, mDNS | 简单、显式、安全 | 第 16、20 章 |
| 敏感信息收集 | URL Mode Elicitation | Form Mode 或 Prompt 注入 | 数据不经 Client/LLM | 第 18 章 |
| 反向请求关联 | 挂在当前正向请求上(2025-11-25 为推荐的交互模型,2026-07-28 以 MRTR 写进协议结构) | 脱离正向请求的独立通道 | 用户能看懂提问由哪次操作引起 | 第 18 章 |
| Token 使用 | Client 安全存储、每个 HTTP 请求带 Authorization 头;Server 校验受众、禁止透传给上游 |
Server 把收到的 Token 转发给上游 API | 令牌只对签发给的那个 Server 有效 | 第 15 章 |
| Sampling 鉴权 | 人在回路、用户可拒绝(SHOULD) | Server 自持 Token/API Key | 防滥用 LLM 费用 | 第 17 章 |
(这张表按本专栏锚定的 2025-11-25 版整理。2026-07-28 版已把 Sampling、Roots、Logging 和动态客户端注册标为 Deprecated,Sampling 建议改为直接对接 LLM 提供商的 API。)
21.11 面向未来的思考
MCP 的设计模式揭示了几个面向未来的方向:
21.11.1 Agent-to-Agent 通信
当前 MCP 是 Client-Server 模型,但 AI Agent 生态正在走向 Agent 间直接协作。MCP 的传输抽象和能力协商机制可以自然地扩展到 Agent-to-Agent 场景——只要把"Agent"也视为一个 Peer,双方在 initialize 时协商对等能力即可。
已经出现的实验包括 Google 的 A2A 协议。
21.11.2 多模态原语
当前的三原语模型主要处理文本和简单的二进制数据。随着多模态 AI 的发展,视频流、实时音频、3D 场景等数据类型可能需要新的原语或现有原语的扩展。
一个可能的演进方向是在三原语上增加"Stream"原语——专门处理连续的时序数据,但这会触碰到 JSON-RPC 本身的边界,可能需要传输层的深度修改。
21.11.3 去中心化发现
当前的 Server 发现依赖配置文件,以及官方的 MCP Registry——它已上线但仍处于 preview,API 冻结在 v0.1(见 §16.7 与 §20.11.2)。让 Server 在 /.well-known/mcp.json 暴露元数据、不连接就能被发现的 MCP Server Card 目前还只是提案,两版规范正文都没有它。未来可能出现基于 DNS-SD、mDNS 或区块链的去中心化发现机制——类似于 IPFS 对静态内容做的事,应用到 Agent Service Discovery 上。
21.11.4 长生命周期任务
MCP 的 Progress 和 Cancellation 机制为长操作提供了基本支持,但随着 Agent 执行越来越复杂的任务(几小时甚至几天),需要更完善的任务管理原语——Tasks 就是这个方向的答案,它已经写进 2025-11-25 规范(basic/utilities/tasks),能力声明细到 tasks.requests.tools.call。
不过"写进规范"不等于"稳定":2025-11-25 的 changelog 原文就是 "Add experimental support for tasks"(docs/specification/2025-11-25/changelog.mdx 主要变更第 9 条);TypeScript SDK 把它隔离在 server.experimental.tasks 命名空间下,Python SDK 则叫 experimental_task_handlers。这是一个正在成型、但还不该被生产代码依赖的特性。2026-07-28 版果然大改:Tasks 移出核心协议,成为官方扩展 io.modelcontextprotocol/tasks,tasks/result 换成用 tasks/get 轮询、tasks/list 被删除(SEP-2663)。
Tasks 特性的核心思路:把长操作从 "request-response" 模式升级为 "fire-poll-retrieve" 模式,类似于 AWS Step Functions 的异步执行语义。
21.12 本专栏总结
MCP 不只是一个协议规范——它是一套关于如何让 AI 系统安全、可靠、可扩展地与外部世界交互的设计思想。
从第 1 章到第 21 章,我们完成了一次完整的旅程:
- 为什么:AI Agent 需要标准化的外部交互协议(第 1 章)
- 是什么:基于 JSON-RPC 的 Client-Server 架构,三大原语,能力协商(第 2-7 章)
- 怎么实现:TypeScript 和 Python SDK 的深度解读(第 8-11 章)
- 怎么通信:从 stdio 到 Streamable HTTP,传输层的选择与权衡(第 12-14 章)
- 怎么安全:OAuth 授权、Elicitation 安全隔离、工具注解(第 15、18 章)
- 怎么发现:配置文件、注册表、动态发现(第 16 章)
- 怎么协作:Sampling、Elicitation、Progress 等反向通道(第 17-18 章)
- 怎么落地:Claude Code 的 1.6 万行实战经验(第 19 章)
- 怎么构建:从零到生产级 Server 的完整开发流程(第 20 章)
- 为什么这样:六大设计模式与架构决策(本章)
MCP 协议仍在快速演进。但本专栏覆盖的核心概念和设计模式是稳定的——它们不会随版本更新而失效,因为它们反映的是更深层的系统设计智慧。理解了这些模式,你不仅能用好 MCP,更能在面对下一个协议设计问题时,做出更好的架构决策。
模型是引擎,协议是道路。好的道路不限制车速,但确保每辆车都到达正确的目的地。
本专栏源码参考
- MCP 规范:
https://github.com/modelcontextprotocol/modelcontextprotocol(原名specification,已重命名) - TypeScript SDK:
https://github.com/modelcontextprotocol/typescript-sdk - Python SDK:
https://github.com/modelcontextprotocol/python-sdk