一次握手决定整场会话的功能边界
远程 MCP Server 上线后,工具列表能正常拉取,日志也没有报错。可只要某个工具内部尝试调用客户端的采样能力,请求就立刻返回一个错误;另一些工具则干脆跳过采样,改用固定模板生成结果。服务端代码没有分支判断错误,客户端也没有崩溃,问题出在启动时那一次 initialize 交换里:客户端没有在 capabilities 中声明 sampling,服务端却按“客户端应该支持”的假设写了调用逻辑。
这类故障的麻烦之处在于它不报致命错误。协议层面连接是健康的,工具调用也能返回,只是部分功能被悄悄降级。要定位它,必须回到会话建立的第一条消息,看清双方各自声明了什么,以及协议要求双方如何使用这些声明。
本文以一个远程知识库 MCP Server 为主线场景:它暴露检索工具,并在生成摘要时希望回调客户端的采样能力,同时需要客户端提供根目录来限定可访问的文件范围。我们会沿着这条链路解释能力协商的机制、失效路径和观测手段。
初始化阶段到底交换了什么
MCP 基于 JSON-RPC 2.0 消息通信,连接是有状态的。规范把连接生命周期划分为初始化、操作和关闭三个阶段,其中初始化必须是客户端与服务器的第一次交互。
客户端发起初始化时,initialize 请求的 params 里必须包含三项内容:支持的协议版本、客户端能力、客户端实现信息。服务器收到后,用同样的结构返回自己的协议版本、服务器能力和实现信息。客户端确认无误后,再发送 notifications/initialized 通知,表示可以进入正常操作。
规范给出的客户端请求结构大致如下(字段含义见后文):
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {
"roots": { "listChanged": true },
"sampling": {},
"elicitation": {}
},
"clientInfo": { "name": "ExampleClient", "version": "1.0.0" }
}
}
服务器响应里同样带 capabilities,常见条目包括 logging、prompts、resources、tools,并可携带 listChanged、subscribe 这类子能力。
这里有一个容易被忽略的语义:capabilities 描述的是本次会话中允许使用的可选功能,而不是“实现大概支持哪些功能”。规范在操作阶段明确要求,双方只能使用成功协商的能力。也就是说,客户端没有在 initialize 里写 sampling,本次会话就不存在采样这条通道,哪怕客户端代码里其实实现了采样处理函数。
三类客户端能力各自解决什么问题
规范把客户端能力分为 roots、sampling、elicitation 和 experimental。在远程知识库场景里,前两类最常触发静默失效。
roots 让服务器知道客户端允许它在哪些文件系统边界或 URI 范围内操作。服务器通过 roots/list 向客户端发起请求,客户端返回一组根目录。它解决的问题是:服务器不该自行猜测能读哪些路径,而应由客户端划定范围。子能力 listChanged 表示根目录列表变化时客户端会发通知,服务器可以据此刷新缓存。
sampling 允许服务器反向请求客户端执行一次 LLM 采样。注意方向:通常是大模型应用(Host)通过客户端调用服务器的工具,而采样是服务器主动向客户端发 sampling/createMessage,由客户端决定是否真正调用模型、用什么提示词、返回什么结果。规范特意限制服务器对提示词的可见性,用户需要明确批准采样请求。
elicitation 让服务器向用户索取补充信息,例如工具执行到一半发现缺少参数时弹窗询问。
三类能力的共同点是:它们都由服务器发起、客户端响应。因此客户端漏报任何一项,受影响的都是服务器侧那些依赖反向调用的功能。
漏报 sampling 后调用链如何断掉
回到知识库场景。检索工具返回若干文档片段后,服务器想把片段拼成摘要,于是构造 sampling/createMessage 请求发给客户端。
如果客户端在 initialize 中声明了 sampling,这条请求会进入客户端的采样处理逻辑,由客户端决定是否调用模型,并把结果回传。服务器拿到摘要后继续组装工具输出。
如果客户端没有声明 sampling,规范要求双方只使用成功协商的能力。服务器此时发出采样请求,属于使用了未协商的能力。不同实现的处理方式不完全一致,但常见结果有两类:客户端直接以方法不存在或能力不支持的错误响应;或者服务器在发送前检查协商结果,发现 sampling 缺失,于是走降级分支。
降级分支正是“静默失效”的来源。服务器可能改用本地模板拼接摘要,或者直接返回原始片段,工具调用依然成功,只是输出质量和预期不同。调用方看到的是 200 级别的正常响应,不会意识到采样从未发生。
flowchart TD
A[客户端发送 initialize] --> B{capabilities 是否含 sampling}
B -- 含 --> C[服务端记录 sampling 可用]
B -- 不含 --> D[服务端标记 sampling 不可用]
C --> E[工具触发采样需求]
D --> E
E --> F{会话是否允许 sampling}
F -- 允许 --> G[发送 sampling/createMessage]
G --> H[客户端调用模型并回传]
H --> I[服务端组装摘要结果]
F -- 不允许 --> J[走降级分支]
J --> K[模板拼接或返回原始片段]
K --> I
I --> L[工具返回成功响应]
图中关键转折在 F 节点:会话是否允许采样,完全由初始化时的声明决定,与客户端代码里是否存在采样实现无关。
roots 缺失时服务器如何失去路径边界
roots 的问题比 sampling 更隐蔽,因为它影响的是服务器对可访问范围的理解。
服务器需要读取项目文件时,会向客户端发 roots/list。客户端声明了 roots,就会返回一组根目录,服务器据此把文件访问限制在这些路径内。如果客户端没有声明 roots,服务器拿不到任何根目录信息。
此时服务器有两种典型反应。一种是拒绝执行需要路径边界的操作,返回错误,用户看到工具失败。另一种是退回到默认行为,例如使用服务器进程自己的工作目录,或者干脆放开路径限制。后一种情况风险更高:服务器可能访问到客户端并未授权的目录,而调用方因为工具返回成功而没有察觉。
listChanged 子能力的缺失同样会造成问题。假设客户端声明了 roots 但没有声明 listChanged,服务器就无法感知根目录变化。用户在客户端里新增了一个工作目录,服务器仍按旧列表判断路径合法性,新目录下的文件会被判为越界。
静态假设与运行时探测的取舍
服务端开发者面对能力协商,通常有两种写法。
静态假设是在代码里假定客户端支持某些能力,直接调用,不检查协商结果。它实现简单,但一旦客户端漏报,就会触发前面描述的静默降级或错误。
运行时探测是在初始化完成后,根据服务器记录的协商结果决定是否调用。规范本身支持这种做法:服务器在 initialize 响应中已经声明了自己的能力,客户端的能力也在请求里给出,双方都有完整的协商结果可用于分支判断。
| 维度 | 静态假设 | 运行时探测 |
|---|---|---|
| 实现复杂度 | 低,无需保存协商状态 | 中,需要维护会话级能力表 |
| 漏报 sampling 时行为 | 调用被拒或静默降级 | 主动走降级分支,可记录原因 |
| 漏报 roots 时行为 | 可能使用默认路径或放开限制 | 可拒绝执行或提示用户补全 |
| 可观测性 | 差,故障隐藏在成功响应里 | 好,可上报能力缺失指标 |
| 兼容性 | 依赖客户端“应该支持”的假设 | 适配不同客户端的能力差异 |
| 适用场景 | 自研客户端与服务端配套 | 面向多客户端的公共服务端 |
选择的关键在于客户端是否可控。如果服务器只服务自家客户端,且客户端版本与服务端同步发布,静态假设的维护成本可以接受。如果服务器要接入多个第三方客户端,能力组合不可预测,运行时探测几乎是唯一稳妥的选择。
需要观测哪些信号
能力协商的故障很难从工具成功率上看出来,因为降级后的调用往往仍然返回成功。可观测性需要专门针对协商结果设计。
会话建立时,记录客户端 capabilities 的原始内容,至少包括 sampling、roots、elicitation 是否存在,以及 roots.listChanged 的取值。这条记录应与会话 ID 绑定,便于后续排查。
工具执行时,如果走了降级分支,应产生一条明确的日志或指标,标注降级原因,例如“sampling 未协商”。这条信号比工具失败率更能反映真实问题。
对于 roots,可以统计服务器实际使用的路径是否来自客户端返回的根目录。如果服务器在未协商 roots 的情况下仍执行了文件访问,应视为异常并告警。
采样请求被拒时,客户端返回的错误码和消息值得单独采集。规范给出的初始化错误示例使用 -32602 表示参数问题,采样请求被拒的具体错误形态由实现决定,采集原始错误有助于区分“能力未协商”和“用户拒绝授权”这两种不同原因。
兼容性边界与尚未解决的问题
能力协商的边界由协议版本和实现选择共同决定。规范要求客户端在 initialize 中发送它支持的协议版本,服务器若支持则回同一版本,否则回自己支持的另一个版本。客户端若不支持服务器返回的版本,应当断开连接。这意味着能力集合本身也随版本变化,跨版本对接时不能假定字段完全一致。
另一个边界是子能力的默认值。listChanged、subscribe 这类子能力是否出现,会影响通知行为。服务器不能因为主能力存在就假定子能力存在。
目前仍有一些问题没有统一答案。服务器在发现能力缺失后,是应该直接报错还是静默降级,规范没有强制规定,不同实现的做法可能不同。降级后的输出质量损失如何量化,也缺少通用方法。对于面向多客户端的公共服务端,如何在不牺牲兼容性的前提下尽早暴露能力缺失,仍需要各实现自行设计策略。
可以确定的是:把能力协商结果当作会话的一等状态保存下来,并在每次反向调用前检查,是避免静默失效的最低成本做法。这一步不做,故障就会一直藏在成功的响应里。