AI 技术
#MCP#能力协商#JSON-RPC#采样#根目录

MCP 初始化能力协商:客户端未声明的能力为何让服务端功能静默失效

以远程 MCP Server 启动握手为场景,说明 initialize 请求中 capabilities 如何声明双方支持的采样、通知与根目录能力,分析客户端漏报 sampling 或 roots 后服务端调用被拒或降级的机制,并给出可观测指标与兼容性边界。

一次握手决定整场会话的功能边界

远程 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 这类子能力是否出现,会影响通知行为。服务器不能因为主能力存在就假定子能力存在。

目前仍有一些问题没有统一答案。服务器在发现能力缺失后,是应该直接报错还是静默降级,规范没有强制规定,不同实现的做法可能不同。降级后的输出质量损失如何量化,也缺少通用方法。对于面向多客户端的公共服务端,如何在不牺牲兼容性的前提下尽早暴露能力缺失,仍需要各实现自行设计策略。

可以确定的是:把能力协商结果当作会话的一等状态保存下来,并在每次反向调用前检查,是避免静默失效的最低成本做法。这一步不做,故障就会一直藏在成功的响应里。

资料来源

  1. Model Context Protocol Specification
  2. MCP Lifecycle Specification
  3. 生命周期 | MCP 中文文档