SEP-1577:支持工具的采样

SEP-1577:支持工具的采样

社区译文;来源: MCP 29affa6b

本 SEP 已达到 Final 状态,并作为已接受设计的历史记录保留。 定稿后对协议所做的更改不会反映在此处。权威要求请参阅当前规范 及其变更日志。
字段值
SEP1577
标题支持工具的采样
状态Final
类型标准跟踪
创建日期2025-09-30
作者Olivier Chafik (@ochafik)
发起人无
PR#1577

摘要

本 SEP 为 sampling/createMessage 引入 tools 和 toolChoice 参数,并软弃用 includeContext(将 thisServer 和 allServers 置于能力协商之后)。这使 MCP 服务器能够使用 客户端的 token 运行自己的 agent 循环(仍受用户监督),同时降低客户端实现复杂度(上下文支持 明确成为可选能力)。

动机

  • 采样不支持工具调用, 但工具调用是现代 agent 行为的基石。没有明确支持时,使用采样的 MCP 服务器只能通过复杂提示词 和自定义输出解析模拟工具调用,或者局限于较简单的非 agent 请求。增加工具调用支持可为 MCP 生态解锁许多新用例。

  • 上下文包含的定义含糊不清(参见此文档): 这使完整实现采样尤其困难;再加上采样所需的其他防护措施(不受本 SEP 影响),可能共同导致 客户端对该特性的采用率较低 (该特性在 MCP 2024 年 11 月版规范中引入)。

另请注意以下相关工作:

  • MCP Sampling (@jerome3o-anthropic):一项极为相似的提案:
    • 添加相同的工具语义
    • 弃用 includeContext(文档解释了其语义为何含糊)
    • (进一步建议显式共享上下文,但这超出本提案范围)
  • 允许 Prompt/Sampling 消息包含多个内容块。#198
    • 此 PR 已让 {CreateMessageResult,SamplingMessage}.content 接受单个内容或内容数组。 result.content 的变更不向后兼容,但支持并行工具调用需要该变更。SamplingMessage.content 的变更则使编写工具循环自然得多(参见参考实现示例: toolLoopSampling.ts)。

在下文“可能的后续工作”一节中,我们列出了未纳入本 SEP 范围的特性示例;设计本 SEP 时已注意 尽量与这些特性保持兼容。

规范

概览

  • 在 CreateMessageRequest 中加入传统工具调用支持,增加 tools(带 JSON schema)和 toolChoice 参数,并要求服务器端工具循环
    • 采样现在可以产生 ToolCallBlock 响应
    • 服务器需要自行调用工具
    • 服务器再次调用采样,并通过 ToolResultParamBlock 注入工具结果
    • toolChoice.mode 可以是 “auto" | "required" | "none",以支持常见的结构化输出用例 (可能的后续改进见下文)
    • 由新能力(sampling { tools {} })控制
  • 修正/更新 CreateMessageResult 中定义不充分的字符串:
    • stopReason: “endTurn" | "stopSequence" | “toolUse" | “maxToken" | string (显式枚举加开放字符串,以保持兼容)
    • role: “assistant”
  • 软弃用 CreateMessageRequest.params.includeContext 不等于 ‘none’ 的用法(现在由能力控制)
    • 鼓励实现不带上下文的采样

协议变更

  • sampling/createMessage
    • 当 includeContext is “thisServer” | “allServers” 但缺少 clientCapabilities.sampling.context 时,MUST 抛出错误
    • 当定义了 tool 或 toolChoice 但缺少 clientCapabilities.sampling.tools 时,MUST 抛出错误
    • 服务器 SHOULD 避免使用 [includeContext](https://modelcontextprotocol.io/specification/2025-06-18/schema#createmessagerequest) 不等于 ‘none’ 的值,因为 “thisServer” 和 “allServers” 可能在未来规范版本中删除。
    • CreateMessageRequest.messages 中,每条带 ToolUseContent(及 id: $id1)的 “assistant” 消息都 MUST 与一条带 ToolResultContent(及 tool_result_id: $id1)的 “user” 消息配对
      • 注意:这是 Claude API 实现的要求(并行工具调用必须一次性全部响应)
    • 包含工具结果内容块的 SamplingMessage MUST NOT 包含其他内容类型。

Schema 变更

  • ClientCapabilities

    interface ClientCapabilities {
      ...
      sampling?: {
        context?: object; // NEW: Allows CreateMessageRequest.params.includeContext != "none"
        tools?: object;   // NEW: Allows CreateMessageRequest.params.{tools,toolChoice}
      };
    }
  • CreateMessageRequest (使用现有的 Tool)

    interface CreateMessageRequest {
      method: “sampling/createMessage”;
      params: {
        ...
        messages: SamplingMessage[]; // Note: type updated, see below
        
        tools?: Tool[] // NEW (existing type)
    
        toolChoice?: ToolChoice // NEW
      };
    }
    
    interface ToolChoice { // NEW
      mode?: “auto” | "required" | "none";
      // disable_parallel_tool_use?: boolean; // Update (Nov 10): removed, see below
    }
    • 注意:
      • OpenAI 与 Anthropic API 禁止并行工具调用的惯用方式:
        • OpenAI:parallel_tool_calls: false(顶层参数)
        • Anthropic:tool_choice.disable_parallel_tool_use: true
          • 此处倾向将未设置时的默认值设为 false(即允许并行工具调用)
      • OpenAI 与 Anthropic API 对 tool_choice 为 "none" 与 tools 的处理:
        • OpenAI:tools: [$Foo], tool_choice: "none" 禁止任何工具调用
          • 此处倾向采用该行为
        • Anthropic:tools: [$Foo], tool_choice: {mode: "none"} 仍可能调用工具 Foo
      • Gemini 与 OAI/Anthropic 对 disable_parallel_tool_use 的处理:
        • Gemini API 目前无法禁用并行工具调用(OAI/Anthropic API 可以)。因此暂时移除此标志, 待 Gemini 提供支持后再重新引入。否则客户端会收到意外的多个工具调用(或者取决于实现, 出现意外失败或代价高昂的重试,直至模型只发出一次工具调用)
        • Gemini API 的函数调用模式 包含 ANY 值,应与提议的 required 对应
  • SamplingMessage:

    /*
      BEFORE:
    
      interface SamplingMessage {
        content: TextContent | ImageContent | AudioContent
        role: Role;
      }
    */
    
    type SamplingMessage = UserMessage | AssistantMessage; // NEW
    
    type AssistantMessageContent =
      TextContent | ImageContent | AudioContent | ToolUseContent;
    type UserMessageContent =
      TextContent | ImageContent | AudioContent | ToolResultContent;
    interface AssistantMessage {
      // NEW
      role: "assistant";
      content: AssistantMessageContent | AssistantMessageContent[];
    }
    
    interface ToolUseContent {
      // NEW
      type: "tool_use";
      name: string;
      id: string;
      input: object;
    }
    
    interface UserMessage {
      // NEW
      role: "user";
      content: UserMessageContent | UserMessageContent[];
    }
    
    interface ToolResultContent {
      // NEW
      _meta?: { [key: string]: unknown };
      type: "tool_result";
      toolUseId: string;
      content: ContentBlock[];
      structuredContent: object;
      isError?: boolean;
    }
  • 注意:

    • 各 API 在工具调用中对角色与内容类型的处理差异:
      • OpenAI:role: “system" | “user" | “assistant" | “tool"(其中 tool 用于工具结果), 工具调用嵌套在 assistant 消息中;content 通常为 null,但某些“OpenAI 兼容”API 接受非 null 值
        • [
            { role: "user", content: "what is the temperature in london?" },
            {
              role: "assistant",
              content: "Let me use a tool...",
              tool_calls: [
                {
                  id: "call_1",
                  type: "function",
                  function: {
                    name: "get_weather",
                    arguments: '{"location": "London"}',
                  },
                },
              ],
            },
            {
              role: "tool",
              content: '{"temperature": 20, "condition": "sunny"}',
              tool_call_id: "call_1",
            },
          ];
      • Claude API:role: “user" | “assistant",工具使用和结果通过有专门类型的消息内容片段传递:
        • [
            {
              "role": "user",
              "content": [
                {
                  "type": "text",
                  "text": "what is the temperature in london?"
                }
              },
            {
              "role": "assistant",
              "content": [
                {
                  "type": "text",
                  "text": "Let me use a tool..."
                },
                {
                  "type": "tool_use",
                  "id": "call_1",
                  "name": "get_weather",
                  "input": {"location": "London"}
                }
              ]
            },
            {
              "role": "user",
              "content": [
                {
                  "type": "tool_result",
                  "tool_call_id": "call_1",
                  "content": {"temperature": 20, "condition": "sunny"}
                }
              ]
            }
          ]
      • Gemini API:
        • function 角色(类似 OAI 的 tool 角色)
        • 没有工具调用 ID 的概念(函数调用: Gemini 要求按工具使用片段完全相同的顺序提供工具结果。实现可生成工具调用 ID,并在需要时用它们 对工具结果重新排序。
  • CreateMessageResult

    /*
      BEFORE:
    
      interface CreateMessageResult {
        _meta?: { [key: string]: unknown };
        content: TextContent | ImageContent | AudioContent;
        role: Role;
        stopReason?: string;
        [key: string]: unknown;
    }
    */
    interface CreateMessageResult {
      _meta?: { [key: string]: unknown };
    
      content: AssistantMessageContent | AssistantMessageContent[] // UPDATED
    
      role: "assistant"; // UPDATED
    
      stopReason?: “endTurn" | "stopSequence" | “toolUse" | “maxToken" | string // UPDATED
    
      [key: string]: unknown;
    }
    • 注意:
      • 向后兼容问题:让 CreateMessageResult.content 返回内容数组或单个内容会带来问题,因此提议:
        • 在 2025 年 11 月版规范之前,sampling/createMessage MUST NOT 在 CreateMessageResult.content 中返回数组。
          • 这可保证线级向后兼容性
        • 使用采样的现有代码可能随新 SDK 发布而中断,因为它需要检测 content 是数组还是单个块, 并采取相应处理。
        • 这似乎是合理的(?)
      • CreateMessageResult.stopReason 字段目前定义为开放的 string,规范只将 endTurn 作为示例值提及。
      • OpenAI 与 Anthropic API 的惯用方式
        • 完成/停止原因
          • OpenAI 的 ChatCompletion: finish_reason: “stop” | “length” | “tool_use”(……?)
          • Anthropic: stop_reason: “end_turn” | “max_tokens” | “stop_sequence” | “tool_use” | “pause_turn” | “refusal”

可能的后续工作

以下内容超出本 SEP 范围,但设计时已注意不排除它们;因此在适当位置给出了如何基于本 SEP 或在其后 实现这些内容的示例。

流式支持

参见:流式工具使用结果 #117

这对某些长时间运行的用例或重视延迟的场景可能很重要,但若 MCP 工具也支持流式传输,配合会更好。

一种可能的实现方式是使用带 payload 的通知,并可能创建新方法 sampling/createMessageStreamed。 二者应与本 SEP 正交(但需要为结果创建 delta 类型,类似 Claude API 和 OpenAI API 等推理 API 中的流式 API)。

缓存友好性更新

这里需要两部分:

  • 引入缓存感知
    • 以 SHOULDs 表述的隐式缓存指南
    • 显式缓存点和 TTL 语义,如 Claude API 所示?(包括更长缓存的 beta 行为)
      • 优点:至少对一个实现方(Anthropic)容易实现
      • 缺点:如果其他实现方难以实现,就不太可能获批。
    • 使用显式 key 的“完整提示词”/提示词前缀缓存, 如 OpenAI API 所示?
      • 优点:
        • 对用户更简单(无需考虑共享前缀在哪里结束)
        • 隐式支持更新缓存(甚至可能以子树方式)
      • 缺点:可能更难实现或存储效率更低
  • 引入 allowed_tools 特性,以便在不破坏上下文缓存的情况下启用/禁用工具

允许客户端在 agent 循环中自行调用服务器工具

从服务器视角看,这将消除服务器自行调用工具并在后续采样调用中注入工具结果的需要。

MCP 服务器只需在采样请求中将自己的工具列入允许列表,并使用专用工具定义,例如:

{
  type: "server-tool"; // MCP tool from same server.
  name: string;
}

优点:

  • 安全,范围仅限该服务器的工具。
  • 如果传播 mcp-session-id,就可以保留服务器端会话上下文或缓存

允许客户端在 agent 循环中自行调用其他任意 MCP 服务器的工具

尽管这听起来与前一项(只允许同一服务器的工具)相似,但该选项无需协议变更,完全可以作为客户端的 实现细节。

终端用户可允许在采样请求中使用来自其他任意 MCP 服务器的工具,而无需服务器提出请求。例如, 客户端 UI 可在采样审批流程中显示工具选择界面,并默认自动启用同一服务器的工具。

优点:

  • 技术上无需规范变更(如果需要,只要说明客户端拥有这种自由)
  • 可能类似 CreateMessageRequest.params.includeContext 等于 thisServer/allServers 时原本可能表达的语义
    • CreateMessageRequest.params.allowImplicitToolCalls = “none” | “thisServer” | “allServers” (假设我们希望让服务器对此拥有一定控制权)

缺点:

  • 可能需要分类器,以避免很高的数据泄露或滥用风险
    • 如果用户误批 Gmail MCP 工具的使用或委派,服务器就会通过采样访问其私人邮件

允许服务器列出并调用客户端工具(客户端/服务器 → 点对点)

如果规定客户端现在可以公开供服务器调用的工具,就会开启一系列可能性:

  • 客户端可以“转发”其他服务器的工具(也许使用某种命名空间来实现无缝聚合)
    • 服务器随后可在其工具循环中调用这些工具。
  • 客户端与服务器的语义开始弱化,进入更对等、更对称的关系
    • 同样地,客户端也可以请求服务器执行采样
    • 协议层保持对称,但传输层仍有方向性(例如对 HTTP 传输而言,POST 请求的方向仍然重要)

简化结构化输出用例

采样的一个主要用例是获取符合给定 schema 的输出。

例如,OpenAI API 可以实现这一点。

最常见的变通方法是只提供一个工具,并设置 tool_choice: "required";这样可保证输出为 ToolCall, 且输入符合该工具的输入 schema。

虽然本 SEP 提议启用基于 "required" 的变通方法,但后续最好提供更明确、更简单的 JSON schema 支持; 这还能允许工具输入中不允许的 schema 类型(工具输入要求是带 properties 的 object,因此必须至少为输出 选择一个名称,这需要额外思考,并会与提示策略相互影响):

interface CreateMessageRequest {
  method: “sampling/createMessage”;
  params: {
    messages: SamplingMessage[];
    ...
    format: {
      type: "json_schema",
      "schema": {
        "type": "array",
        "minItems": 5,
        "maxItems": 100
      }
    }
  }