Appearance
深入 MCP:一次请求需要多轮交互时怎么办?
有些 MCP Request 并不能一次完成。
比如一个部署 Tool 收到tools/call → deploy_production以后,Server 已经计算好了部署计划,但真正执行之前还需要用户确认。
问题就出现了:
text
Server 已经进入一次 tools/call,现在却还需要 Client 帮它获取额外输入,这个请求应该怎么继续?当前 2026-07-28 MCP 使用 MRTR(Multi Round-Trip Requests,多轮往返请求)解决这个问题。
它没有让 Server 把原来的 Request 一直挂在那里,也没有继续采用旧版的 Server → Client 反向 Request,而是把一次复杂操作拆成多个彼此独立的 Client Request。
为什么旧版 Server → Client Request 会成为问题?
早期 MCP 允许双向 Request。
Client 可以向 Server 发送:
text
tools/callServer 在处理过程中,也可以反过来发送:
text
elicitation/create让 Client 向用户收集信息;
或者发送:
text
sampling/createMessage让 Client 帮它调用模型;
还可以发送:
text
roots/list获取 Client 提供的工作区信息。
因此一次 Tool 调用可能形成:
text
Client → Server
tools/call
↓
Server → Client
elicitation/create
↓
Client → Server
ElicitResult
↓
Server → Client
最终 tools/call Response这套模型问题在于,一次 Client 发起的 RPC,在执行途中又嵌套出了一次反方向 RPC。
这会明显增加协议和 Transport 的复杂度。
尤其对于 Streamable HTTP,Server 如果需要在原请求执行期间反向发送 Request,就必须继续维持当前 Response Stream,让 Client 回答以后再继续原来的处理。
如果中间等待的是用户确认,这个等待可能不是几十毫秒,而是几十秒,甚至几分钟。
这意味着:
HTTP Response 不能结束;
负载均衡器和网关的 Timeout 必须足够长;
Server 还要保留原 Handler 当前执行到了哪里;
Client 返回结果以后,又必须恢复刚才被暂停的处理过程。
而且这种 Server Request 究竟属于哪个用户动作,也必须能够明确关联。
MCP 在真正移除反向 Request 之前,其实已经经历过一次收紧。
SEP-2260提案明确要求 Sampling、Elicitation、Roots 这类 Server→Client Request 必须和某条原始 Client Request 关联,不能让 Server 在后台突然主动要求:
text
帮我调用一下模型。或者:
text
让用户填一个表单。原因除了 Transport 简化,还有一个很重要的安全问题:
text
Client 必须知道 Server 为什么突然需要这些信息。如果 elicitation/create 是由用户刚刚执行的deploy_production引起的,Client 至少知道:
text
这次确认属于哪个操作。如果 Server 可以完全脱离用户动作主动索要信息,Host 就很难判断这次输入究竟会被拿去做什么。
到了 2026-07-28,MCP 进一步把这套模型彻底改掉:
Server 不再向 Client 发起独立 JSON-RPC Request。
如果 Server 处理中途需要更多信息,它结束当前 Request,并把:
“我还缺什么”
作为这一次处理的结果返回。
然后由 Client 主动发起下一轮。
这样整个协议重新恢复成一个非常清晰的方向:
text
Request 永远由 Client 发起,Server 只负责返回 Result。InputRequiredResult 为什么被设计成一种正常 Result?
Server 处理中途发现自己还需要输入时,返回的是:
text
InputRequiredResult其中:
text
resultType = input_required例如:
json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "input_required",
"inputRequests": {
"confirm_deploy": {
"method": "elicitation/create",
"params": {
"mode": "form",
"message": "确认部署到生产环境?",
"requestedSchema": {
"type": "object",
"properties": {
"confirm": {
"type": "boolean"
}
},
"required": ["confirm"]
}
}
}
},
"requestState": "..."
}
}input_required 不是 Error。
因为 Server 没有执行失败。
它已经正确处理了当前 Request,只是现在还不能给出最终业务结果。
所以 MCP 需要区分:
text
协议执行失败和:
text
当前这一轮正常结束,但整个操作还需要更多输入两种完全不同的状态。
这也是前面消息模型里讲过的 resultType 真正发挥作用的地方。
text
resultType = complete表示:
text
这已经是最终结果。而:
text
resultType = input_required表示:
text
这一轮结束了,但 Client 还需要再发下一轮 Request。注意这里的:
text
这一轮结束了。这是理解 MRTR 最关键的一点。
Server 返回 InputRequiredResult 以后:
text
Request id = 1对应的 JSON-RPC 调用已经彻底结束。
Server 并没有把 id = 1挂在那里等用户回来。
Client 获取完额外输入以后,需要创建一条新的 Request。
当前规范只允许:
text
tools/call
resources/read
prompts/get返回 InputRequiredResult。
因为 MRTR 面向的是:Server 正在执行一个具体操作,但完成这个操作还缺少输入。
它并不是一个可以随便插到任何 MCP Method 里的通用暂停机制。
例如:
text
tools/list本质上是在列出 Tool,没有理由突然进入:
text
请用户回答一个问题以后我再告诉你有哪些 Tool。所以协议没有简单粗暴地让所有 Request 都支持 input_required。
inputRequests 和 inputResponses 为什么要设计成 Map?
InputRequiredResult 中不是:
text
inputRequest而是:
text
inputRequests并且它本身是一个 Map:
text
key → Input Request例如 Server 一轮可能返回:
json
{
"inputRequests": {
"confirm_deploy": {
"method": "elicitation/create",
"params": {
"mode": "form",
"message": "确认继续部署?",
"requestedSchema": {
"type": "object",
"properties": {
"confirm": {
"type": "boolean"
}
},
"required": ["confirm"]
}
}
},
"change_ticket": {
"method": "elicitation/create",
"params": {
"mode": "form",
"message": "请输入变更单编号",
"requestedSchema": {
"type": "object",
"properties": {
"ticket": {
"type": "string"
}
},
"required": ["ticket"]
}
}
}
}
}这里:
text
confirm_deploy
change_ticket都是 Server 自己分配的 Key。
Client 完成以后,再把结果按照同样的 Key 放进:
text
inputResponses例如:
json
{
"inputResponses": {
"confirm_deploy": {
"action": "accept",
"content": {
"confirm": true
}
},
"change_ticket": {
"action": "accept",
"content": {
"ticket": "CR-1024"
}
}
}
}为什么不是:
text
Request 1
Response 1
Request 2
Response 2按数组位置对应?
因为这一轮的多个输入本身可以互相独立。
它们不一定需要串行执行。
也就是说:
text
confirm_deploy
change_ticket完全可以同时交给对应的 Client Handler。
等全部完成以后,再一起汇总成:
text
inputResponses所以 Map 里的 Key 实际上承担了这一轮内部的输入关联标识。
还有一个重要细节:
text
每一轮 inputResponses 只代表这一轮新收集到的 Response。假设整个流程必须分三轮:
第一轮获取:
text
A第二轮获取:
text
B第三轮获取:
text
C第三轮并不能简单假设:
text
inputResponses里还一直保存着 A、B、C。
跨 Round(轮次)真正需要持续存在的状态,应该通过:
text
requestState或者业务自己的持久化机制保存。
requestState 为什么必须对 Client 保持不透明?
如果 MRTR 每一次都是全新的 Request,那么新的 Server Instance 怎么知道:
text
上一轮已经做到了哪里?这就是:requestState存在的原因。
规范把它定义成一个 Opaque String(对 Client 不透明的字符串)。
所谓“不透明”,意思不是:它一定经过加密,所以 Client 技术上绝对看不到里面是什么。
而是协议层规定:
text
Client 不应该理解它。Client 不应该:
解析它、修改它、亦或者根据它里面的字段做业务判断;
假设它采用 JSON、JWT 或某种固定编码。
Client 唯一需要做的是:
text
下一轮原样带回。例如 Server 第一轮已经:
text
校验部署参数
读取生产环境配置
计算部署计划最后只缺用户确认。
它可以生成一份:
text
requestState里面记录恢复下一轮所需要的信息。
流程就变成:
text
Server A
生成 requestState
↓
Client 暂存
↓
下一轮原样带回
↓
Server B
验证 requestState
恢复执行这里最大的价值是:
text
Server B 不一定非得是 Server A。也就是说,一个 HTTP Request 落到实例 A,下一轮 Retry 完全可能被负载均衡到实例 B。
只要 B 能够验证和理解 Server 自己生成的 requestState,就可以继续执行。
这样就不必为了 MRTR 强制要求:
Sticky Session(粘性会话)
也不需要:
text
第一轮一定命中这台机器,第二轮还必须继续命中这台机器。这就是 MRTR 和 Stateless Server 非常契合的地方。
不过这里马上出现一个安全问题。
requestState 会经过 Client 再回来。
所以从 Server 的角度看,它回来时本质上已经属于:
text
不可信输入。假设 Server 直接生成:
json
{
"step": "confirmed",
"isAdmin": true
}然后只是 Base64 编码以后交给 Client。
Client 完全可以自己修改:
json
{
"step": "confirmed",
"isAdmin": true
}再发回来。
所以真正生产级的 requestState 至少需要防篡改。
官方 TypeScript SDK 提供:
text
createRequestStateCodec帮助 Server 生成和验证这类状态。
当前实现使用 HMAC-SHA256 做完整性校验,并且支持 TTL,也就是状态过期时间。
如果部署了多个 Server Instance,它们还需要共享能够验证这类状态的 Key,否则 A 生成的状态到了 B 根本验证不了。
但是这个 Codec 有一个特别需要注意的地方:
text
它是签名,不是加密。所以它可以证明:
text
这段内容没有被 Client 修改。却不能保证:
text
Client 看不到这段内容。因此密码、Access Token、数据库凭证之类的秘密数据,不应该直接放进这种 requestState。
为什么每一轮重试都必须使用新的 Request ID?
假设第一轮 Client 发出:
text
Request id = 101Server 返回:
text
InputRequiredResult
Response id = 101此时101这次 RPC 已经完成。
用户随后完成确认以后,Client 不应该继续id = 101发送数据。
而应该重新创建Request id = 102。
例如:
json
{
"jsonrpc": "2.0",
"id": 102,
"method": "tools/call",
"params": {
"name": "deploy_production",
"arguments": {
"version": "v2.1.0"
},
"inputResponses": {
"confirm_deploy": {
"action": "accept",
"content": {
"confirm": true
}
}
},
"requestState": "..."
}
}Server 最后返回:
text
Response id = 102为什么不能一直使用id = 101?
因为 JSON-RPC Request ID 的职责从来没有变。
它只负责:
text
把当前这一条 Response 和当前这一条 Request 关联起来。实际上流程是:
text
RPC 1
结束
↓
RPC 2
结束
↓
RPC 3
结束这些独立 RPC 在更高一层共同组成一个业务流程。
所以真正跨 Round 延续业务上下文的是:
text
requestState而不是:
text
Request IDtext
Request id = 101
↓
InputRequiredResult
↓
X 这一轮已经结束
requestState + inputResponses
↓
Request id = 102
↓
Complete Result官方 SDK 是怎么把 MRTR 隐藏成“一次调用”的?
从 Wire(线路上传输的实际协议消息)看,MRTR 显然可能包含很多轮 Request。
但使用官方 TypeScript SDK 时,应用代码完全可能仍然只是:
text
await client.callTool(...)这是因为 SDK 内部有一套专门的:
text
Input Required Driver负责驱动整个 MRTR Loop(多轮循环)。
第一次:
text
tools/call如果收到:
text
resultType = input_requiredDriver 就会读取其中的:
text
inputRequests然后交给 Client 已经注册好的对应 Handler。
例如:
text
elicitation/create就进入 Elicitation Handler。
同一轮存在多个 inputRequests 时,SDK 会并发执行。
所有结果完成以后生成:
text
inputResponses再把上一轮的:
text
requestState字节级原样复制到新的 Request,然后重新发送原始 Method。
除此之外,其实还有几层非常重要的保护。
首先是maxRounds
当前默认最大 Round 数是10。
如果一个错误或恶意 Server 永远返回:
text
input_requiredClient 不会无限循环。
超过上限以后会直接终止。
第二,同一轮的多个 Input Request 会并发执行,但共享一套 Abort(取消)机制。
其中一个失败以后,同一轮其他尚未完成的任务也会被取消,避免明知道这一轮已经失败,剩余交互还在后台继续执行。
第三,SDK 同时区分:
text
timeout和:
text
maxTotalTimeout前者约束每一个 Request Leg(单轮请求)。
后者约束整个 MRTR 流程。
假设:
text
每轮 timeout = 30 秒但 Server 连续执行十轮。
如果只有单轮 Timeout,理论上整个调用可能持续:
text
30 × 10 = 300 秒所以还需要:
text
maxTotalTimeout从整个调用第一次发出时开始计算总预算。
每进入下一轮,SDK 都会计算:
text
整体还剩多少时间?这样不会因为每次 Retry 都重新获得一份完整 Timeout,导致总执行时间不断延长。
SDK 甚至考虑了另一种比较特殊的情况:
Server 返回:
text
input_required但没有真正的 inputRequests,只有:
text
requestState这种模式可以让 Server 表达:
text
当前还不能继续,请稍后带着状态再试。如果 Client 完全没有等待,马上:
text
Retry → input_required → Retry → input_required就可能形成 Hot Loop(高速空转循环)。
所以当前 SDK 会在这种 requestState-only Round 之间加入固定 Pacing(节流等待),避免疯狂占用 CPU 和网络。
另外,自动 MRTR 也不是强制的。
SDK 允许关闭:
text
autoFulfill让应用自己看到:
text
input_required并手动决定怎样处理。
这说明 SDK 做的其实是:
text
在协议允许的基础上,为 Host 提供一个默认执行策略。MRTR 真正有价值的地方,也并不是让 MCP “支持多问用户几个问题”。
它完成了一次更深的协议重构:
text
把原本依赖反向 RPC、挂起连接和连接级执行状态的交互过程,重新表示成多个独立的 Client Request,再通过显式输入和可验证的 requestState 把这些 Request 连接成一个完整流程。每一轮 RPC 都可以独立结束。
每一轮都可以使用新的 Request ID。
下一轮甚至可以落到另外一个 Server Instance。
但整个业务过程仍然能够继续。
这才是 MRTR 在新版 MCP 中真正解决的问题。
总结
MCP 中并不是所有 Request 都能够一次完成。一个 Tool、Resource 或 Prompt 在处理过程中,可能还需要用户输入、模型生成结果或者 Client 提供 Roots 等额外信息。2026-07-28 引入的 MRTR(Multi Round-Trip Requests,多轮往返请求),就是为这种场景设计的。
旧版 MCP 允许 Server 在处理 Client Request 的过程中,再反向向 Client 发起 elicitation/create、sampling/createMessage、roots/list 等 Request。这样会形成嵌套的双向 RPC,不仅让 Transport 和请求生命周期更加复杂,也意味着原来的 Request 可能需要长时间保持未完成状态。
MRTR 改变了这种模型。Server 如果发现当前信息不足,不再向 Client 发起一条独立 Request,而是结束当前 Request,返回:
InputRequiredResult
并通过:
resultType = input_required
告诉 Client:
这一轮已经正常结束,但整个操作还需要更多输入。
Client 收集完这些信息以后,再重新发送原来的 Method。新的 Request 与上一轮完全独立,因此必须使用新的 JSON-RPC Request ID。
InputRequiredResult 可以携带 inputRequests。它是一个由 Server 分配 Key 的 Map,每一个 Key 对应一项需要 Client 完成的输入请求。Client 完成以后,再使用相同 Key 将结果放入 inputResponses。这种设计既解决了 Request 与 Response 的关联,也允许多个互相独立的输入在同一轮中被处理。
如果一次操作需要跨多轮保存上下文,Server 还可以返回 requestState。它是一个只对 Server 有意义的 Opaque String(不透明字符串)。Client 不应该解析、修改或依赖其中的内容,只需要在下一轮 Retry 时原样带回。
这种设计让 MRTR 不必依赖原来的 Server Instance。第一轮可以由 Server A 处理,下一轮完全可以被负载均衡到 Server B;只要新的实例能够验证并恢复 requestState,就可以继续处理。因此 MRTR 很适合 MCP 当前的 Stateless(无状态)协议模型,也不要求为了多轮交互强制使用 Sticky Session。
但 requestState 会经过 Client 再返回 Server,因此 Server 必须把它当成不可信输入。如果其中的数据会影响授权、资源访问或者业务行为,就必须保护其完整性,并根据实际场景考虑用户身份、过期时间、原始请求绑定以及 Replay(重放)等问题。
从 Wire 层来看,一次 MRTR 实际可能包含多条独立 Request:
Request → InputRequiredResult → 收集输入 → Retry → InputRequiredResult → Retry → Complete Result
而官方 SDK 可以通过 Input Required Driver 把这套循环封装起来。应用代码仍然可能只是一次 callTool(),SDK 在内部负责处理 inputRequests、收集 inputResponses、回传 requestState、生成新的 Request ID 并继续 Retry,直到获得最终结果或达到最大轮数。
因此,MRTR 最核心的设计思想可以概括成一句话:
不要把一个 Request 挂起来等待额外输入,而是结束当前 Request,把需要的信息显式返回给 Client,再通过一条新的自包含 Request 继续执行。
相关面试题
- MCP 中一次 Request 需要多轮交互时是怎么处理的?什么是 MRTR?
- 为什么新版 MCP 要用 MRTR 替代旧版的 Server → Client Request?
InputRequiredResult是什么?为什么input_required被设计成正常 Result 而不是 Error?- 哪些 MCP Request 可以返回
InputRequiredResult? inputRequests和inputResponses为什么设计成 Map?它们是怎么关联的?requestState是什么?为什么 Client 必须把它当成不透明字符串?- 为什么 MRTR 每一轮 Retry 都必须使用新的 JSON-RPC Request ID?
- MRTR 为什么能够支持 Stateless Server,而不依赖 Sticky Session?
requestState有哪些安全风险?Server 为什么必须对它进行完整性校验?- 官方 SDK 是怎么把多轮 MRTR 封装成看起来像一次普通调用的?