Chapter 03
双向会话:server 反过来驱动 client
02 章把消息放上了线路,并提到 JSON-RPC 是对称的——双方都能发请求。01 §1.6 钉下的那条反向箭头,这一章展开:server 不只被动应答,它会反过来向 client 发起请求,借用 host 的模型、问用户要输入、读 client 允许的目录。这一步,才把 MCP 从"无状态工具 API"分出来。
本章你将建立的 schema
- 控制流为什么能反向:02 的会话是持久且对称的,于是 server 能回头给 client 发请求
- 三类 server→client 请求:sampling(借模型)、roots(问边界)、elicitation(要用户输入)
- sampling 让 server 不带 LLM SDK、不带 API key——它借用 host 的模型
- 反向能力是 MCP 的招牌,却部署得最少:无状态传输支撑不了 server→client
延续 01–02 的 GitHub server 场景:助手已经能读 issue、查 PR。这一章再加一个 文档总结 server——它收到一篇超长文档,却不自带模型,而是反过来请 client 用用户的模型来总结。这个"server 借模型"的动作,就是本章的主角。
3.1控制流为什么能反向
02 的 JSON-RPC 会话是持久且对称的,因此 server 可以回头向 client 发请求——这正是 MCP 区别于无状态工具 API 的根本。
普通 function calling 是无状态的一来一回:client 把工具调用发给 server,server 算完返回结果,到此结束,server 没有任何渠道再主动联系 client。MCP 不同——01–02 建立的是一条持久会话:握手协商完能力后,连接一直开着。连接既然一直开着、JSON-RPC 又是对称的,server 就能在处理一个请求的过程中,反过来给 client 发新请求。这一步把"调用"升级成了"会话"。
底层机制(比文档深一层):对称性在 02 就埋好了——JSON-RPC 不区分谁是 client、谁是 server,任何一端都能发 request、收 response。无状态工具 API 之所以做不到反向,不是缺一个 API,而是缺那条一直开着的连接:HTTP 请求-响应打完即走,server 手里没有指向 client 的回路。MCP 的会话保留了这条回路,于是 server→client 的请求有处可发。具体有三类,全部由 server 发起、client 应答:sampling(请 client 跑一次 LLM)、roots(问 client 允许操作哪些目录)、elicitation(请 client 向用户要一段结构化输入)。三者方向一致,都是平时被当成"被动方"的 server 反客为主。
| 维度 | 普通 function calling | MCP 会话 |
|---|---|---|
| 连接生命周期 | 一次请求-响应,打完即走 | 握手后持久保持 |
| 方向 | 单向:client→server→返回 | 双向:两端都能发起 request |
| server 能否回头联系 client | 不能(没有回路) | 能:sampling / roots / elicitation |
| 状态 | 无状态 | 有状态(协商结果 + 在途请求关联) |
3.2sampling:server 借用 client 的模型
sampling/createMessage 让 server 请 client 的 LLM 补全一段 prompt——server 因此不必自带模型,借用 host 的就行。
很多 server 的活儿本质上需要一次 LLM 推理:总结一篇长文、把自然语言转成查询、给一段日志归类。如果每个 server 都自带模型,就要每个 server 各配一套 LLM SDK、各持一份 API key、各自计费——又回到了 01 痛批的"每一方重复造轮子"。sampling 反转了这件事:模型访问权和 API key 都归 client(host)所有,server 缺推理时就发一个 sampling/createMessage,让 client 用用户自己的模型跑。
底层机制(比文档深一层):这是 MCP 最关键、也最反直觉的一招——server 保持模型无关(model-independent):它不需要任何 LLM SDK,也不需要自己的 API key。文档总结 server 收到 50 页文档,自己不调任何模型,而是把"请总结这段内容"打包成 sampling/createMessage 发给 client;client 用用户配置的模型(Claude、GPT、Gemini 都行)跑完,把补全结果回给 server。server 从头到尾没碰过模型权重,却完成了一次推理。这条反转还带来两个硬约束:
- human-in-the-loop:server 借的是用户的模型、用户的额度,所以 client 应当用一道用户确认来把关每次 sampling——让人看到"这个 server 想用你的模型跑这段 prompt",可以拦下。控制权仍在 host 手里。
- modelPreferences 只能给倾向,点不到具体模型:server 不能直接点名某个具体模型(如 claude-3-opus)。它只能发抽象的
costPriority/speedPriority/intelligencePriority(0..1 的权重)外加建议性的hints;client 可以无视或重映射——server 提示claude-3-sonnet,client 完全可以改用 Gemini。模型选择的最终决定权,留在持有模型的那一端。
2025-11-25 修订版还给 sampling 加上了带工具调用的能力:server 发起的这次 sampling 里,模型可以再触发工具,从而在 server 侧跑一个 agentic 循环。反向请求不再只是"补全一段文本",而能驱动一整段自主推理。
{
"jsonrpc": "2.0",
"id": 7,
"method": "sampling/createMessage",
"params": {
"messages": [
{
"role": "user",
"content": {
"type": "text",
"text": "用三句话总结这篇文档:\n\n<50 页正文>"
}
}
],
"modelPreferences": {
"hints": [{ "name": "claude-3-sonnet" }],
"costPriority": 0.3,
"speedPriority": 0.2,
"intelligencePriority": 0.8
},
"maxTokens": 400
}
}
注意 method 是 server 发出的——在 02 的 GitHub 场景里,method 总是 client 发的 tools/call;这里方向反了过来。hints 里的 claude-3-sonnet 只是建议,client 可以换成任何模型;三个 priority 给的是"省钱 / 求快 / 要聪明"的相对权重,而非硬指标。
文档总结 server 想保证"一定用 GPT-4 来跑总结",于是在 modelPreferences.hints 里写死 gpt-4。这能保证吗?(先停十秒)
展开答案(先自己答)
不能。hints 是建议性的,modelPreferences 整体只表达倾向,最终选哪个模型由 client 决定。client 完全可以把 gpt-4 重映射成它本地配置的 Claude 或 Gemini,甚至忽略 hints 只按三个 priority 权衡。根本原因是 sampling 的设计前提——模型访问权归 client,server 借用而非指挥。server 唯一能影响的是"我希望更聪明 / 更快 / 更省",点不到具体型号。这正是 server 保持 model-independent 的代价与前提。
3.3roots:client 划出可操作的边界
client 通过 roots 告诉 server"你被允许在这些目录/URI 范围内操作";server 用 roots/list 来问,URI 必须是 file://。
一个文件系统 server 启动时并不知道"自己能访问哪些目录"。靠它自己猜既危险又不可靠。roots 让 client 显式声明边界:把用户授权的目录列出来,server 据此知道作用域,不必越界探测。
底层机制(比文档深一层):server 发 roots/list(又一个 server→client 请求),client 回一组 root URI。规范限定这些 URI 必须是 file://——roots 表达的是文件系统边界,不是任意网络地址。它和 sampling 共享同一条反向回路,只是用途从"借模型"变成"问作用域"。当用户授权的目录变化时,client 还能用 notifications/roots/list_changed 通知 server 重新拉取。本节到此为止,重点是认得它是第二类 server→client 请求。
3.4elicitation:操作中途向用户要输入
elicitation/create 让 server 在操作进行到一半时,经由 client 向用户索取一段结构化输入——用 requestedSchema 描述要什么。
有些信息 server 在启动时拿不到,只有跑到一半才知道缺。比如 GitHub server 要建 issue,却发现没指定目标仓库;与其失败退出,不如暂停操作、回头问用户"建到哪个仓库"。elicitation 就是这条"中途问一句"的通道。
底层机制(比文档深一层):server 发 elicitation/create,附一个 requestedSchema 描述需要哪些字段;client 据此渲染一张表单给用户填,再把结果回送。用户的回复是三选一动作:accept(填了并提交)、decline(明确拒答)、cancel(直接关掉不决定)——server 必须分别处理这三种,而不是只看"有没有数据"。两条硬约束值得钉死:
- 禁止借表单要密钥:server 不得用表单式 elicitation 索取密码、token 等机密。这类敏感凭据走另一条路(见下)。
- schema 刻意保持扁平:
requestedSchema只允许原始类型字段(字符串、数字、布尔、枚举),不允许嵌套对象或数组。这不是协议没做完,而是有意的约束——扁平 schema 让 client 渲染表单这件事保持极简,任何 client 都能稳定地把它画成几个输入框,不必处理任意嵌套结构。
2025-11-25 修订版补了一条 URL-mode elicitation:当真要走 OAuth / 凭据这类敏感流程时,server 让 client 把用户引导到一个带外(out-of-band)URL 完成,并配了专门的错误码来表达这条路径——既满足"要凭据"的现实需求,又守住了"表单不碰机密"的红线。
elicitation/create,client 渲染表单,用户以 accept/decline/cancel 三选一回复,结果回送后操作恢复。注意:schema 扁平是有意设计——它把 client 的表单渲染压到最简;要密钥不能走这条表单,得走带外 URL。{
"jsonrpc": "2.0",
"id": 12,
"method": "elicitation/create",
"params": {
"message": "要把 issue 建到哪个仓库?",
"requestedSchema": {
"type": "object",
"properties": {
"repo": { "type": "string", "title": "仓库 owner/name" },
"assignToMe": { "type": "boolean", "title": "指派给我" }
},
"required": ["repo"]
}
}
}
requestedSchema 里只有 string 和 boolean 两个扁平字段,没有嵌套对象——这正是约束所在。client 收到后画两个输入框,用户填完以 accept 回送 { "repo": "acme/web", "assignToMe": true },GitHub server 的建 issue 操作随即从暂停处恢复。
3.5有状态会话:嵌套才是"会话"的实质
会话保存着三样东西:协商出的 protocolVersion 与 capabilities、按 id 关联的在途请求、以及一次 sampling/elicitation 嵌套在某个工具调用内部的上下文。
底层机制(比文档深一层):把前几节的反向请求叠到 02 的握手上,会话里实际驻留三层状态:
- 协商结果:02 握手敲定的
protocolVersion和双方 capabilities——后续每个请求都默认在这套约定下进行。 - 在途请求关联:JSON-RPC 用
id把 request 和 response 对上号。会话里会同时有多个请求在飞,靠 id 各认各的回包。 - 嵌套上下文(关键):一次 sampling 或 elicitation 往往发生在某个
tools/call还没返回的中途——GitHub server 处理create_issue到一半,回头发elicitation/create问用户仓库,拿到答复再继续把 issue 建完。外层工具调用挂起、内层反向请求嵌在里面,这种"调用套调用"的嵌套,才是把它叫会话而非无状态调用的实质。
"MCP 永远是有状态会话"是默认,不是绝对。架构刻意留了一条无状态逃生通道:MCP 的一个子集可以在 Streamable HTTP 上以无状态方式运行,以便水平扩展——多个无状态实例分摊请求,不必各自维护长连接。代价是:这种部署放弃了上面那条"一直开着的回路"。所以准确的说法是"会话是默认形态",而不是"一定有会话"。这条后门正是下一节那个意外的根源。
GitHub server 处理一个 create_issue 工具调用,中途发现缺仓库名,于是发 elicitation/create 问用户。此刻那个 create_issue 请求处于什么状态?为什么这能说明 MCP 是"会话"而非"无状态调用"?(先停十秒)
展开答案(先自己答)
那个 create_issue 处于挂起(in-flight,未返回)状态——它的 response 还没发出,server 正卡在处理过程里,等内层 elicitation/create 的答复。这恰恰说明会话的实质是嵌套:一个外层调用没结束,内层反向请求嵌在它内部,会话必须同时记住外层 id、内层 id、以及两者的从属关系。无状态调用做不到这点——它一来一回就结束,没有"在某个调用之中再发起调用"的余地。嵌套上下文 = 会话的实质。
3.6招牌能力,部署最少
sampling 是 MCP 概念上的心脏(server 借模型),却是采用率最低的能力——这个反差本身就是值得直说的洞察。
底层机制(比文档深一层):原因直接接上 3.5 的无状态后门。server→client 请求要成立,前提是那条一直开着的回路;而无状态传输(不带 session 的纯 Streamable HTTP)根本支撑不了 server→client 请求——没有持久连接,server 无处发起反向调用。现实里大量 server 为了好部署、易扩展,选了无状态形态,于是多数已部署的 server 干脆完全跳过 sampling。结果就是这个反差:server 借用 host 模型这件事,是 MCP 区别于普通工具 API 的整个立意所在,却在真实部署里几乎没人用。
立意与采用的背离,根子是一对取舍:反向能力需要有状态的持久会话,而易部署偏爱无状态的水平扩展,二者在传输层直接打架。sampling 站在"有状态"那一边,于是每当工程团队为了上线方便选了无状态 Streamable HTTP,就顺手把 sampling 一起放弃了。这不是 sampling 设计得不好,而是它的前提(持久回路)恰好是规模化部署最想砍掉的东西。记住这条张力:MCP 最有想象力的反转,被它自己对部署友好的让步架空了——这也是 04 章谈信任模型、05 章谈前沿取舍时反复会撞见的主题。
§本章 self-check
先合上教程,把答案写在纸上或编辑器里。写完再点开对照——直接点开等于把这一节当再读一遍。
- 为什么 server 能反过来给 client 发请求,而普通 function calling 的 server 不能?根因是缺了什么?
- sampling 让 server 不需要自带什么两样东西?这对"server 该是什么"意味着什么?
- elicitation 的
requestedSchema为什么不允许嵌套对象?这是 bug 还是有意设计?另外,三种回复动作各是什么? - sampling 是 MCP 的招牌反转,为什么真实部署里采用率最低?跟无状态传输有什么关系?
答案(先做完再展开)
- 因为 MCP 的会话是持久且对称的——握手后连接一直开着,JSON-RPC 不区分发起方,server 手里有一条指向 client 的回路。普通 function calling 是无状态请求-响应,打完即走,server 根本没有这条回路,缺的不是某个 API 而是持久连接。
- 不需要自带 LLM SDK,也不需要自己的 API key。模型访问权归 client,server 缺推理时发
sampling/createMessage借用 host 的模型。这意味着 server 应当保持 model-independent,只管业务逻辑,把模型这件事留给 host。 - 扁平是有意设计,不是 bug:只允许原始类型字段(无嵌套对象/数组),是为了让任何 client 都能把它简单地渲染成几个输入框。三种回复动作:accept(填并提交)、decline(明确拒答)、cancel(关掉不决定),server 须分别处理。
- 因为 server→client 请求依赖那条持久回路,而无状态传输(不带 session 的 Streamable HTTP)支撑不了 server→client 请求。大量 server 为了易部署/易扩展选了无状态形态,就顺手跳过了 sampling。于是这个"借模型"的核心立意,在真实部署里几乎没被用上。
给文档总结 server 设计一次"借模型并补问参数"的流程
需求:文档总结 server 收到一篇长文档,要总结它,但用户没指定"总结成几句话"和"用中文还是英文"。请把这次操作用本章三类反向请求中的两类串起来——哪一步用 elicitation、哪一步用 sampling?外层是哪种调用,它在中途处于什么状态?再说一句:如果这个 server 被部署成无状态 Streamable HTTP,这套流程还跑得通吗?
提示(卡住再展开)
外层是一次模型自发的 tools/call(比如 summarize_doc),它在整个流程里处于挂起状态。先用 elicitation/create 问用户"几句话 + 语言"——requestedSchema 用一个数字字段和一个枚举字段,保持扁平;拿到 accept 后,再用 sampling/createMessage 把文档正文 + 用户给的偏好打包,借 client 的模型跑总结,结果回送、外层工具调用恢复并返回。两类反向请求嵌在同一个外层调用内部——这正是 3.5 的嵌套上下文。最后一问:跑不通——无状态 Streamable HTTP 没有持久回路,elicitation 和 sampling 这两个 server→client 请求都发不出去,整套流程在那种部署下直接失效,正是 3.6 那个反差的具体后果。