第 7 章 协议与集成¶
工具从哪来,决定了 harness 能接多大的世界。硬编码的工具表只能覆盖一个进程内的能力;要把别人写好的服务接进来,就需要一层协议。本章以 Model Context Protocol(MCP)为主线讲清协议解决什么问题、角色与生命周期怎样划分、原语与传输怎样配合,并给出自建工具服务与版本兼容的判据。规范细节以 2026-07-28 版为准,接口字段随版本演进时以规范页为最终依据。
1. 为什么需要协议¶
没有协议时,每接一个工具源都要写一套适配:函数签名、传输、鉴权、错误格式各不一样。协议把可变部分收进三样东西——消息格式、能力声明、生命周期,让 harness 只需要实现一次客户端,就能接任意服务端。
协议不解决“工具干什么”的问题:同一个协议下,一个工具可以是只读查询,也可以是删库操作。它解决的是“怎么找到、怎么调用、怎么回结果”,语义与风险仍在工具自身的契约里(第 4 章)与权限层(第 5 章)。把协议当成安全边界,等于假设“接入了外部服务就默认安全”,这两件事没有关系。
MCP 的消息格式建立在 JSON-RPC 之上,调用与通知都有统一外壳;能力以"声明加协商"的方式交换,双方只使用彼此都支持的部分。判据:协议的价值在边界,而不在功能。它不替工具设计 schema(第 4 章),也不替你决定权限(第 5 章),只负责把"谁有什么能力、怎样调用、怎样回结果"标准化。
2. 角色与生命周期¶
三个角色:宿主(host)是运行智能体的应用,持有模型与权限;客户端(client)由宿主创建,与一个服务端保持一对一连接;服务端(server)暴露工具、资源与提示。一个宿主可以连多个服务端,每个连接独立协商能力。
生命周期分三段:初始化握手、正常操作(请求与通知)、关闭。初始化握手本身就是一组往返——客户端发 initialize 请求带上协议版本与能力声明,服务端在响应里给出自己的版本与能力,客户端再发 notifications/initialized 通知,握手才算完成;此后才允许发操作请求。版本协商决定后续可用字段;版本不一致时,正确做法是明确失败或降级到共同支持的版本,而不是假设字段兼容。
请求都要带 id 并支持并发:同一连接上多个请求可以同时在途,响应乱序返回靠 id 配对;长时间运行的操作要有取消通道,取消是通知而不是请求,发出后不等回应。连接保活与超时由传输层管,协议层只约定“超时后可以安全重试”的条件——幂等操作才可重试。
判据:能力以握手结果为准,不以配置为准。配置里写了"支持某能力"而服务端未声明,调用仍会失败;反过来,服务端声明了某能力但客户端未声明,服务端也不应请求它。
3. 服务端原语¶
服务端向外暴露三类原语:
- 工具(tools):可调用、可能带副作用,由模型选择调用。
- 资源(resources):可读取的内容,按标识寻址,通常只读。
- 提示(prompts):预置的提示模板,由用户或宿主显式选用。
三者的权限语义不同:工具要走权限门,资源读取要受路径与范围约束,提示模板属于内容而非动作。把资源当工具用(每次读取都走一次模型选择),或把工具当资源用(只读却暴露成可调用),都会让权限与预算失真。
资源用 URI 寻址,可带模板参数(如 repo://{owner}/{name}/README),参数同样要校验——模板参数是注入面的另一个入口。工具可以带注解声明“只读”“会破坏环境”,但注解是给模型选工具的提示,不是权限依据;真正决定能不能执行的仍是第 5 章的判定。
4. 客户端特性¶
协议也允许服务端向客户端请求能力,例如让客户端代为调用模型(采样)或向用户请求补充信息(引出)。这类特性由客户端在握手时声明,服务端按声明使用。早期的一些客户端特性已被弃用,采用前应查当前版本规范页,不沿用旧示例。
采样请求要带模型偏好与上限:服务端可以表达“希望的能力”,但不能指定具体付费账户,也不能绕过宿主的预算——每次采样都是一次真实计费调用。引出请求是格式化问答,不是自由聊天,问题与选项要预先声明,否则用户会在协议弹窗里遇到无法回答的开放题。
判据:客户端特性扩大了服务端能触达的范围,必须与权限模型一起设计。服务端发起的模型调用同样计入预算,发起的用户询问同样要防疲劳;不能因为请求来自"协议内部"就绕过第 5 章的门。
5. transport 与会话¶
当前规范定义两种传输:标准输入输出(stdio)与可流式 HTTP(Streamable HTTP)。stdio 适合本地子进程,生命周期与进程绑定,天然隔离;HTTP 适合远程服务,需要处理会话标识、断线重连与多客户端并发。
宿主进程
├─ client A ── stdio ──> 本地工具服务进程
└─ client B ── HTTP ──> 远程工具服务(带会话标识)
传输选择影响三件事:身份与授权(本地进程用操作系统身份,远程用协议授权)、失败恢复(进程退出即会话结束,HTTP 可重连)、审计(本地可看进程树,远程要看会话与请求标识)。把这些差异写进部署判据,比在代码里用同一套重试逻辑包打天下可靠。
stdio 有一条硬约束:标准输出只能跑协议消息,日志一律写标准错误。把一行调试信息打到标准输出,客户端会把它当成半个 JSON-RPC 消息,表现为随机解析失败,而不是报错。HTTP 侧要带会话标识并在重连后恢复订阅,同时校验来源与内容类型,避免把浏览器发起的跨站请求当成合法客户端。
6. 工具结果的协议形态¶
工具结果以内容块数组返回,并带错误标志。内容块可以是文本、图像或结构化数据;错误标志区分"调用成功但业务失败"与"协议层失败"。这个区分很重要:业务失败应回灌给模型判断是否换路径,协议失败应触发重连或换服务端。
错误至少分三层:协议层(方法不存在、参数不合法、版本不符)、工具层(目标不存在、权限拒绝、超时)、业务层(执行完成但结果不满足预期)。三层对应三种处置:重连/升级、换路径、改策略。只给一个 isError 布尔值,模型会把“对象不存在”当成“服务坏了”。
判据:结果要能被模型消费,也要能被 harness 记账。只返回一段自然语言,模型能读但 harness 无法统计;只返回结构化字段,模型可能读不懂失败原因。常见做法是结构化字段加一段人类可读说明,两者同时回灌。
7. 自建工具服务¶
自建服务的最小形态是:声明能力、注册工具、实现调用分发、返回内容块、把异常映射为错误标志。工具实现应复用第 4 章的契约与第 5 章的权限判定,不因换了传输就跳过校验。
def dispatch(name, args):
tool = REGISTRY.get(name) # 未知工具 → 协议级错误
if tool is None:
return err(f"unknown tool: {name}")
try:
payload = tool.validate(args) # 复用同一套 schema 校验
result = tool.run(**payload)
return ok(result) # 内容块数组 + 成功标志
except ToolError as e:
return err(str(e), business=True) # 业务失败:可回灌、可换路径
服务端不应信任客户端传来的参数,也不应假设客户端已做权限检查;两侧都校验,边界才闭合。
服务进程自身要有超时与并发上限,否则一个卡住的工具会阻塞同连接上的其他请求;反过来说,同一个工具处理函数必须可重入,或显式串行化。关闭时先停止接收新请求,再等待在途请求结束或超时,最后退出——直接断连接会让客户端拿到“不确定是否执行”的状态(第 9 章)。
8. 授权与多租户¶
本地 stdio 服务的身份通常继承自宿主进程,权限边界由操作系统与沙箱决定;远程 HTTP 服务需要协议层授权,具体流程与令牌形态以当前规范为准。多租户场景还要区分"服务端能看到哪些资源"与"这个客户端被允许访问哪些资源",前者是部署隔离,后者是授权。
令牌按最小范围签发:限定资源前缀与动作,带短过期与受众声明;受众不符的令牌即便签名有效也不应被接受。多租户的隔离要在存储层再确认一次——只靠网关按租户路由,一旦直接访问后端就绕过了。
判据:授权失败要可区分(未登录、令牌过期、范围不足),并回传可执行的下一步;把三类失败统一成"未授权",客户端只能盲目重试。
9. 版本演进与兼容¶
协议版本用日期标记,字段会增删。兼容策略有三条:握手时只使用双方声明的版本;对未知字段忽略而非报错;对已弃用特性给迁移窗口。服务端与客户端都应记录实际协商到的版本,排障时先看版本再看行为。
能力协商要落成矩阵:客户端声明什么、服务端声明什么、交集是什么、降级后哪些功能不可用。降级不是静默:不可用的功能在调用时返回明确的“未协商”错误,而不是超时或空结果。
判据:不要用"最新示例"代替版本判断。示例页会随规范更新,旧部署不会。把协商版本写进 trace,是跨版本问题最省事的定位手段。
10. 要点¶
- 协议的价值在边界:统一消息、能力与生命周期,不替工具设计与权限决策。
- 角色是宿主、客户端、服务端;每个连接独立握手,能力以协商结果为准。
- 工具、资源、提示的权限语义不同,不能混用。
- 客户端特性扩大服务端触达范围,仍受预算与权限门约束。
- stdio 与 Streamable HTTP 在身份、恢复与审计上差异明显,按部署选。
- 结果要同时可被模型消费与被 harness 记账;业务失败与协议失败分开。
- 自建服务复用 schema 校验与权限判定,两侧都校验。
- 版本以握手协商为准,未知字段忽略,弃用特性给迁移窗口。