经由 MRTR 的采样与征询

10 分钟读完

C7
深入解析 · MCP

服务器依然要向宿主索取模型补全、用户的回答或文件系统 root——但从 2026-07-28 修订版起,它不能再回拨过去要了:它把问题作为结果返回,由客户端带着答案重发原来那次调用。

2026 年年中之前写就的每一篇采样与征询教程,描述的都是协议里已经不存在的机制。2026-07-28 修订版直接删掉了"服务器发起的请求"——规范的原话是 "This is a breaking change"——并以 MRTR(Multi Round-Trip Requests,多轮往返请求)取而代之:服务器用 resultType: "input_required" 连同它的问题一起作答,客户端再把同一个方法重发一次,把答案带上。服务器需要向宿主发问的那些理由原样存续,变的是机制——它被反了过来。

STEP 1

服务器仍然需要宿主给什么——以及什么不再管用了。

有三样东西服务器自己造不出来,仍然必须从连接的另一头来,而这三条理由一条也没变。一台想用 LLM、却不想自己变成 LLM 运维方的服务器,需要一次跑在宿主账号上的补全:返回资源前先做摘要、把用户提供的一段 blob 按工具理解的分类法归类、把一份 diff 改写成一句 commit message 再让调用方 agent 决定要不要采纳。这些只要放到服务器侧去解决,作者就得开一个模型提供方账号、要一枚 API key、承一份账单,碰上合规还要多一场对话。一台只差一条结构化事实的服务器——哪个仓库、三个含糊匹配里挑哪个、删除前确认——需要直接够到用户,而不是把问题绕经模型、让答案丢在一场传话游戏里。而一台要操作文件的服务器,需要知道用户究竟把哪些目录摆上了桌面。MCP 参与者模型解释了为什么这三者指向同一个方向:API key、用户的注意力、被授权的文件系统,都是宿主的资产,不是服务器的。

不再管用的是投递方式。2026-07-28 之前,服务器把 sampling/createMessage、elicitation/create 或 roots/list 作为自己的请求,沿着它正在服务的那条会话反向发出去。这要求一条在整个操作期间都保持打开的双向流,而这条流又倒逼出黏性路由与共享状态:一次逻辑调用的两趟往返必须落到同一个服务器实例上,且那个实例对这次调用的记忆还热着。现在规范说的正相反:"Servers MUST send server-to-client requests (such as roots/list, sampling/createMessage, or elicitation/create) using the MRTR pattern. The previous pattern of server-initiated requests is no longer supported. This is a breaking change."(服务器必须以 MRTR 模式发送服务器到客户端的请求;此前那种服务器发起请求的模式不再受支持;这是一处破坏性变更。)MRTR 之所以存在,就是因为"必须落到同一实例"这条约束必须去掉;它与无状态化一同落地,正是出于这个原因。2026-07-28 修订版那一篇是同期还动了什么的完整交代,Streamable HTTP 则讲了这次在偿还的那笔传输层欠账。

STEP 2

MRTR:服务器把问题返回去,客户端重试那次调用。

MRTR 只允许用在三种客户端请求上——prompts/get、resources/read 与 tools/call——对任何其他客户端请求,服务器 MUST NOT 回以 InputRequiredResult。服务器不返回正常结果,而是返回 resultType: "input_required"、一个键由它自己指派的 inputRequests 映射,以及一个不透明的 requestState 字符串。inputRequests 里的值 MUST 是 ElicitRequest、CreateMessageRequest、ListRootsRequest 三者之一——正是老回调曾经承载的那三种载荷,如今改为搭在一条响应里当货物运送,而不再是自成一体的请求。服务器 MUST 至少带上 inputRequests 与 requestState 中的一个;只有状态、没有问题,就是卸载负载(load shedding)的情形——服务器说"带着这个再来一趟,我接着办",压根不需要用户做任何事。另外,对客户端从未声明过的能力,服务器 MUST NOT 在 inputRequests 里放对应的条目,这让客户端的能力声明成了一道硬闸,而不只是一条提示。

// client -> server : an ordinary call
{ "jsonrpc": "2.0", "id": 1, "method": "tools/call",
  "params": { "name": "open_pr", "arguments": { "title": "Fix retry backoff" } } }

// server -> client : not an answer — a set of questions, plus its own continuation
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resultType": "input_required",
    "inputRequests": {
      "github_login": {
        "method": "elicitation/create",
        "params": {
          "mode": "form",
          "message": "Please provide your GitHub username",
          "requestedSchema": {
            "type": "object",
            "properties": { "name": { "type": "string" } },
            "required": ["name"]
          }
        }
      },
      "capital_of_france": {
        "method": "sampling/createMessage",
        "params": {
          "messages": [
            { "role": "user", "content": { "type": "text", "text": "What is the capital of France?" } }
          ],
          "maxTokens": 100
        }
      }
    },
    "requestState": "eyJsb2NhdGlvbiI6Ik5ldyBZb3JrIn0"
  }
}

客户端在本地把每一条都办掉——渲染表单、跑补全、列出自己的 roots——然后把原来那个方法重发一次,带上一个与 inputRequests 键名完全一致的 inputResponses 映射,以及它收到的那份状态。这次重试上有两条规则,实现常在这里翻车。第一,初始请求与重试之间,JSON-RPC 的 id MUST 不同:这是关于同一次操作的两个彼此独立的请求,不是一个请求被恢复了;而"感觉像是续上前一次"因而顺手复用 id: 1,是 MRTR 客户端代码里最常见的一个错误。第二,客户端 MUST 逐字节原样回传 requestState,并且 MUST NOT 检视、解析或修改它——若服务器根本没给,客户端 MUST NOT 自己编一个出来。过去寄居在一条常开连接里的那份关联关系,如今完全寄居在这两个映射和那一个不透明字符串里。

// client -> server : same method, NEW id, answers keyed to the server's own keys
{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/call",
  "params": {
    "name": "open_pr",
    "arguments": { "title": "Fix retry backoff" },
    "inputResponses": {
      "github_login": { "action": "accept", "content": { "name": "octocat" } },
      "capital_of_france": {
        "role": "assistant",
        "content": { "type": "text", "text": "The capital of France is Paris." },
        "model": "claude-3-sonnet-20240307",
        "stopReason": "endTurn"
      }
    },
    "requestState": "eyJsb2NhdGlvbiI6Ik5ldyBZb3JrIn0"
  }
}
STEP 3

征询的表单模式与 URL 模式:回调没了,两者还在。

征询是这三者中唯一没有被标记弃用的,所以它也是最值得押注的一个。表单模式的载荷本身没变:一份描述服务器想要哪些字段的 JSON Schema、一句人类可读的、解释为什么要问的信息,以及一个可以自由地为布尔渲染 checkbox、为枚举渲染下拉、为字符串渲染文本框的宿主。经受住实战检验的那两条设计规则,在这次修订后同样成立。把 schema 保持得扁平且小——不要嵌套对象,最多三四个字段——因为一个有十五个输入框的对话框,比多一趟往返更能把工作流顶死。给每一个可选字段定一个默认值,好让用户直接接受、往下走,不必再敲键盘。变的是:这份表单如今是作为 inputRequests 里的一个值送达的,而不是服务器发出去的一条请求,所以用户在做决定时,服务器并没有坐在一次没做完的调用上;它早已返回,只有当客户端重试时才会再听到下文。

随回调一同消失的,还有 2025-11-25 的两件配套装置。notifications/elicitation/complete 以及与它配套的 elicitationId 都没有了,因为已经不存在需要被关联的带外完成信号——重试本身就是那份关联。notifications/roots/list_changed 同样没有了。如果你的服务器或客户端还在发这两者之一,那它说的是一个已经不存在的修订版。

URL 模式征询,正是 OAuth 2.1 配置那一篇每次讲"该用什么替代令牌透传"时都会指向的那个变体;而 MRTR 让它的形状更诚实、而非更含糊。这次工具调用要作用在一个服务器并不持有其凭证的第三方服务上。服务器返回一条 url 模式的 ElicitRequest,连同自己的 requestState,然后就停下——没有常开连接、没有挂起的线程、没有需要保温的东西。客户端宿主在用户浏览器里打开这个 URL,用户走完流程,第三方令牌落在由客户端掌控的 callback 上。只有当客户端重试时服务器才会知道点什么,而它知道的是一个 accept 动作,外加它识别"哪个账号被链接了"所需的那个把手——绝不是第三方令牌。令牌透传之所以被禁止,是因为它的天真版本——服务器握着调用者的令牌、把它转发给下游——会把 RFC 8707 的 audience 保证全部打穿,还逼下游服务去信任错误的 principal。不容商量的那一条没变:第三方令牌绝不能碰到服务器。

t=0.00  client  | tools/call id=1                 | name=connect_github
t=0.01  server  | result resultType=input_required | inputRequests.link = elicitation/create mode=url
t=0.01  server  | requestState=AEAD(...)           | request COMPLETE — no connection held, no state parked
t=0.02  client  | opens url in user's browser      | user completes OAuth
t=8.31  client  | callback lands on client         | code -> exchange -> access_token STORED IN CLIENT
t=8.33  client  | tools/call id=2                  | inputResponses.link={action:accept}  requestState echoed byte-exact
t=8.34  server  | verify requestState, then parse  | resumes via linked account | server never sees gh access_token
STEP 4

requestState 是你自己的续行点,被交到了一个不受信的一方手里。

这是 MRTR 里在旧设计中找不到对应物的部分,也是最该做对的部分。当状态由连接持有时,状态待在服务器自己的内存里。如今服务器把它的续行点序列化、交给客户端,再请对方还回来——而规范对此说得直白:服务器 MUST 把 requestState 当作受攻击者控制的数据来对待。只要它以任何方式影响授权或业务逻辑,服务器 MUST 用 HMAC 或 AEAD 做完整性保护,并且 MUST 拒绝验证不通过的状态;它 SHOULD 在其中嵌入 principal、一个 TTL,以及一个标识原始请求的 id。一台把 {"tenant": "acme", "approved": true} 不加签名塞进 requestState 的服务器,造出来的不是恢复令牌,而是一个调用者可以随手改写的授权判定——而调用者,恰恰正是这条判定本来要约束的那一方。MCP 安全反模式那一篇盘点了服务器信错了消息的哪一端会发生什么;这是抵达同一处的最新一条路。

// what belongs inside requestState, before you seal it
{
  "sub":  "user_9x",                     // principal the ORIGINAL request ran as
  "orig": "tools/call:open_pr:01JB7Q",   // originating-request identifier
  "exp":  1793664000,                    // TTL — minutes, not days
  "step": "awaiting_github_login"        // the server's own continuation
}

// on the wire : AEAD(key, canonical_json(state))  ->  opaque base64
// on return   : VERIFY first, then parse. Verification failure is a hard reject,
//               not a warning. Then check exp, then check that sub still matches
//               the principal on the retry — a valid blob from another session
//               is still the wrong blob.

可操作的判别点很短。把状态绑定到重试时的 principal,而不只是绑定到当初创建它的那个 principal,否则你造出来的是一个可以跨会话工作的重放原语。把 TTL 按分钟来定,因为两趟往返之间唯一正当的间隔,就是一个人填完表单或走完 OAuth 流程所需的那点时间。还有,在客户端侧对每次逻辑操作的重试次数设上限:一台对每次重试都再回一个 input_required 的服务器,要么是坏了,要么是在牵着客户端绕圈,而客户端是唯一有位置把它叫停的参与者。

STEP 5

机制是强制的;它能承载的三种载荷里有两种在倒计时。

对于正在决定"该押注什么"的人,这一页上最有用的一句话是:今天你必须用 MRTR 来承载这些请求,而它能承载的三种请求里,有两种本身已被弃用。采样、Roots 与 Logging 在 2026-07-28 中都被标记为 Deprecated,征询没有。带工具的采样——那种服务器在嵌套补全里声明自家工具、从而借走宿主整个 agent 循环而不只是一次模型调用的模式——属于采样,带着同一个标记;includeContext: "thisServer" 与 "allServers" 也一样,它们早在 2025-11-25 就被弃用,如今随承载它们的特性一同走。把这当作一条规划约束,而不是一段倒计时:把用户输入这条路径押在征询上,并且把采样当作一个"没有它服务器也要能优雅降级"的便利项。

对这条弃用政策到底承诺了什么,要说精确,因为网上已经有人在传错了。一个被弃用的特性最早可被移除的时点是"2027-07-28 当天或之后发布的第一个修订版"。这个日期标记的是一个特性何时变得有资格被移除,而不是它何时被移除——真正的移除是 Core Maintainer 在准备发版时作出的决定,可能更晚,也可能那一轮根本不动它。到目前为止,还没有任何特性依这条政策被移除过。"采样将于 2027 年 7 月被移除"是一句目前没人能作出的断言,而建立在它上面的路线图,无论维护者往哪个方向走都会落空。

用户同意这条线在这次反转中存续下来,而且还略有改善。采样与征询依然让服务器在"发生的当下"驱动一段用户没主动发起的体验,因而同意依然是一个"每次交互都要问"而非"每次连接问一次"的问题——这正是 HITL 那一篇当作运维问题来讲的纪律。MRTR 新添的是批处理:因为服务器把所有问题装在一个 inputRequests 映射里一次返回,客户端可以为整组问题渲染一个同意面,而不必每来一次回调就打断用户一次。那个同意面仍然必须显示:哪台服务器在发问、为什么、对一条采样条目而言模型将看到什么,以及——对 URL 模式征询——那个即将在用户浏览器里打开的 URL 来自哪个来源。把三档授权分清楚:这次调用授一次、本会话授一段、永久授给这台服务器。一个批准过一次摘要的用户,并没有批准接下来的一百次。两个原语都跟"渲染它们的那个客户端"完全同水平;两者也都会像任何被客户端敷衍渲染的浏览器权限对话框那样一样地崩掉。