SEP-2322:多轮往返请求
社区译文;来源: MCP 29affa6b
| 字段 | 值 |
|---|---|
| SEP | 2322 |
| 标题 | 多轮往返请求 |
| 状态 | Final |
| 类型 | 标准跟踪 |
| 创建日期 | 2026-02-03 |
| 作者 | Mark D. Roth (@markdroth), Caitie McCaffrey (@CaitieM20), Gabriel Zimmerman (@gjz22) |
| 发起人 | Caitie McCaffrey (@CaitieM20) |
| PR | #2322 |
摘要
本提案规定了一种简单方式,用于在客户端发起请求的上下文中处理服务器发起请求 (例如,在工具调用的上下文中发起信息征询请求),而无需服务器实例之间共享存储层, 也无需负载均衡具备状态性。在常见场景下,这将显著降低大规模运行 MCP 服务器的成本。 该方案还降低了 HTTP 传输对 SSE stream 的依赖;许多无法支持长连接的环境会因 SSE stream 而遇到问题。
本提案处理服务器发起请求的方式将取代当前发送服务器发起请求的方式。这是一项破坏性变更。
本 SEP 还规定了服务器可以在哪些客户端请求上发送服务器发起请求。与当前规范相比, 该范围有所缩小,因此这也是一项破坏性变更。
此处必须进行破坏性变更,因为支持 SSE stream 和服务器端状态的运维复杂度,导致许多远程 MCP 服务器或服务器托管客户端对信息征询(Elicitation)、采样(Sampling)和 ListRoots 等服务器发起 请求功能的采用率很低,甚至无法采用。
动机
注意:本 SEP 旨在提供一种通用机制,用于在任意客户端发起请求的上下文中处理任意服务器发起请求。 为清楚起见,本文会专门以工具调用代表任意客户端发起请求,但应理解为同样适用于资源请求或提示词 请求等场景;同理,本文会以信息征询请求代表任意服务器发起请求,但应理解为同样适用于采样请求等场景。
首先需要观察到,MCP 工具有两种类型:
- 临时(Ephemeral):服务器端不积累状态。
- 如果服务器需要更多信息才能处理工具调用,在获得额外信息后可以从头开始。
- 示例:天气应用、访问电子邮件
- 持久(Persistent):服务器端会积累状态。
- 服务器在向客户端请求更多信息之前可能已经生成大量状态;收到客户端信息后,可能需要恢复这些状态 以继续处理。
- 服务器等待客户端提供更多信息时,可能需要继续在后台处理;此时需要服务器端状态跟踪持续进行的处理。
- 示例:访问 Agent,或者启动虚拟机后需要用户交互来操作虚拟机
绝大多数 MCP 工具都将是临时工具,而且工具部署在水平扩展、负载均衡的服务中极为常见, 因此我们需要针对这种情况进行优化。
目前,如果工具为了继续处理而需要发送信息征询请求,工作流如下:
- 客户端发送工具调用请求。在本例中,假设负载均衡器恰好将该请求发送到服务器实例 A。
- 服务器 A 打开 SSE stream,并通过该 stream 发送信息征询请求。
- 客户端将信息征询响应作为独立请求发送;负载均衡器选择服务器实例时,完全不受步骤 1 所选实例的影响。在本例中,假设负载均衡器恰好将该请求发送到服务器实例 B。
- 服务器 A 必须设法发现已送达服务器 B 的信息征询响应。
- 服务器 A 随后通过步骤 2 打开的 SSE stream 发送工具调用结果。
sequenceDiagram
participant C as Client
participant LB as Load Balancer
participant SA as Server Instance A
participant SB as Server Instance B
Note over C,SA: 1. Initial Request
C->>LB: Tool Call (Start)
LB->>SA: Route to Instance A
activate SA
SA-->>C: Elicitation Request (SSE Stream Open)
Note right of SA: Server A holds memory,<br/>waiting for response...
Note over C,SB: 2. User Responds
C->>LB: Elicitation Result
LB->>SB: Route to Instance B (Stateless LB)
activate SB
Note over SA,SB: A is waiting, but B has the data.
Note over SA,SB: Requires Shared Storage to bridge this gap.
deactivate SA
deactivate SB
难点在步骤 4,它要求服务器端具备某种状态性。目前解决该问题的主要方式,是让所有服务器实例共享 一个存储层,使多个服务器实例能够将某一服务器实例收到的信息征询响应,与另一服务器实例上仍在进行的 原始工具调用匹配起来。
目前主要有两种方式可以解决该问题:
- 服务器实例间共享持久存储层:服务器可以部署和管理持久存储层(例如 PostgreSQL、Redis、
DynamoDB),让多个服务器实例能够将某一实例上的信息征询响应,与另一实例上仍在进行的原始工具调用
匹配起来。该方式存在多项缺点:
- 持久存储层的成本极高,尤其对于本来可能不具备这种存储层的临时工具(例如天气工具)。
- 持久存储层会带来严重的可靠性问题:它会成为关键依赖,因而可能成为单点故障。为避免这种情况, 它必须提供高可用、复制和备份机制。
- 持久存储层会成为瓶颈,限制水平扩展能力。地域分布需要昂贵的全局复制或粘性路由。
- 持久存储层还会带来很高的运维复杂度。在水平扩展部署中,它需要分布式锁或共识协议;还需要专门的 垃圾回收逻辑来判断何时可以清理共享状态,因此必须谨慎权衡:过于激进地清理状态可以降低存储成本, 但会缩短用户可响应的时间;较不激进的清理可以容纳响应较慢的用户,却会增加存储成本。
- 该方式要求工具实现加入特殊行为,以便与持久存储层集成。当前 MCP SDK 没有针对此类存储层集成的 专用 hook,这意味着很难通过 SDK 编写内联代码。
- 负载均衡中的状态性:通过 cookie,负载均衡层可以确保步骤 3 的信息征询响应送达步骤 1
中原始请求所到达的同一服务器实例。该方式通常比持久存储层便宜,但存在以下缺点:
- 它要求负载均衡器具备特殊配置和行为,而这些通常很难管理。
- 它破坏正常的负载均衡模型,造成负载分布不均,从而提高服务运行成本。
- 它要求客户端加入特殊行为,传播用于维持状态性的 cookie。
- 它要求工具实现将信息征询响应与仍在进行的工具调用匹配起来。(MCP SDK 包含一些处理此问题的代码, 但这在 HTTP 世界中仍是一种很不寻常的模式。)
- 它不具备容错能力。如果服务器实例宕机,所有状态都会丢失,工具调用必须从头开始。 (这对临时工具不一定重要,但对持久工具而言是个问题。)
此外,这两种方式都依赖 SSE stream,在无法支持长连接的环境中会引发问题。它们还要求工具实例无限期地 驻留在某个特定服务器实例的内存中。这对信息征询请求尤其成问题,因为用户可能在没有时间上限的情况下 迟迟不返回结果(例如可能是数天或数月,甚至永远不返回)。
本 SEP 的目标,是提出一种更简单的方式来处理客户端发起请求上下文中的服务器发起请求模式。具体而言, 我们需要在水平扩展、负载均衡部署中的临时工具这一常见场景下,降低支持该模式的成本。这意味着解决方案 不能依赖 SSE stream,也不能要求持久存储层或有状态负载均衡;进而意味着必须避免请求之间的依赖:服务器 处理每个独立请求时,除该请求直接携带的信息外,不应需要任何其他信息。
需要注意的是,虽然这里的目标是优化临时工具这一常见场景,但我们仍希望继续支持通常本来就需要持久存储层 的持久工具。
规范
本 SEP 提出一种新机制,用于在客户端请求的上下文中处理服务器请求。该机制为临时工具和持久工具采用稍有 不同的工作流,后者会利用 Task。不过,两种工作流使用相同的数据结构。
Schema 变更
首先,引入 InputRequests 概念,表示要发送给客户端的一组一个或多个服务器发起请求;并引入
InputResponses,表示客户端对这些请求的响应。请求和响应都保存在以字符串为键的 map 中。
InputRequests 的 map 值是服务器发起请求(例如信息征询或采样请求),InputResponses 的 map 值
则是这些请求的响应。其 TypeScript MCP schema 如下:
export type InputRequest =
CreateMessageRequest | ElicitRequest | ListRootsRequest;
export interface InputRequests {
[key: string]: InputRequest;
}
export type InputResponse =
CreateMessageResult | ElicitResult | ListRootsResult;
export interface InputResponses {
[key: string]: InputResponse;
}这些键由服务器在发出请求时分配。客户端使用对应的键发送每个请求的响应。例如,服务器可能发送以下输入请求:
"inputRequests": {
// Elicitation request.
"github_login": {
"method": "elicitation/create",
"params": {
"mode": "form",
"message": "Please provide your GitHub username",
"requestedSchema": {
"type": "object",
"properties": {
"name": {
"type": "string"
}
},
"required": ["name"]
}
}
},
// Sampling request.
"capital_of_france" : {
"method": "sampling/createMessage",
"params": {
"messages": [
{
"role": "user",
"content": {
"type": "text",
"text": "What is the capital of France?"
}
}
],
"modelPreferences": {
"hints": [
{
"name": "claude-3-sonnet"
}
],
"intelligencePriority": 0.8,
"speedPriority": 0.5
},
"systemPrompt": "You are a helpful assistant.",
"maxTokens": 100
}
}
}客户端随后以如下形式发送响应:
"inputResponses": {
// Elicitation response (ElicitResult).
"github_login": {
"action": "accept",
"content": {
"name": "octocat"
}
},
// Sampling response (CreateMessageResult).
"capital_of_france": {
"role": "assistant",
"content": {
"type": "text",
"text": "The capital of France is Paris."
},
"model": "claude-3-sonnet-20240307",
"stopReason": "endTurn"
}
}schema 如下:
export interface InputRequiredResult extends Result {
// Requests issued by the server that must be complete before the
// client can retry the original request.
inputRequests?: InputRequests;
// Request state to be passed back to the server when the client
// retries the original request.
// Note: The client must treat this as an opaque blob; it must not
// interpret it in any way.
requestState?: string;
}
// RequestParams type that includes input responses and request state.
// These parameters may be included in any client-initiated request.
export interface InputResponseRequestParams extends RequestParams {
// New field to carry the responses for the server's requests from the
// InputRequiredResult message. For each key in the response's inputRequests
// field, the same key must appear here with the associated response.
inputResponses?: InputResponses;
// Request state passed back to the server from the client.
requestState?: string;
}由于此变更会为 tools/call 等方法调用产生多态响应,我们在 Result 中引入一个用于指示
ResultType 的新字段。客户端应解析该字段,以确定消息中所含 Result 的类型。为保持向后兼容,
如果未提供该字段,客户端应假定 ResultType 为 "complete"。
扩展**可以(MAY)添加其他 ResultType 值。受支持的 ResultType 值集合必须(MUST)**由核心
协议定义的集合构成,并包含通过 capabilities 声明的受支持扩展所增加的值。
客户端**应该(SHOULD)**将无法识别的值视为无效协议响应。
schema 变更如下:
/**
* Common result fields.
*
* @category Common Types
*/
export interface Result {
_meta?: MetaObject;
// New field to indicate the type of the result, which allows the client to determine how to parse the result object. If no resultType is specified "complete" should be assumed.
resultType: ResultType;
[key: string]: unknown;
}
export type ResultType =
| "complete" // the request completed successfully and the result contains the final content.
| "input_required" // the request is incomplete and the result contains an {@link InputRequiredResult} object
| string; // open to extensions
我们预计该字段对未来扩展很有用,因为它允许引入新的结果类型,并且也可应用于 tasks。
这些类型将用于两种不同的工作流:一种用于临时工具,另一种用于持久工具。
客户端请求对服务器发起请求的支持
许多 ClientRequest 并不存在服务器需要向客户端请求更多信息的明确用例。本 SEP 以
SEP-2260
为基础,进一步限制服务器何时可以向客户端发送服务器发起请求。
服务器**可以(MAY)**在以下客户端请求上发送 InputRequiredResult 响应:
| ClientRequest | ServerResult | 是否支持 InputRequiredResult |
|---|---|---|
GetPromptRequest | GetPromptResult | 是 |
ReadResourceRequest | ReadResourceResult | 是 |
CallToolRequest | CallToolResult | 是 |
GetTaskPayloadRequest | GetTaskPayloadResult | 是 |
服务器**不得(MUST NOT)**在任何其他客户端请求上发送 InputRequiredResult 响应。下表列出撰写本 SEP
时由此排除的 ClientRequest:
| ClientRequest | 是否支持 InputRequiredResult |
|---|---|
PingRequest | 否 |
InitializeRequest | 否 |
CompleteRequest | 否 |
SetLevelRequest | 否 |
ListPromptsRequest | 否 |
ListResourcesRequest | 否 |
ListResourceTemplatesRequest | 否 |
SubscribeRequest | 否 |
UnsubscribeRequest | 否 |
ListToolsRequest | 否 |
GetTaskRequest | 否 |
ListTasksRequest | 否 |
CancelTaskRequest | 否 |
TaskInputResponseRequest | 否 |
临时工具工作流
对于临时工具用例,除输入请求外,我们还引入请求状态概念。当服务器需要更多信息时,它把请求状态发送给 客户端;客户端再将状态回传给服务器,从而使服务器能够保持无状态。
临时工具采用以下工作流:
- 客户端发送工具调用请求。
- 服务器返回一个响应,表明请求尚未完成。该响应可以包含客户端必须完成的输入请求,也可以包含客户端 必须回传给服务器的请求状态。此响应会终止原始请求。通常它会作为单个响应发送,而不是通过 SSE stream 发送;但目前(未来的 SEP 可能改变这一点),在进度通知等消息之后通过 SSE stream 发送该响应也是合法的。 如果未完成响应通过 SSE stream 发送,它必须像普通响应一样,成为该 SSE stream 上的最后一条消息。
- 客户端发送一个与原始请求完全独立的新工具调用请求。新工具调用包含对步骤 2 中输入请求的响应, 以及服务器在步骤 2 中指定的请求状态。
- 服务器返回 CallToolResponse。
sequenceDiagram
participant U as User
participant C as Client
participant S as Server (Stateless)
C->>S: tools/call (id: 1)
note right of S: Server needs more info<br/>Generates requestState
S-->>C: InputRequiredResult (id: 1, inputRequests, requestState)
note over C,S: Initial Request Terminated
C->>U: Prompts user for input
U-->>C: Provides responses
C->>S: tools/call (id: 2, inputResponses, requestState)
note right of S: Server reconstitutes state<br/>Completes execution
S-->>C: Result (id: 2, final content)
请注意,步骤 1 和步骤 3 中的请求完全独立:处理步骤 3 请求的服务器不需要任何未直接包含在该请求中的信息。 为支持这种解耦,步骤 1 与步骤 3 所发送请求的 JsonRPC Id **必须(MUST)**不同。
还需注意,“inputRequests” 和 “requestState” 字段只影响客户端下一次对原始请求的重试。它们不会用于 客户端可能并行发送的任何其他请求(例如工具列表请求,甚至另一个工具调用)。
点击展开临时工具示例流程
临时工具示例流程注意:这是一个为了说明流程而刻意简化的示例。
- 客户端发送初始工具调用请求:
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "get_weather",
"arguments": {
"location": "New York"
}
}
}- 服务器返回未完成响应,表明客户端必须响应一项信息征询请求,工具调用才能完成;响应中还包含需要回传的 请求状态:
{
"jsonrpc": "2.0",
"id": 2,
"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"]
}
}
}
},
"requestState": "foo"
}
}- 客户端随后重试原始工具调用,这次带上对服务器输入请求的响应和请求状态:
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "get_weather",
"arguments": {
"location": "New York"
},
"inputResponses": {
"github_login": {
"action": "accept",
"content": {
"name": "octocat"
}
}
},
"requestState": "foo"
}
}- 最后,服务器完成工具调用:
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"resultType": "complete",
"content": [
{
"type": "text",
"text": "Current weather in New York:\nTemperature: 72°F\nConditions: Partly cloudy"
}
],
"isError": false
}
}临时工作流的真实示例
本示例说明 requestState 如何支持由 Azure DevOps 自定义规则
驱动的多轮往返信息征询流程。场景中,update_work_item 工具把一个 Bug 工作项转换为 “Resolved” 状态。
ADO 自定义规则要求在特定状态转换发生时提供指定字段,服务器通过多轮信息征询逐步收集这些字段,并在各轮之间
把累积上下文保存在 requestState 中,使最终更新无需任何服务器端存储即可执行。
点击展开 ADO 自定义规则示例
背景:当前生效的 ADO 自定义规则
- 规则 1: 当 State 变为 “Resolved” 时,要求提供 “Resolution” 字段 (例如 Fixed、Won’t Fix、Duplicate、By Design)。
- 规则 2: 当 Resolution 为 “Duplicate” 时,要求提供 “Duplicate Of” 字段 (指向原始工作项的链接)。
第 1 轮:工具调用触发状态变更,服务器征询 Resolution
- 客户端调用
update_work_item工具,将 Bug #4522 标记为已解决:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "update_work_item",
"arguments": {
"workItemId": 4522,
"fields": { "System.State": "Resolved" }
}
}
}- 服务器识别出将 State 设为 “Resolved” 会触发规则 1,而该规则要求提供 Resolution 值。
服务器不会让调用失败,而是返回带有信息征询请求的未完成响应。此时还不需要
requestState, 因为重试时会再次发送原始工具调用参数:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "input_required",
"inputRequests": {
"resolution": {
"method": "elicitation/create",
"params": {
"message": "Resolving Bug #4522 requires a resolution. How was this bug resolved?",
"requestedSchema": {
"type": "object",
"properties": {
"resolution": {
"type": "string",
"enum": ["Fixed", "Won't Fix", "Duplicate", "By Design"],
"description": "Resolution type for this bug"
}
},
"required": ["resolution"]
}
}
}
}
}
}- 用户选择 “Duplicate”。客户端携带信息征询响应重试原始工具调用:
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "update_work_item",
"arguments": {
"workItemId": 4522,
"fields": { "System.State": "Resolved" }
},
"inputResponses": {
"resolution": {
"action": "accept",
"content": { "resolution": "Duplicate" }
}
}
}
}第 2 轮:Resolution 触发另一条规则,服务器征询 Duplicate Of
- 服务器合并用户响应,并发现 Resolution = “Duplicate” 会触发规则 2,因而需要 “Duplicate Of” 链接。
它返回另一个未完成响应,这次把已经收集到的 Resolution 编码到
requestState中。这样,无论哪个服务器 实例处理下一次重试,该值都可用:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"resultType": "input_required",
"inputRequests": {
"duplicate_of": {
"method": "elicitation/create",
"params": {
"message": "Since this is a duplicate, which work item is the original?",
"requestedSchema": {
"type": "object",
"properties": {
"duplicateOfId": {
"type": "number",
"description": "Work item ID of the original bug"
}
},
"required": ["duplicateOfId"]
}
}
}
},
"requestState": "eyJyZXNvbHV0aW9uIjoiRHVwbGljYXRlIn0..."
}
}- 用户提供原始工作项 ID。客户端重试工具调用,原样回传
requestState,并带上新的信息征询响应:
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "update_work_item",
"arguments": {
"workItemId": 4522,
"fields": { "System.State": "Resolved" }
},
"inputResponses": {
"duplicate_of": {
"action": "accept",
"content": { "duplicateOfId": 4301 }
}
},
"requestState": "eyJyZXNvbHV0aW9uIjoiRHVwbGljYXRlIn0..."
}
}最终阶段:服务器完成更新
- 服务器解码包含 Resolution 的
requestState,读取包含重复项 ID 的inputResponses, 此时已经具备全部必需字段,可以完成工具调用:
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"resultType": "complete",
"content": [
{
"type": "text",
"text": "Bug #4522 resolved as Duplicate of Bug #4301. State set to Resolved and duplicate link created."
}
],
"isError": false
}
}要点: 在两轮信息征询中,服务器没有保留任何内存状态或持久状态。requestState 字段通过客户端
携带累积上下文,因此任意一轮都可以由任意服务器实例处理。
请求状态的用例
“requestState” 机制允许对同一个逻辑请求进行多轮往返,主要有两类用例。
用例 1:滚动升级
假设正在对水平扩展的服务器实例进行滚动升级,以部署新版本的工具实现。旧版本有两个输入请求,键分别为 “github_login” 和 “google_login”。新版本仍使用 “github_login” 输入请求,但用新的 “microsoft_login” 输入请求替换了 “google_login”。
如果第一个请求到达旧版服务器,而包含输入响应的第二次尝试到达新版服务器,那么服务器会看到它所需的 “github_login” 结果,却看不到 “microsoft_login” 结果。(它也会看到已经不再需要的 “google_login” 结果,但这并不重要。)此时,服务器需要发送新的 “microsoft_login” 输入请求,但又不希望丢失已经获得的 “github_login” 答案,因此会使用 1685 中提出的状态类型来保留该信息,而无需在服务器端存储状态。
工作流如下:
- 客户端发送工具调用请求,该请求到达运行旧版本的服务器实例。
- 服务器返回未完成响应,指明 “github_login” 和 “google_login” 输入请求。
- 客户端发送新的工具调用请求,包含 “github_login” 和 “google_login” 输入请求的响应; 这次请求到达运行新版本的服务器实例。
- 服务器再次返回未完成响应,指明客户端尚未提供的 “microsoft_login” 输入请求。不过,该响应还包含 请求状态,其中保存了已提供的 “github_login” 响应,因此客户端无需第二次向用户征询相同信息。
- 客户端发送第三个工具调用请求,包含对 “microsoft_login” 输入请求的响应,并原样回传服务器在步骤 4 中提供的请求状态。
- 此时,服务器可从请求状态中看到 “github_login” 信息,从输入响应中看到 “microsoft_login” 信息; 请求已包含服务器执行工具调用并返回完整响应所需的全部内容。
用例 2:负载转移
假设一个 MCP 服务器实例正在处理大量工具调用,并发现自身负载过高,因此希望把一个正在进行的工具调用 转移到另一服务器实例。但它已经为该工具调用完成大量处理,不希望直接让调用失败,并让客户端在另一实例上 从头开始;它希望保留已经累积的状态,使恢复处理的任意服务器实例都能从原实例停止的位置继续。服务器可以 发送一个包含请求状态、但不含任何输入请求的未完成响应来实现这一点。
工作流如下:
- 客户端发送原始请求,负载均衡器将其路由到服务器实例 A。
- 服务器实例 A 完成大量计算后决定转移负载。它发送未完成响应,把累积状态放入
requestState字段, 但不包含inputRequests字段。 - 客户端携带
requestState字段重试请求。负载均衡器将该请求路由到服务器实例 B。 - 服务器实例 B 从
requestState字段中的状态开始处理,接续服务器实例 A 停止时的计算,最终返回完整响应。
临时工作流的协议要求
服务器行为:
- 服务器**可以(MAY)使用
InputRequiredResult响应任何客户端发起请求。该消息可以(MAY)作为 独立响应发送,也可以作为 SSE stream 上的最后一条消息发送,但建议实现优先采用前者。如果使用 SSE stream,服务器在未完成响应消息之后不得(MUST NOT)**再通过该 stream 发送任何消息。 InputRequiredResult**可以(MAY)**包含inputRequests字段。InputRequiredResult**可以(MAY)**包含requestState字段。如果指定,该字段是仅对服务器有意义的 不透明字符串。服务器可以自由采用任何格式编码状态(例如纯 JSON、Base64 编码的 JSON、加密 JWT、 序列化二进制等)。- 如果请求包含
requestState字段,服务器必须(MUST)始终验证该状态,因为客户端是不可信的中间方。 如果存在篡改风险,服务器应该(SHOULD)使用自行选择的加密算法加密requestState字段 (例如 AES-GCM 或签名 JWT),同时保证机密性和完整性。还需注意重放/劫持攻击风险:经过身份认证的攻击者 可能重新发送原本发给另一用户的状态。因此,如果请求状态包含任何原始用户专属数据,服务器必须(MUST) 使用某种机制以加密方式把数据绑定到原始用户,并且**必须(MUST)验证客户端发送的requestState数据 与当前通过身份认证的用户相关联。使用明文状态的服务器必须(MUST)**把解码后的值视为不可信输入, 并像验证任何客户端提供的数据一样验证这些值。
- 服务器**可以(MAY)使用
客户端行为:
- 如果客户端收到
InputRequiredResult消息,且消息包含inputRequests字段,那么客户端在重试原始请求前 必须(MUST)构造所请求的输入。相反,如果消息_不_包含inputRequests字段,客户端可以(MAY) 立即重试原始请求。 - 如果客户端收到包含
requestState字段的InputRequiredResult消息,在重试原始请求时,客户端 **必须(MUST)原样回传该字段的精确值。客户端不得(MUST NOT)**检查、解析、修改requestState内容,也不得对其作任何假设。如果InputRequiredResult不包含requestState字段,客户端在重试中 **不得(MUST NOT)**包含该字段。
- 如果客户端收到
持久工具工作流
持久工具工作流将使用 Task。Tasks 已经提供一种机制,用于表明完成请求还需要更多信息。input_required Task 状态允许服务器表明,完成 Task 处理还需要其他信息。
Tasks 的工作流如下:
- 服务器把 Task 状态设为
input_required。服务器可以在此时暂停处理请求。 - 客户端调用
tasks/get获取 Task 状态,并看到还需要更多信息。 - 客户端调用
tasks/result。 - 服务器返回
InputRequests对象。 - 客户端调用
tasks/input_response,请求中包含InputResponses对象和Task元数据字段。 - 服务器恢复处理,并把 Task 状态重新设为
working。
sequenceDiagram
participant U as User
participant C as Client
participant S as Server (Stateful)
C->>S: tools/call (id: 1, task config)
S-->>C: Result (id: 1, taskId, status: working)
note over C,S: Client polls for status
C->>S: tasks/get (id: 2, taskId)
S-->>C: Result (id: 2, status: input_required)
C->>S: tasks/result (id: 3, taskId)
S-->>C: Result (id: 3, inputRequests)
C->>U: Prompts user for input
U-->>C: Provides responses
C->>S: tasks/input_response (id: 4, taskId, inputResponses)
S-->>C: Result (id: 4, acknowledged)
note over C,S: Processing resumes, Client polls
C->>S: tasks/get (id: 5, taskId)
S-->>C: Result (id: 5, status: completed)
C->>S: tasks/result (id: 6, taskId)
S-->>C: Result (id: 6, final content)
由于 Tasks 通常运行时间更长、带有相关状态,并且计算成本可能更高,因此请求更多信息不会结束最初请求的操作(例如工具调用)。服务器可以在收到必要信息后恢复处理。
为与 MRTR 语义保持一致,服务器将使用 InputRequests 对象响应 tasks/result 请求,两者具有相同的 JSON-RPC id。当客户端使用 InputResponses 对象响应时,这是一个带有新 JSON-RPC id 的新客户端请求,因此需要新的方法名。我们提议使用 tasks/input_response。
上述工作流和下方示例均未使用任何可选的 Task 状态通知,但本 SEP 不排除使用这些通知。
点击展开持久工具示例流程
下面的示例完整展示 Echo Tool 的 Task 消息流;该工具可以通过信息征询(Elicitation)向客户端请求其他信息。
- 客户端请求调用 EchoTool。
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "echo",
"task": {
"ttl": 60000
}
}
}- 服务器响应,其中包含一个
Task。
{
"id": 1,
"jsonrpc": "2.0",
"result": {
"task": {
"taskId": "echo_dc792e24-01b5-4c0a-abcb-0559848ca3c5",
"status": "working",
"statusMessage": "Task has been created for echo tool invocation.",
"createdAt": "2026-01-27T03:32:48.3148180Z",
"lastUpdatedAt": "2026-01-27T03:32:48.3148180Z",
"ttl": 60000,
"pollInterval": 100
}
}
}- 客户端请求使用
tasks/get定期检查Task状态。
{
"jsonrpc": "2.0",
"id": 2,
"method": "tasks/get",
"params": {
"taskId": "echo_dc792e24-01b5-4c0a-abcb-0559848ca3c5"
}
}- 服务器响应,其中 Task 状态为
input_required。
{
"id": 2,
"jsonrpc": "2.0",
"result": {
"taskId": "echo_dc792e24-01b5-4c0a-abcb-0559848ca3c5",
"status": "input_required",
"statusMessage": "Input Required to Proceed call tasks/result",
"createdAt": "2026-01-27T03:38:07.7534643Z",
"lastUpdatedAt": "2026-01-27T03:38:07.7534643Z",
"ttl": 60000,
"pollInterval": 100
}
}- 客户端请求发送
tasks/result消息,以确定继续处理需要哪些输入。
{
"jsonrpc": "2.0",
"id": 3,
"method": "tasks/result",
"params": {
"taskId": "echo_dc792e24-01b5-4c0a-abcb-0559848ca3c5"
}
}- 服务器响应返回
inputRequests,请求其他输入。
{
"id": 3,
"jsonrpc": "2.0",
"result": {
"resultType": "input_required",
"inputRequests": {
"echo_input": {
"method": "elicitation/create",
"params": {
"mode": "form",
"message": "Please provide the input string to echo back",
"requestedSchema": {
"type": "object",
"properties": {
"input": { "type": "string" }
},
"required": ["input"]
}
}
}
}
},
"_meta": {
"io.modelcontextprotocol/related-task": {
"taskId": "echo_dc792e24-01b5-4c0a-abcb-0559848ca3c5"
}
}
}- 客户端请求向用户展示信息征询并收集输入,然后向服务器发送消息。
{
"jsonrpc": "2.0",
"id": 4,
"method": "tasks/input_response",
"params": {
"inputResponses": {
"echo_input": {
"action": "accept",
"content": {
"input": "Hello World!"
}
}
},
"_meta": {
"io.modelcontextprotocol/related-task": {
"taskId": "echo_dc792e24-01b5-4c0a-abcb-0559848ca3c5"
}
}
}
}- 服务器响应服务器应发送 ‘JSONRPCResponse’,确认收到 ’tasks/input_response’
消息。如果成功收到消息,则发送包含
taskId的JSONRPCResultResponse。如果发生错误,则发送JSONRPCErrorResponse。服务器现在可以使用所提供的输入继续完成Task,并把Task状态改为Working。
{
"id": 4,
"jsonrpc": "2.0",
"result": {
"_meta": {
"io.modelcontextprotocol/related-task": {
"taskId": "echo_dc792e24-01b5-4c0a-abcb-0559848ca3c5"
}
}
}
}- 客户端请求继续使用
tasks/get轮询状态,直到服务器响应的 Task 状态为Completed。
{
"jsonrpc": "2.0",
"id": 5,
"method": "tasks/get",
"params": {
"taskId": "echo_dc792e24-01b5-4c0a-abcb-0559848ca3c5"
}
}- 服务器响应,其中 Task 状态为
completed。
{
"id": 5,
"jsonrpc": "2.0",
"result": {
"taskId": "echo_dc792e24-01b5-4c0a-abcb-0559848ca3c5",
"status": "completed",
"statusMessage": "Task has been completed successfully, call tasks/result",
"createdAt": "2026-01-27T03:38:07.7534643Z",
"lastUpdatedAt": "2026-01-27T03:38:08.1234567Z",
"ttl": 60000,
"pollInterval": 100
}
}- 客户端请求调用
tasks/result,从服务器获取Task的最终结果。
{
"id": 6,
"jsonrpc": "2.0",
"method": "tasks/result",
"params": {
"taskId": "echo_dc792e24-01b5-4c0a-abcb-0559848ca3c5"
}
}- 服务器响应,其中包含
Task的最终结果。
{
"id": 6,
"jsonrpc": "2.0",
"result": {
"resultType": "complete",
"isError": false,
"content": [
{
"type": "text",
"text": "Echo: Hello World!"
}
],
"_meta": {
"io.modelcontextprotocol/related-task": {
"taskId": "echo_dc792e24-01b5-4c0a-abcb-0559848ca3c5"
}
}
}
}持久工作流的协议要求
服务器行为:
- 服务器**可以(MAY)**在响应
tasks/get时表明 Task 处于input_required状态。 - 当 Task 处于
input_required状态时,服务器**必须(MUST)**在tasks/result响应中包含inputRequests字段。
- 服务器**可以(MAY)**在响应
客户端行为:
- 当
tasks/get显示input_required状态时,客户端**必须(MUST)调用tasks/result获取输入请求。 客户端应该(SHOULD)**构造这些请求的结果,然后携带输入响应调用tasks/input_response,为 Task 提供所需输入。 - 客户端**可以(MAY)**选择不满足输入请求;在这种情况下,客户端可以取消 Task。
- 当
临时工作流与持久工作流之间的转换
如果某个工具实现在开始处理前就需要客户端响应一组输入请求,但随后又需要进行持久处理,那么它可以先使用 临时工作流,再在适当时机创建 Task,从而切换到持久工作流。这样,在服务器真正获得开始处理请求所需的信息前, 无需存储状态。该工作流如下:
- 客户端发送带有 Task 元数据的工具调用请求。
- 服务器返回
inputRequests响应,表明处理请求还需要更多信息。这会终止原始请求。 - 客户端发送一个与原始请求完全独立的新工具调用请求,其中包含
inputResponses对象和 Task 元数据。 - 服务器返回 Task ID,表明它将在后台处理请求。此后的所有交互都通过 Tasks API 完成。
请注意,反向转换不可行:工具实现一旦返回 Task,就承诺在该 Task 的整个存续期间在服务器端存储状态,无法再 退回临时模式。此后的所有交互都必须通过 Tasks API 执行。
错误处理指导
本节针对客户端在 inputResponses 对象中提供意外数据或格式错误数据的场景,给出错误处理的实现指导。
与收到任何请求时一样,服务器**应该(SHOULD)**验证客户端提供的数据是有效的 inputResponses 对象,
并确认其中的信息可以被正确解析。对于格式错误的 JSON、无效 schema 等协议错误,或妨碍请求处理的服务器内部
错误,应返回带有适当错误代码和消息的 JSONRPCErrorResponse。
如果 inputResponses 对象中提供了额外参数,服务器**应该(SHOULD)把它们视为可选参数。因此,对于
inputResponses 对象中无法识别或不需要的任何意外信息,服务器应该(SHOULD)**忽略。
客户端也可能没有发送先前 inputRequests 所请求的全部信息。如果缺失的请求信息是服务器处理该请求所必需的,
服务器**应该(SHOULD)**使用新的 InputRequiredResult 响应。
我们讨论过返回特定的应用层错误代码,但客户端在所有场景中未必都有足够信息完成恢复。因此,我们决定依靠现有
机制,通过 InputRequiredResult 请求更多输入,确保服务器可以再次请求必要信息,使客户端始终能够恢复。
恶意客户端可能故意在 inputResponses 对象中发送错误信息,使服务器重复请求相同信息,从而增加服务器负载。
不过,这并不是该工作流引入的新问题,因为恶意客户端原本就可以通过发送格式错误的请求来增加负载。服务器实现者
可以使用速率限制和节流等标准技术抵御此类攻击。
在临时工作流中,流程如下:
- 客户端重试原始工具调用,这次包含
inputResponses对象,但该响应缺少服务器处理请求所需的必要信息。
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "get_weather",
"arguments": {
"location": "New York"
},
"inputResponses": {
"not_requested_info": {
"action": "accept",
"content": {
"not_requested_param_name": "Information the server did not request"
}
}
}
}
}- 服务器返回未完成响应,表明客户端需要响应一项信息征询请求,工具调用才能完成;响应中还包含需要回传的请求状态:
{
"jsonrpc": "2.0",
"id": 2,
"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"]
}
}
}
}
}
}- 服务器返回未完成响应,表明客户端需要提供缺失信息,请求才能成功。
在持久工作流中,流程如下: 上文步骤 7:客户端请求客户端错误地或恶意地发送意外但格式正确的数据,作为对输入请求的响应。
{
"jsonrpc": "2.0",
"id": 4,
"method": "tasks/input_response",
"params": {
"inputResponses": {
"echo_input": {
"action": "accept",
"content": {
"not_requested_parameter": "Information the server did not request."
}
}
},
"_meta": {
"io.modelcontextprotocol/related-task": {
"taskId": "echo_dc792e24-01b5-4c0a-abcb-0559848ca3c5"
}
}
}
}上文步骤 8:服务器响应服务器发送 JSONRPCResultResponse,确认收到该响应。但由于响应缺少必要信息,
服务器不会继续处理 Task,并将 Task 状态保留为 input_required。客户端下一次调用 tasks/result 时,
服务器会使用新的 inputRequest 响应,再次请求必要信息。
{
"id": 4,
"jsonrpc": "2.0",
"result": {
"_meta": {
"io.modelcontextprotocol/related-task": {
"taskId": "echo_dc792e24-01b5-4c0a-abcb-0559848ca3c5"
}
}
}
}设计理由
我们考虑过使用双向流替代 SSE stream。不过,该方案会使线协议更加复杂(例如,需要 HTTP/2 或 HTTP/3)。 此外,它既不能消除不支持长连接的环境所面临的问题,也不能解决容错问题。
我们还讨论过输入请求应采用 map 还是单个对象;如果采用单个对象,可以利用请求内部的某个字段(例如信息征询 ID) 区分不同请求。我们认为采用 map 更合理,因为它在结构上保证键的唯一性,使 SDK 和应用程序无需为避免冲突而进行 显式检查。
在持久工作流中,我们考虑过直接在 tasks/get 响应中包含输入请求,而不是让客户端先看到 input_required
状态,再调用 tasks/result 获取输入请求。考虑到某些实现分别使用不同基础设施来管理 Task 状态和实际工具实现,
我们决定将两者分开。这样,无论 Task 实际处于何种状态,tasks/get 调用都应具有一致的延迟特征。我们承认这会
额外增加一次服务器往返,但如果将来成为问题,可以再进行优化。
向后兼容性
目前,许多 SDK 以内联但异步的方式支持信息征询:先等待信息征询响应,再通过原始 SSE stream 发送工具调用响应。 这种方式适用于单进程 MCP Server,或能够保证请求粘性路由的 MCP Server。
def my_tool():
do_work()
await elicit_more_info()
do_more_work()
return tool_result为支持现有工具并保持向后兼容性,SDK **可以(MAY)继续支持这种信息征询方式,但应该(SHOULD)**将此模式 标记为旧式/已弃用。
今后的示例和 SDK 需要支持新的信息征询方式,代码不能假定两次工具调用由同一进程处理。这种编程模型不那么直观, 但它确保 MCP Server 可以从单进程 Stdio MCP Server 演进为多进程远程 MCP Server,而无需进行大规模重写; 同时也确保今后只有一种推荐的信息征询方式。
def my_tool(request):
if(request.requestState):
state = decode(request.requestState)
if(request.inputResponses):
additionalInfo = decode(request.inputResponses)
do_work(state, additionalInfo)
if(more_info_needed):
return IncompleteResponse();
else
do_more_work()
return tool_result我们还考虑过提供两套独立编程模型,让开发者根据 MCP Server 是单进程部署还是多进程部署进行选择,从而继续支持 await 语义。但这会增加开发体验的复杂度,也会让开发者更难在单进程部署与多进程部署之间切换。
安全影响
由于 requestState 会经过客户端,恶意客户端或已被攻破的客户端可能尝试修改它,以改变服务器行为、绕过授权检查
或破坏服务器逻辑。为降低此风险,我们要求服务器按照上述协议要求验证该状态。
参考实现
待定
致谢
感谢 Luca Chang(@LucaButBoring)就如何把输入请求集成到 Tasks 中提供宝贵意见。