KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
MCP 协议工程:从连接边界到可验证的集成 — keel 龙骨
MCP Protocol Engineering 的长文信息:MCP 协议工程:从连接边界到可验证的集成
文档定位:面向已经理解 Agent 运行时与工具调用的工程师,把 MCP 理解为一条可测试的连接协议,而不是一组框架 API。
阅读逻辑:先纠正一个流传很广的误解 → 拆开协议的三层结构 → 用课程项目走一遍完整链路 → 处理版本、授权、任务和兼容这四类真实问题 → 明确它不负责什么。
依据版本:MCP 规范 2026-07-28。官方定义与规范条目检索于 2026-09-29,来源见文末。协议处于活跃演进期,涉及 MUST/SHOULD 的结论请以该版本规范原文为准。
一、先把一个误解按在地上
先看一段在中文技术社区流传很广的描述:
MCP 就是 AI Agent 的「上下文管家通用标准」,负责管理上下文窗口限制、Token 预算分配、智能压缩回收,是所有稳定靠谱智能 Agent 的底层核心。
这段话听起来很有条理,但每一个分句都是错的,而且错得很危险——它把 MCP 和上下文工程(Context Engineering)缝合成了同一个东西。
官方规范的原话是:
MCP 是一个开放协议,用于在 LLM 应用与外部数据源和工具之间实现无缝集成(seamless integration between LLM applications and external data sources and tools)。
官方入门的类比更直白:MCP 之于 AI 应用,相当于 USB-C 之于电子设备——它提供的是标准化的连接方式,不是连接之后你来我往的内容该怎么管理。
| 说法 | 事实 |
|---|---|
| MCP 管理上下文窗口 | ❌ 窗口容量、预算分配、压缩策略由 Agent 的上下文工程层决定 |
| MCP 负责 Token 预算 | ❌ 预算属于 Harness / Context Engineering 的职责 |
| MCP 做智能压缩回收 | ❌ 压缩是上下文工程的手段,MCP 完全不参与 |
| MCP 连接 AI 应用与外部能力 | ✅ 这才是它解决的问题 |
为什么这个误解会流行
因为它混淆了「传输通道」和「通道里跑什么」。MCP 的 Server 确实会提供上下文(resources)——但那是数据本身,不是上下文的管理策略。就像一个快递员把包裹送到你家,他负责运输,不负责你书桌怎么整理。
判断口诀:如果一个说法把 MCP 描述成「决定模型看到什么、看多少」,它讲的是上下文工程;如果说的是「让模型所在的应用能够发现并调用外部能力」,它讲的才是 MCP。
这个误解的实际代价
在面试里,这个误解是高频失分点。当面试官问「MCP 和上下文工程什么关系」,如果你答「MCP 管上下文」,接下来追问「那你觉得 MCP 规范里哪一条规定了 token 预算」就会立刻穿帮——规范里根本没有这类条目。
正确的回答框架是:
MCP 和上下文工程是正交的两层。MCP 定义的是「外部能力怎么被连接和调用」;上下文工程定义的是「调用前后,哪些信息该进模型、该占多少预算、装不下时怎么退」。一次真实调用里,两者通过 Harness 交汇:Harness 用上下文工程决定这一轮带哪些工具描述进 prompt,用 MCP 决定这些工具具体怎么被发现和调用。
本章检查点
- 用一句话说明 MCP 与 Context Engineering 的分工,不得出现「管理上下文」这个说法。
- 指出上表四个错误说法里,哪两个其实描述的是同一件事。
二、协议的三层结构:数据层、消息层、传输层
MCP 规范自己不是按「功能列表」组织的,而是按分层组织的。理解这个分层,比记住有哪些方法重要得多。
┌─────────────────────────────────────────────┐
│ 传输层 (Transport Binding) │
│ stdio:换行分隔的 JSON-RPC,走子进程标准流 │
│ Streamable HTTP:每次消息一个 HTTP POST │
│ 自定义传输:只要保持消息格式与元数据模型 │
├─────────────────────────────────────────────┤
│ 消息层 (Message Patterns) │
│ Request / Response / Notification │
│ 谁可以发起请求?谁只能回应? │
├─────────────────────────────────────────────┤
│ 数据层 (JSON-RPC 2.0) │
│ jsonrpc / method / params / id │
│ 错误对象:code / message / data │
└─────────────────────────────────────────────┘
规范对这三层有一条关键约束:协议语义在每种传输上完全相同(Protocol semantics are identical on every transport)。传输只是一层绑定(binding),它规定「消息怎么分帧、怎么送、怎么取消」,不规定消息是什么意思。
这条约束的工程价值:你为一个自研传输写的协议测试,理论上应该能直接复用到 stdio 上。如果换传输就要重写业务逻辑,说明你把传输细节泄漏进了协议层。
2.1 数据层:JSON-RPC 2.0 的最小形状
课程项目里的 JsonRpcRequest 就是这一层的最小实现:
@dataclass(frozen=True)
class JsonRpcRequest:
method: str
params: dict[str, Any] = field(default_factory=dict)
request_id: int | str | None = None
def to_dict(self) -> dict[str, Any]:
result: dict[str, Any] = {"jsonrpc": "2.0", "method": self.method}
if self.request_id is not None:
result["id"] = self.request_id
if self.params:
result["params"] = self.params
return result
@property
def is_notification(self) -> bool:
return self.request_id is None
is_notification 这个属性是整段代码里最重要的一个判断:没有 id 的消息是通知,不是请求。通知不需要回复,也不应该收到回复。把通知当请求处理(回一个响应)会破坏协议对称性,某些客户端会因此报出「收到意外响应」的错误。
2.2 消息层:谁可以发起请求
这是 2026-07-28 版本相对早期规范最容易被忽略、也最容易踩坑的一条变化。
早版本允许服务器主动向客户端发起 JSON-RPC 请求(例如请求模型做一次 sampling)。新规范收紧了方向:
绑定必须把客户端发出的请求和通知投递给服务器,把服务器发出的响应和通知投递给客户端。不存在其他消息方向:服务器不发起 JSON-RPC 请求,客户端不发送 JSON-RPC 响应。
用课程项目的 InMemoryTransport 可以把这个不对称性写成一个断言:
def test_server_never_initiates_request(transport):
server_response = transport.receive() # 只有响应/通知
assert "id" not in server_response or "result" in server_response
assert "method" not in server_response # 服务器不得发请求
这条规则为什么重要:它把「服务器能反向驱动客户端」这个安全面直接砍掉了。早期设计里,服务器可以要求客户端调用模型,这意味着一个不可信的 Server 有机会消耗你的模型预算、看到你的模型输出。改成单向之后,Server 只能被动响应,威胁模型显著收窄。
2.3 传输层:两种标准绑定
| 绑定 | 消息如何送达 | 取消如何表达 | 适用场景 |
|---|---|---|---|
| stdio | 客户端启动子进程,走标准输入输出,换行分隔 | 客户端发 notifications/cancelled |
本地工具、单机部署 |
| Streamable HTTP | 每条消息一个 HTTP POST 到同一个 MCP 端点,回复是 JSON 对象或请求作用域的 SSE 流 | 客户端关闭该请求的响应流 | 远程服务、多客户端共享 |
注意 Streamable HTTP 的设计细节:不是「建一条长连接然后双向跑」,而是每条消息一次 POST。这在工程上意味着它天然适配无状态的服务端部署(可以放在负载均衡后面、可以水平扩展),代价是需要额外的会话标识机制来关联同一逻辑会话的多次 POST。
规范也允许自定义传输,但有两条硬约束:必须保留 JSON-RPC 消息格式与每请求元数据模型;如果跑在可靠的字节流上(Unix socket、TCP),应该复用 stdio 的分帧方式,而不是自己发明一套。
本章检查点
- 说明为什么「服务器不发起请求」这条规则会收窄安全面。
- 如果要把课程项目从内存传输换成 Streamable HTTP,哪些代码不用改?说明理由。
三、三种原语:谁控制、谁看见
规范把服务器能力归纳为三类原语。很多资料只列名字,但真正决定设计的是「每一类由谁控制」。
| 原语 | 是什么 | 谁控制 | 典型例子 |
|---|---|---|---|
| Resources | 上下文与数据 | 应用/用户决定何时读取 | 文件、数据库记录、runbook 文档 |
| Prompts | 模板化消息与工作流 | 用户显式选择 | 斜杠命令、预设提示模板 |
| Tools | 模型可执行的函数 | 模型决定调用(经用户同意) | 检索事件、读取服务健康状态 |
这张表的关键在最后一列的「谁控制」。它不是实现细节,而是权限设计:
- 资源是用户要来的——所以按用户的身份做访问控制;
- 提示是用户选的——所以它不该偷偷被模型自动展开;
- 工具是模型想调的——所以它必须经过独立的鉴权与审批,不能因为模型「想调」就执行。
课程项目里,list_tools 用 scope 过滤来落实第三行:
def list_tools(self, principal: Principal) -> list[ToolDefinition]:
return [
definition
for definition, _ in self.tools.values()
if set(definition.required_scopes).issubset(principal.scopes)
]
注意这里是子集判断(issubset),不是「有交集即可」。这个选择决定了:一个只有 service:read 的凭据,看不到 incident.search(它需要 incident:read),因此也就不会把它写进模型可调用的工具列表。
设计要点:不可见优于可见但拒绝。如果工具出现在列表里但调用时才拒绝,模型会反复尝试、产生无意义的重试与 token 消耗;如果它从一开始就不在列表里,模型根本不会提出这个请求。
3.1 客户端也能提供能力:Elicitation
规范明确:客户端可以提供给服务器一项能力——Elicitation,即服务器请求用户补充信息。注意它仍然遵守「服务器不发起请求」的规则:服务器是通过响应里的特定结构表达「我还需要输入」,而不是反过来发一个请求给客户端。
这正是课程项目里任务状态 input_required 的来源:
@dataclass
class TaskSnapshot:
task_id: str
status: str = "working"
input_schema: dict[str, Any] | None = None
input_value: dict[str, Any] | None = None
result: dict[str, Any] | None = None
idempotency_key: str | None = None
input_schema 不为空时,任务初始状态直接是 input_required,而不是先跑起来再中途卡住。这个「先声明需要什么,再进入等待」的顺序,让客户端可以在不破坏任务的情况下先收集输入。
本章检查点
- 解释为什么
list_tools用子集判断而不是交集判断。 - Elicitation 与「服务器主动请求」的区别在哪里?为什么前者不违反单向规则?
四、走一遍完整链路:Incident Bridge
理论到这里够了,下面把协议放回一个具体业务:企业事件诊断助手。它需要读取已授权的事件摘要、检查服务健康,并且能在需要补充服务名时暂停、等待、再恢复。
4.1 一次运行涉及的五个对象
Host(诊断助手应用)
│ 创建并管理
├─► Client(对应 incident-bridge 这一个 Server 的连接)
│ │ JSON-RPC 双向
│ └─► Server(incident-bridge)
│ ├─ tools: incident.search / service.health
│ ├─ resources: knowledge://runbook/{id}
│ └─ tasks: diagnose(长任务,可暂停恢复)
│
└─► 模型(决定调哪个工具)
Host 负责编排与安全策略,Client 负责一条连接的协议细节,Server 只暴露它自己那几项能力。规范明确:Server 不应读取整段对话,也不能「看穿」其他 Server。每条 Client–Server 连接是隔离的。
这不是建议,而是架构原则。它带来的直接收益是:你可以把公司内部的 runbook Server 与一个第三方搜索 Server 同时挂在一个 Host 下,而第三方无法通过协议得知内部 Server 返回了什么。
4.2 初始化:能力协商
def initialize(self, protocol_version: str, client_capabilities: dict[str, Any] | None = None) -> dict[str, Any]:
if protocol_version != self.protocol_version:
self.events.record("protocol.rejected", requested=protocol_version, supported=self.protocol_version)
raise ValueError(f"unsupported protocol version: {protocol_version}")
self.events.record("protocol.initialized", version=protocol_version, client_capabilities=client_capabilities or {})
return {
"protocolVersion": self.protocol_version,
"capabilities": self.capabilities.to_dict(),
"serverInfo": {"name": "incident-bridge", "version": "0.1.0"},
}
两个细节值得注意:
版本不匹配直接拒绝,并记一条
protocol.rejected事件。拒绝要留痕——否则线上出问题时,你无法区分「客户端没连上」和「客户端连上了但被拒」。能力在响应里显式声明(
capabilities.to_dict()),而不是让客户端猜测。规范要求服务器把实现的功能在其 capabilities 里声明;未声明的能力,客户端不应假设可用。这段代码建模的是 Legacy 生命周期,不是 2026-07-28 的唯一形态。 这一条必须说清楚,否则照着写会走偏。版本化文档把实现分成三代:
术语 含义 Modern 2026-07-28及以后,版本、身份、能力通过每请求_meta传递,没有协商握手Legacy 2025-11-25及以前,用initialize握手建立会话Dual-era 两者都支持,按客户端怎么开口来选择行为 也就是说,真正的 Modern 服务端并不依赖一次
initialize——它对每一个请求独立校验版本,不匹配就返回错误对象(规范定义的UnsupportedProtocolVersionError,错误码-32022,data里给出supported列表与requested值),客户端挑一个双方都支持的版本重试:{ "jsonrpc": "2.0", "id": 1, "error": { "code": -32022, "message": "Unsupported protocol version", "data": {"supported": ["2026-07-28", "2025-11-25"], "requested": "1900-01-01"} } }教学项目保留
initialize有两个理由:一是 Dual-era 服务端仍然会接受它(否则旧客户端无从下手),二是这个项目的传输是进程内函数调用,没有地方挂"每请求信封"。换成真实 stdio / Streamable HTTP 时,版本校验的位置要从"握手一次"搬到"每个请求入口"——这是本课程明确的生产替换点之一。
CapabilitySet 的序列化形状直接对应规范:
def to_dict(self) -> dict[str, Any]:
result: dict[str, Any] = {}
if self.tools:
result["tools"] = {"listChanged": False}
if self.resources:
result["resources"] = {"subscribe": False}
if self.prompts:
result["prompts"] = {"listChanged": False}
if self.tasks:
result["tasks"] = {"requests": {"list": True, "get": True, "cancel": True}}
if self.extensions:
result["extensions"] = list(self.extensions)
return result
listChanged: False / subscribe: False 这类字段的含义是「我不支持这个子特性」。显式声明不支持比省略更好:客户端可以据此决定不退订、不轮询,避免双方对能力的理解不一致。
4.3 发现:server/discover
def discover(self, principal: Principal) -> dict[str, Any]:
visible = self.list_tools(principal)
self.events.record("server.discover", subject=principal.subject, tools=len(visible))
return {
"name": "incident-bridge",
"version": "0.1.0",
"capabilities": self.capabilities.to_dict(),
"tools": [tool.to_dict() for tool in visible],
"resources": [{"uri": "knowledge://runbook/{id}", "name": "授权运行手册"}],
}
discover 是 2026-07-28 版本引入的重要机制。它的作用是让客户端在不建立有状态会话的前提下了解对方提供什么。这与「协议改为无状态、每请求携带版本与能力」是配套的设计:
早期:initialize 建会话 → 会话内保持状态 → 后续请求依赖会话
现在:每个请求自带 _meta(版本 + 客户端能力)→ 无需会话状态 → 可水平扩展
规范把每请求元数据放在消息体的 _meta.io.modelcontextprotocol/* 字段里,并允许传输层镜像这些字段到信封(例如 Streamable HTTP 把它反射到 HTTP 头),好处是中间层不必解析 body 就能做路由与检查。
关键约束:镜像出去的是副本,消息体始终是唯一真相来源(the body remains the source of truth)。如果两者冲突,以 body 为准,且绑定必须定义如何拒绝这种不一致。
每请求 _meta 里规范明确要求携带的键,都带固定命名空间前缀:
| 键 | 必填 | 说明 |
|---|---|---|
io.modelcontextprotocol/protocolVersion |
是 | 该请求所用的协议版本 |
io.modelcontextprotocol/clientCapabilities |
是 | 客户端能力 |
io.modelcontextprotocol/clientInfo |
否 | 客户端身份(名称 + 版本) |
io.modelcontextprotocol/logLevel |
否 | 已被后续提案弃用 |
应答方向也有对称的要求:服务端 SHOULD 在每个响应上戳 io.modelcontextprotocol/serverInfo。
另外两个容易写错的点,都是教学实现为了让代码短一些而做的简化,换成生产必须改回来:
- 扩展标识符必须是带前缀的完整 DNS 式名字。规范里 Tasks 写作
io.modelcontextprotocol/tasks,MCP Apps 写作io.modelcontextprotocol/ui,而且capabilities.extensions是映射(扩展标识符 → 该扩展的配置对象),不是字符串数组。本项目写成extensions=("tasks",)是为了让代码短一些、把注意力留"扩展需要协商"这件事上,换成生产线必须改成映射形式。 serverInfo是自报的、不被协议验证。官方原文说得很直接:它用于展示、日志和调试,客户端 SHOULD NOT 用它改变行为,SHOULD NOT 把它用作安全决策依据。把"服务器自称是谁"当成授权输入,是一个真实存在的 confused deputy 入口。
4.4 工具调用:鉴权在调用路径上
def call_tool(self, name: str, arguments: dict[str, Any], token: str, tenant: str) -> ToolResult:
if name not in self.tools:
raise KeyError(f"unknown tool: {name}")
definition, handler = self.tools[name]
principal = verify_token(self.secret, token, definition.required_scopes[0], tenant)
required = definition.input_schema.get("required", [])
if any(not arguments.get(field) for field in required):
self.events.record("tool.rejected", tool=name, subject=principal.subject, reason="invalid_input")
return ToolResult([{"type": "text", "text": "required input is missing"}], is_error=True)
self.events.record("tool.started", tool=name, subject=principal.subject, tenant=tenant)
result = handler(arguments, principal)
self.events.record("tool.finished", tool=name, subject=principal.subject, error=result.is_error)
return result
这 15 行里有四个独立的检查,顺序不能换:
- 工具是否存在——未知工具是错误,不能静默忽略;
- 凭据是否有效且带所需 scope——
verify_token同时校验签名、过期、租户、scope; - 输入是否符合 schema——缺失必填字段返回
is_error=True的结果,而不是抛异常; - 执行并记录——开始与结束都记事件,用
error=result.is_error关联。
第 3 点的处理方式值得展开:输入校验失败返回错误结果,而不是抛出异常。区别在于,错误结果会回到模型,模型有机会修正参数重试;抛异常则可能直接中断整次运行。规范对工具错误的处理倾向是可以表达为结果的错误就表达为结果。
4.5 长任务:pause / resume 的实现
def create(self, input_schema=None, idempotency_key=None) -> TaskSnapshot:
if idempotency_key:
for task in self.tasks.values():
if task.idempotency_key == idempotency_key:
return task
task = TaskSnapshot(
str(uuid.uuid4()),
"input_required" if input_schema else "working",
input_schema,
idempotency_key=idempotency_key,
)
self.tasks[task.task_id] = task
self._save()
return task
两点设计:
- 幂等键先行:
idempotency_key命中就直接返回既有任务。这让客户端可以安全重试创建请求,不会产生两个诊断任务。规范把 Tasks 列为扩展,其异步语义(含中途中输入、持久句柄)正是这类长任务的规范载体。 - 状态机显式化:
working → input_required → working → completed,加上failed/cancelled两个终态(TERMINAL_STATES)。终态判断在complete和cancel里都存在,保证已结束的任务不会被重复改写。
为什么任务需要持久化:TaskStore 支持传入路径并在初始化时读回 JSON。没有这一步,进程重启后所有 input_required 的任务都会消失,用户补充的输入无处可归。规范对 Tasks 扩展的要求包含「持久句柄」,对应同一个工程判断。
本章检查点
call_tool里四个检查的顺序如果调换(例如先校验输入再验凭据),会引入什么问题?- 说明
server/discover与「无状态化」之间的关系。
五、授权:什么时候必须有,什么时候不该有
授权是 MCP 里最容易被简化处理、也最容易出事故的部分。规范给出的边界很清楚:
| 传输 | 授权要求 |
|---|---|
| HTTP 类传输 | SHOULD 遵循 OAuth 授权规范 |
| stdio | SHOULD NOT 遵循该规范,改为从环境读取凭据 |
| 其他传输 | MUST 遵循该协议既有的安全最佳实践 |
stdio 为什么不走 OAuth:因为进程由客户端亲自启动,身份边界已经由操作系统给出,再叠一层 OAuth 只会增加复杂度而不增加安全性。机制要匹配威胁模型,不是越多越好。
5.1 HTTP 授权模型
规范把角色对齐到 OAuth 2.1:
MCP Client ──(OAuth 2.1 client)──► Authorization Server
│ │ 签发 access token
│ Authorization: Bearer <token> │
▼ │
MCP Server ──(OAuth 2.1 resource server)◄──┘
几条硬要求:
- 授权服务器必须实现 OAuth 2.1;
- MCP 服务器必须实现 Protected Resource Metadata(RFC 9728),客户端必须用它做授权服务器发现;
- 客户端必须实现 Resource Indicators(RFC 8707),在授权请求与令牌请求中都带上
resource参数; - 服务器必须校验令牌是专门签发给自己的(audience 校验),失败时返回 401。
5.2 为什么 audience 校验不能省
这是 MCP 授权里最关键的一条,对应一类叫 confused deputy(混淆代理)的攻击:
1. 用户授权给 A 服务器,拿到 token T(audience = A)
2. 攻击者控制的 B 服务器拿到了 T(通过日志泄漏、或者用户误配置)
3. B 用 T 去请求 A 的资源
4. 如果 A 不校验 audience,就会认为这是合法请求 → 越权成功
所以规范的原话是:MCP 服务器必须只接受对自己资源有效的令牌,且不得接受或中转任何其他令牌。token 不是「通行证」,是「限定用途的凭证」。
5.3 课程项目的教学替身
课程项目用 HMAC 签名令牌模拟这一层,并在 verify_token 里保留了租户绑定:
if token_tenant != tenant:
raise ValueError("tenant mismatch")
scopes = frozenset(filter(None, raw_scopes.split(",")))
if required_scope not in scopes:
raise ValueError("missing scope")
必须诚实说明:这是教学替身,不是 OAuth 实现。它验证了签名、过期、租户、scope 四件事,但没有实现真实的授权服务器发现、PKCE 流程、resource indicator 与 audience 校验。
生产替换点是明确的:
| 教学实现 | 生产替换 |
|---|---|
| HMAC 共享密钥 | OAuth 2.1 授权服务器 + 非对称签名 |
tenant 参数比对 |
令牌中的 audience / resource 声明校验 |
| 本地 scope 集合 | 授权服务器下发的 scope 与 Protected Resource Metadata |
| 内存/JSON 文件存任务 | 持久化数据库 + 队列 |
本章检查点
- 用一个具体场景说明 confused deputy 攻击,并指出哪一步校验可以阻断它。
- 为什么 stdio 传输不应该套用 OAuth 规范?
六、版本、兼容与观测
6.1 版本协商与向后兼容
规范在传输层明确承认了历史包袱:
早期协议版本建立了连接作用域的会话(带
initialize握手),并允许服务器发起 JSON-RPC 请求。与那些版本互通的客户端与服务器检测对方所处的版本时代并降级。
这带来一个实现要求:你不能只判断版本号,还要判断对方的行为时代。一个声称支持旧版本的客户端,可能期待会话状态与服务器主动请求;一个新客户端可能完全不发 initialize。
课程项目的处理是显式拒绝:
if protocol_version != self.protocol_version:
self.events.record("protocol.rejected", requested=protocol_version, supported=self.protocol_version)
raise ValueError(f"unsupported protocol version: {protocol_version}")
这是教学实现的合理简化,但要写清楚边界:生产实现需要一张兼容矩阵,按对方版本决定走哪条路径,而不是一律拒绝。
6.2 观测:让兼容性可证明
「兼容不是能连上」——课程项目的 09 章标题就是这个判断。规范里也有对应的可观测要求:配置、进度追踪、取消、错误报告属于核心工具面。
课程项目的 EventRecorder 记录了四类关键事件,它们构成了一条可审计链:
protocol.initialized / protocol.rejected → 协商是否成功
server.discover → 谁在什么时候发现了什么
tool.started / tool.finished / tool.rejected → 调用是否发生、是否失败
task.created / task.input / task.completed → 长任务是否完整走完
为什么要记 tool.rejected:它和 tool.started 的区别是「请求到了但没被允许执行」。在排查线上问题时,这两类事件的比值能区分「模型不知道有这个工具」和「模型试了但被拦」,两种情况的修复方向完全不同。
6.3 兼容测试最小矩阵
| 维度 | 用例 |
|---|---|
| 版本 | 相同版本通过;不兼容版本被拒且留痕 |
| 能力 | 声明的能力可调用;未声明的能力不被假设可用 |
| 权限 | 无 scope 的工具不出现在列表;直接猜名字调用被拒 |
| 输入 | 缺必填字段返回 is_error 而非抛异常 |
| 错误 | 未知工具报明确错误 |
| 任务 | 创建幂等;重启后可读回;终态不可改写 |
| 传输 | 关闭后收发都报错;取消能表达 |
课程项目的 tests/test_protocol.py 覆盖了其中的多数条目,实测 12 项通过。
本章检查点
- 为什么「兼容」不能简化为「版本号相同」?
tool.rejected与tool.started的比值异常,分别可能指向什么问题?
七、边界:MCP 明确不负责什么
写到这里,回到第一章的误解。MCP 的边界可以这样划:
模型看到什么 ───► Context Engineering 预算、选择、压缩、恢复
跨运行记住什么 ──► Persistent Memory 作用域、写入门禁、检索
外部能力怎么连 ──► MCP 发现、协商、调用、任务
两个 Agent 互调 ► A2A 跨实现、跨服务器的任务交换
界面怎么同步 ───► AG-UI 事件流、状态同步、人机协同
MCP 不负责:
- 不决定模型怎样管理上下文——这是上下文工程的职责;
- 不等于记忆系统——那是持久记忆的职责;
- 不等于 RAG——检索是数据平面的事,MCP 只是可能承载它的通道;
- 不等于 Agent 间协作——两个 Agent 互相发现并交换任务属于 A2A 的范畴。
规范自己有一个很好的设计说明:MCP 的服务器应当易于构建、高度可组合,并且不应该能读到整段对话,也不能看穿其他服务器。这个「不做全知者」的定位,正是它能成为通用连接层的原因。
本章检查点
- 从上面五层里,指出哪两层最容易在设计中被混为一谈,并说明后果。
- 为什么「服务器易构建、可组合、互不可见」这三条是同一个设计取向?
八、生产落地检查清单
把协议理解的成果转化为可验收动作:
- 明确本系统用哪种传输,并说明为什么不选另一种;
- 能力在协商阶段显式声明,未支持子特性显式标
false; - HTTP 传输实现 Protected Resource Metadata 发现与 audience 校验;
- stdio 传输从环境读取凭据,不套用 OAuth;
- 工具列表按主体 scope 过滤,做到「不可见」而非「可见但拒绝」;
- 输入校验失败返回错误结果,保留模型自我修正的机会;
- 长任务有幂等键、显式状态机、持久化与终态保护;
- 记录协商、发现、调用、任务四类事件,并区分拒绝与失败;
- 兼容测试覆盖版本、能力、权限、输入、错误、任务、传输七个维度;
- 明确标出教学替身与生产替换点,不把 HMAC 说成 OAuth,不把内存存储说成持久化。
九、来源
| 内容 | 来源 | 检索日期 |
|---|---|---|
| MCP 定义与 USB-C 类比 | https://modelcontextprotocol.io/docs/getting-started/intro | 2026-09-29 |
| 架构、Host/Client/Server 职责、能力协商 | https://modelcontextprotocol.io/specification/2025-06-18/architecture | 2026-09-29 |
| 最新规范概览、无状态请求、三类原语、扩展 | https://modelcontextprotocol.io/specification/2026-07-28 | 2026-09-29 |
| 传输绑定、消息方向、请求元数据、取消 | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports | 2026-09-29 |
| 授权模型、OAuth 2.1、RFC 8707、audience 校验 | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization | 2026-09-29 |
版本说明:本文件基于 2026-07-28 规范。MCP 处于活跃演进期,Tasks、Apps、Skills over MCP 等扩展可能独立于核心规范发布新版本;涉及具体 MUST/SHOULD 的结论应以规范原文为准。