MCP 协议设计与实现
第17章 Sampling:服务端发起的 LLM 调用
MCP 里最反直觉的一环,是把请求方向掉了个个儿:Server 反过来通过 Client 去调 LLM。
本章要点
- Sampling 反转了 MCP 的请求方向——Server 通过 Client 调用 LLM
- 零 API Key 架构——Server 贡献逻辑,用户提供算力
- 两层模型抽象:hints(字符串模糊匹配)+ priorities(三维能力量化)
- Agent 循环内嵌——Sampling 支持工具调用,Server 可以实现完整 Agent 行为
- 人机审批三点:请求审查 / 工具审查 / 响应审查
- 五层安全防线:能力协商 / 人工审批 / Client 控制 / 迭代限制 / 内容验证
- 设计意义:把 Agent 认知能力从基础设施变成按需获取的服务
17.1 什么是 Sampling
在前面的章节中,我们看到的都是 Client 向 Server 发起请求的模式——调用工具、读取资源、获取提示词模板。这些都是经典的"Client 主动,Server 被动"的交互方式。
Sampling 彻底反转了这个方向。
Sampling 是 MCP 协议中少数由 Server 主动向 Client 发起请求的功能之一(另外两个是第 18 章要讲的 Elicitation 和 Roots)。具体来说,Server 可以通过 sampling/createMessage 请求,要求 Client 使用其连接的 LLM 进行一次文本生成(completion)。Client 收到请求后,将其转发给 LLM,拿到生成结果,再返回给 Server。
以上是本专栏锚定的 2025-11-25 版规范的形态,本章也按这一版来讲。2026-07-28 版有两处变化(见该版 changelog.mdx):服务端发起的请求改为 Multi Round-Trip Requests——Server 在结果里返回 InputRequiredResult,把 sampling/createMessage 放进 inputRequests,由 Client 带着答复重试原请求(SEP-2322);Sampling 功能本身也被标为 Deprecated,新实现 SHOULD NOT 采用,建议改为直接对接 LLM 提供商的 API(SEP-2577)。
这个看似简单的"反向调用"设计,解决了一个困扰 Agent 生态已久的核心问题:Server 如何在不持有 LLM API Key 的情况下使用 AI 能力?
graph LR
subgraph 传统方式
S1[MCP Server] -->|需要 API Key| LLM1[LLM API]
end
subgraph Sampling 方式
S2[MCP Server] -->|sampling/createMessage| C[MCP Client]
C -->|已有 API Key| LLM2[LLM API]
end
style S1 fill:#fee2e2,stroke:#ef4444
style S2 fill:#dcfce7,stroke:#22c55e
style C fill:#dbeafe,stroke:#3b82f6
传统方式的四大痛点
传统方式下,如果一个 MCP Server 的逻辑中需要调用 LLM(比如对用户提交的代码进行审查、生成摘要、做分类判断),它必须自己持有一个 LLM API Key,自己管理调用、计费、限流。这带来了几个严重问题:
- 密钥管理负担:每个 Server 都要安全地存储和轮转 API Key——小团队做不了,大厂也会出事
- 计费碎片化:用户可能同时使用多个 Server,每个 Server 各自计费,费用不透明
- 模型选择权丧失:Server 绑定了特定的 LLM 提供商,用户无法选择自己偏好的模型
- 安全风险放大:API Key 散布在各种第三方 Server 中,攻击面急剧扩大
Sampling 的设计哲学
LLM 的调用权归 Client 所有,Server 只需要描述"我需要什么样的 AI 输出",具体用哪个模型、怎么调用、花多少钱,全部由 Client 决定。
这个哲学带来三个根本性改变:
- 开发者门槛降低:不需要有 LLM 账号就能写 MCP Server
- 用户体验统一:所有 Sampling 都在同一个 Client 里,用户感知统一的 AI 账单
- 模型选择自由:用户自主决定用哪家 LLM,Server 无法锁定
17.2 能力声明
Sampling 不是默认启用的。Client 必须在初始化阶段通过能力声明告知 Server 自己支持 Sampling。
最基本的声明方式:
{
"capabilities": {
"sampling": {}
}
}
如果 Client 还支持在 Sampling 过程中使用工具(这是更高级的能力),需要额外声明:
{
"capabilities": {
"sampling": {
"tools": {}
}
}
}
只有当 Client 声明了 sampling 能力后,Server 才被允许发送 sampling/createMessage 请求。这是 MCP 协议一贯的能力协商原则——不要假设对方支持什么,先问再用。
能力协商的工程价值
能力协商看似繁琐,但它带来了协议演进的自由度:
- 新增能力不破坏老 Client:声明了才用,没声明就不用
- 老 Server 不需要理解新 Client 的声明:能忽略就行
- 渐进升级路径清晰:先发布支持能力,再真正使用
这是一个在 HTTP、IMAP、XMPP 等成熟协议中反复验证过的设计范式。
值得注意的是,规范中还有一个 sampling.context 子能力。被标记为"软弃用"(soft-deprecated)的其实是 includeContext 的 "thisServer" 和 "allServers" 两个取值:2025-11-25 版规范说 Server SHOULD 避免使用它们(直接省略 includeContext、走默认的 "none"),并且 SHOULD NOT 在 Client 未声明 sampling.context 时使用;这两个值可能在未来版本中移除。sampling.tools 与 sampling.context 两个子能力都是 2025-11-25 才出现的,此前各版 schema 里 sampling 只是一个空的 object。2026-07-28 版把这两个取值正式列为 Deprecated,最迟随 Sampling 功能一起移除。
17.3 CreateMessage 请求与响应
17.3.1 请求结构
Server 通过发送 sampling/createMessage 来请求 LLM 生成(这个方法自 2024-11-05 首版规范就有)。按 2025-11-25 的 schema,messages 与 maxTokens 是必填字段,其余都可选。请求的核心字段包括:
{
"jsonrpc": "2.0",
"id": 1,
"method": "sampling/createMessage",
"params": {
"messages": [
{
"role": "user",
"content": {
"type": "text",
"text": "请分析这段代码的时间复杂度"
}
}
],
"systemPrompt": "你是一位资深的算法工程师。",
"maxTokens": 500,
"modelPreferences": {
"hints": [{ "name": "claude-3-sonnet" }],
"intelligencePriority": 0.8,
"speedPriority": 0.5
},
"temperature": 0.7,
"stopSequences": ["\n\n---"],
"metadata": {
"purpose": "code-analysis"
}
}
}
六个关键字段的设计
1. messages 数组
Server 构造一个完整的对话上下文传给 Client。每条消息的 content 可以是单个内容块,也可以是内容块数组,内容块支持以下类型:
- 文本(text)
- 图片(image,Base64 编码)
- 音频(audio,Base64 编码,2025-03-26 加入)
- 工具调用
tool_use与工具结果tool_result(连同数组形式的content,都是 2025-11-25 随 Sampling 工具调用一起加入的,见 17.4 节)
这意味着 Sampling 不仅支持纯文本场景,也天然支持多模态交互。
2. systemPrompt
Server 可以指定系统提示词。但 schema 写明 Client 可以(MAY)修改或省略它。
3. maxTokens
请求生成的最大 token 数,必填。schema 对它的说明是"防止生成失控"(to prevent runaway completions),并允许 Client 采样得比这个上限更少(MAY)——它是 Server 提出的上限,Client 还可以进一步压低。
4. modelPreferences
这是最精妙的设计之一,在 17.5 节详细分析。
5. temperature 和 stopSequences
标准的生成控制参数,让 Server 可以调优输出特性。
6. metadata
可选的元数据对象。schema 的定义是"透传给 LLM 提供商",格式由提供商决定(provider-specific);协议并没有为它约定任何键,上例里的 purpose 只是示意。
除了这六个字段,请求里还可以带 includeContext(取值见 17.2 节,默认 "none"),以及 2025-11-25 加入的 tools / toolChoice(见 17.4 节)。
17.3.2 响应结构
Client 处理完请求后返回:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"role": "assistant",
"content": {
"type": "text",
"text": "这段代码使用了嵌套循环,时间复杂度为 O(n²)..."
},
"model": "claude-3-sonnet-20240307",
"stopReason": "endTurn"
}
}
关键字段:
-
role/content:CreateMessageResult继承SamplingMessage,所以content同样可以是单个内容块或数组;带工具时可能包含tool_use(见 17.4 节) -
model:告诉 Server 实际使用的是哪个模型——Server 只是"建议"了一个模型,Client 可能用了完全不同的模型 -
stopReason:生成停止的原因,常见值:endTurn——正常结束toolUse——需要调用工具(2025-11-25 加入)maxTokens——达到 maxTokens 限制stopSequence——匹配到 stopSequences
stopReason在 schema 里是开放字符串('endTurn' | 'stopSequence' | 'maxTokens' | 'toolUse' | string),允许各家 LLM 提供商扩展自己的停止原因。注意CreateMessageResult没有 token 用量字段——记账信息不在 Sampling 响应里,需要 Client 自己在本地统计。
17.4 Sampling 中的工具调用
Sampling 的基础用法是"Server 请求一次 LLM 生成,拿到文本结果"。但 2025-11-25 版规范进一步支持了一个更强大的模式:在 Sampling 过程中使用工具(tools / toolChoice 参数,SEP-1577)。
这意味着 Server 可以定义一组工具,让 Client 侧的 LLM 在生成过程中调用这些工具,形成一个完整的 Agent 循环——全部发生在一次 Sampling 会话中。
完整流程
sequenceDiagram
participant Server as MCP Server
participant Client as MCP Client
participant User as 用户
participant LLM as LLM
Note over Server,Client: 第一轮:发起带工具的 Sampling 请求
Server->>Client: sampling/createMessage<br/>(messages + tools)
Client->>User: 展示请求,等待审批
User-->>Client: 批准
Client->>LLM: 转发请求(附带工具定义)
LLM-->>Client: 返回 tool_use 响应<br/>(stopReason: "toolUse")
Client->>User: 展示工具调用,等待审批
User-->>Client: 批准工具调用
Client-->>Server: 返回 tool_use 结果
Note over Server: 执行工具(如查询天气 API)
Server->>Server: 运行 get_weather("北京")
Note over Server,Client: 第二轮:携带工具结果继续对话
Server->>Client: sampling/createMessage<br/>(完整历史 + tool_result + tools)
Client->>User: 展示继续请求
User-->>Client: 批准
Client->>LLM: 转发(含工具结果)
LLM-->>Client: 最终文本响应<br/>(stopReason: "endTurn")
Client->>User: 展示最终响应
User-->>Client: 批准
Client-->>Server: 返回最终结果
整个流程的关键在于:工具的定义由 Server 提供,但工具的执行也由 Server 完成。Client 和 LLM 只负责"决定调用哪个工具、传什么参数",真正的执行权还在 Server 手中。
控制反转的优美
这是一个精妙的控制反转:
- Server 把"认知决策权"交给 LLM(通过 Client)
- Client 把"执行权"交回给 Server
- 用户在两端都有审批点
这种分工让每一方都做自己擅长的事,同时用户始终保持控制权。
工具选择模式
Server 可以通过 toolChoice 字段控制 LLM 使用工具的行为:
| 模式 | 含义 | 典型场景 |
|---|---|---|
{mode: "auto"} |
LLM 自主决定是否使用工具(默认) | 开放式任务 |
{mode: "required"} |
LLM 必须至少调用一个工具 | 强制路由到工具 |
{mode: "none"} |
禁止 LLM 使用任何工具 | 最终轮强制输出文本 |
ToolChoice 只有 mode 一个字段,取值就是上面三种(schema/2025-11-25/schema.ts 的 ToolChoice、TS ToolChoiceSchema、Python types/_types.py 的 ToolChoice 三处一致)——协议层没有"指定调用某个具体工具"的模式。
一个常见的实践是:Server 在多轮工具循环中设置最大迭代次数,当到达最后一轮时,传入 {mode: "none"} 强制 LLM 输出最终的文本结果,避免无限循环。
消息内容约束
工具调用引入了严格的消息格式约束,这些约束是为了兼容不同 LLM 提供商的 API 设计(如 OpenAI 的 tool 角色、Gemini 的 function 角色):
- tool_result 消息不能混合其他内容:当一条
user消息包含tool_result类型的内容时,该消息只能包含tool_result,不能混入text或image - tool_use 和 tool_result 必须成对出现:每个
assistant消息中的tool_use(带有id)都必须在紧接其后的user消息中有对应的tool_result(带有匹配的toolUseId) - 支持并行工具调用:LLM 可以在一条
assistant消息中返回多个tool_use,Server 需要执行全部工具并一次性返回所有结果
这些约束看起来很"硬",但正是这种严格性让协议能被多种 LLM 后端支持。
17.5 模型偏好系统
Server 和 Client 可能使用完全不同的 LLM 提供商。一个 Server 不能简单地说"请用 claude-3-sonnet",因为 Client 可能根本没有接入 Anthropic 的 API。
MCP 用一个两层抽象解决了这个问题:hints(提示)+ priorities(优先级)。
17.5.1 Hints:模型名称的模糊匹配
hints 是一个有序的模型名称建议列表,每个名称被当作子字符串匹配:
{
"hints": [
{ "name": "claude-3-sonnet" },
{ "name": "claude" },
{ "name": "gpt-4" }
]
}
Client 按顺序尝试匹配:
- 先看有没有包含 "claude-3-sonnet" 的模型
- 没有就退而求其次找包含 "claude" 的
- 还没有就找 "gpt-4"
- 如果都没有,怎么选由 Client 决定——比如参考 priorities 选能力接近的替代模型
schema 的要求是:多个 hint 必须按顺序评估、取第一个匹配(MUST),hints 应优先于数值 priority(SHOULD),priority 可用来在多个匹配之间做取舍(MAY)。
比如,一个只接入了 Google 的 Client 可能会把 "claude-3-sonnet" 映射到 gemini-1.5-pro(相似能力级别的模型)。
Hints 只是建议,不是命令。 最终的模型选择权始终在 Client 手中。
17.5.2 Priorities:能力维度的量化表达
三个归一化的优先级值(0 到 1),让 Server 精确表达自己的需求侧重:
| 优先级 | 含义 | 高值倾向 |
|---|---|---|
costPriority |
成本敏感度 | 更便宜的模型 |
speedPriority |
延迟敏感度 | 更快的模型 |
intelligencePriority |
能力需求 | 更强的模型 |
17.5.3 场景示例
场景 1:代码审查 Server
{
"intelligencePriority": 0.9,
"speedPriority": 0.3,
"costPriority": 0.2
}
需要高质量的分析,不在乎多等几秒。
场景 2:实时聊天机器人
{
"speedPriority": 0.9,
"intelligencePriority": 0.5,
"costPriority": 0.4
}
响应速度比深度分析更重要。
场景 3:批量内容分类
{
"costPriority": 0.9,
"speedPriority": 0.5,
"intelligencePriority": 0.3
}
要处理海量数据,省钱第一。
17.5.4 设计的前瞻性
这个设计的精妙之处在于:它把"用哪个模型"的决策完全解耦了。
- Server 描述需求
- Client 根据自己实际可用的模型做最优匹配
- 未来出现新的模型、新的提供商,协议本身不需要任何修改
这是一个面向不确定未来的 API 设计——不假设模型名称,只描述能力维度。
17.6 人机审批:安全的核心防线
Sampling 赋予了 Server 通过 Client 调用 LLM 的能力。但这也意味着:一个恶意的 Server 可以构造精心设计的 prompt,通过用户的 Client 生成有害内容,或者通过工具调用执行危险操作。
MCP 规范对此的回答是:人机审批(Human-in-the-Loop)——出于信任与安全考虑,应当(SHOULD)始终有人在回路中,并且能够拒绝 Sampling 请求。
三个审批点
sequenceDiagram
participant Server as MCP Server
participant Client as MCP Client
participant User as 用户
participant LLM as LLM
Server->>Client: sampling/createMessage
rect rgb(255, 240, 230)
Note over Client,User: 审批点 1:审查请求
Client->>User: 展示 Server 的 prompt<br/>允许用户查看和编辑
User-->>Client: 批准 / 修改 / 拒绝
end
Client->>LLM: 转发(可能已被用户修改)
LLM-->>Client: 返回生成结果
rect rgb(240, 240, 255)
Note over Client,User: 审批点 2:工具调用(如果有)
Client->>User: 展示 LLM 要调用的工具<br/>允许用户审批
User-->>Client: 批准 / 拒绝
end
rect rgb(230, 255, 230)
Note over Client,User: 审批点 3:审查响应
Client->>User: 展示 LLM 生成的内容<br/>允许用户审查和编辑
User-->>Client: 批准 / 修改 / 拒绝
end
Client-->>Server: 返回(可能已被用户修改的)结果
规范要求的能力
规范要求应用应当(SHOULD)提供以下能力:
- 请求审查:提供便于审查 Sampling 请求的 UI,允许用户在发送前查看和编辑 prompt
- 响应审查:把生成的响应交给用户审查之后再发回 Server
规范的 SHOULD 清单只有这两类;工具调用审查(LLM 决定调用工具时由用户审批)出现在规范"Sampling with Tools"一节的流程图里,是推荐流程,不在这份 SHOULD 清单中。
如果用户拒绝了 Sampling 请求,Client 应(SHOULD)返回错误码 -1:
{
"jsonrpc": "2.0",
"id": 3,
"error": {
"code": -1,
"message": "User rejected sampling request"
}
}
SHOULD 而非 MUST:务实的权衡
需要注意的是,规范使用的是 SHOULD 而非 MUST——这是一个务实的选择。
在某些自动化场景中(比如 CI/CD 流水线中的 Agent),要求每次 Sampling 都弹出审批窗口并不现实。规范还写明,协议本身不规定任何具体的用户交互模型,实现可以用任何合适的界面形态来暴露 Sampling。
这种"强烈建议最佳实践,容忍特殊场景"的表述方式,是协议规范常见的取舍。
审批 UI 的最佳实践
Client 的审批 UI 设计是用户体验的关键。一些最佳实践:
- 摘要+详情:默认展示 prompt 摘要,点击展开查看完整内容
- 敏感内容高亮:检测到可能的 PII、代码、URL 时高亮显示
- 批量审批模式:受信任的 Server 可以开启"本会话内自动批准"
- 一次拒绝全局生效:用户拒绝一次后,同类请求一段时间内自动拒绝
- 审批日志:记录所有审批历史,便于事后审计
17.7 安全考量:五层防御
Sampling 的安全模型建立在五个层面上:
graph TD
Request[Server 的 Sampling 请求]
Request --> L1{① 能力协商<br/>Client 是否声明支持?}
L1 -->|否| Block[❌ 拒绝]
L1 -->|是| L2{② 人机审批<br/>用户是否批准?}
L2 -->|否| Block
L2 -->|是| L3{③ Client 控制<br/>满足约束?}
L3 -->|否| Block
L3 -->|是| L4{④ 迭代限制<br/>未超循环上限?}
L4 -->|否| Block
L4 -->|是| L5{⑤ 内容验证<br/>消息格式合法?}
L5 -->|否| Block
L5 -->|是| Pass[✅ 执行]
style L1 fill:#dbeafe,stroke:#3b82f6
style L2 fill:#fef3c7,stroke:#f59e0b
style L3 fill:#dcfce7,stroke:#22c55e
style L4 fill:#f3e8ff,stroke:#a855f7
style L5 fill:#fecaca,stroke:#dc2626
第一层:能力协商
Server 只有在 Client 明确声明支持 Sampling 后才能发起请求。Client 可以选择不支持 Sampling,从而完全规避相关风险。
第二层:人机审批
如 17.6 节所述,用户对每次 Sampling 的 prompt 和结果都有审查权。
第三层:Client 的完全控制权
Client 可以:
- 修改 Server 发来的 systemPrompt
- 选择与 Server 建议不同的模型
- 限制 maxTokens 的上限
- 实施请求速率限制(rate limiting)
- 对消息内容进行安全审查和过滤
- 剔除敏感数据
- 注入自己的安全 prompt
第四层:工具调用的迭代限制
当 Sampling 中涉及工具调用时,规范要求 Server 和 Client 都应当(SHOULD)实现循环次数上限,防止 LLM 陷入无限的工具调用循环。规范没有给出具体数值,上限由实现自定;常见做法是:
- 给一次工具循环设一个固定的最大轮数
- 到最后一轮时传
toolChoice: {mode: "none"},让 LLM 输出最终答案(规范原文给出的例子)
第五层:内容验证
双方都应验证消息内容的合法性,包括:
- tool_result 消息是否只包含 tool_result 类型
- tool_use 和 tool_result 是否正确配对
- 消息角色是否符合 user/assistant 的约束(MCP 只有这两种角色,没有
system——系统提示词走systemPrompt字段) - 敏感数据是否被妥善处理
错误码
2025-11-25 版 sampling 规范建议 Client 对常见失败返回以下错误码(SHOULD),后两行是 JSON-RPC 2.0 的通用码,不是 Sampling 专有:
| 错误码 | 含义 |
|---|---|
-1 |
用户拒绝了 Sampling 请求(规范) |
-32602 |
参数无效:请求里缺少 tool_result、tool_result 与其他内容混在一起(规范) |
-32603 |
内部错误(JSON-RPC 通用) |
-32000 到 -32099 |
JSON-RPC 保留给实现自定义的服务端错误区间 |
两个 SDK 都没有为 -1 定义常量,拒绝时由应用自己构造这个错误。实测:TS 的 sampling/createMessage 处理器抛 ProtocolError(-1, ...)、Python 的 sampling_callback 返回 ErrorData(code=-1, ...),Server 端的 createMessage / create_message 都会以 code 为 -1 的错误抛出;Python client 没传 sampling_callback 时,默认回调返回 -32600(INVALID_REQUEST,"Sampling not supported")。
17.8 实战:用 Sampling 实现代码审查 Server
一个实际的 Sampling 用法示例——用 MCP Server 实现代码审查功能:
import { Server } from '@modelcontextprotocol/server';
const server = new Server(
{ name: 'code-reviewer', version: '1.0.0' },
{ capabilities: { tools: {} } } // 不声明 tools 能力,下面注册 tools/call 会直接抛错
);
server.setRequestHandler('tools/call', async (req) => {
if (req.params.name !== 'review_code') {
throw new Error('Unknown tool');
}
const { code, language } = req.params.arguments as { code: string; language: string };
// 通过 Sampling 让 LLM 做代码审查
const result = await server.createMessage({
messages: [{
role: 'user',
content: {
type: 'text',
text: `Review the following ${language} code for bugs, style issues, and potential improvements.
Be specific and actionable.
\`\`\`${language}
${code}
\`\`\``
}
}],
systemPrompt: 'You are a senior software engineer performing code review. Focus on correctness and maintainability.',
maxTokens: 1000,
modelPreferences: {
hints: [{ name: 'claude-3-sonnet' }, { name: 'gpt-4' }],
intelligencePriority: 0.9,
speedPriority: 0.3,
},
temperature: 0.2, // 低 temperature 让审查更稳定
});
return {
content: [{
type: 'text',
// 不带 tools 时 content 是单个块,但可能是 text / image / audio,先收窄
text: result.content.type === 'text' ? result.content.text : '',
}],
_meta: {
modelUsed: result.model,
stopReason: result.stopReason,
}
};
});
// 省略:tools/list 处理器与 server.connect(transport)
这个 Server 完全不持有任何 LLM API Key。用户通过自己的 MCP Client(带着自己的 Claude/OpenAI 账号)连接它,所有 LLM 调用的费用都走用户自己的账号。
17.9 Sampling 的设计意义
理解 Sampling 的设计,需要把它放在更大的架构图景中看。
前后对比
在没有 Sampling 的世界里,MCP Server 本质上是"被动的工具提供者"——Client 问什么它答什么,Client 不问它就沉默。这限制了 Server 的能力上限:除非自备 LLM API Key,它无法实现需要"思考"的复杂逻辑。
有了 Sampling 之后,Server 变成了"有认知能力的智能体"。它可以:
- 自主推理:在执行复杂任务的过程中,调用 LLM 进行中间推理
- 动态决策:根据 LLM 的判断决定下一步操作
- 嵌套 Agent:在一个 MCP 工具调用的内部,启动一轮完整的 LLM 对话
- 多步规划:结合工具调用和 LLM 推理,实现复杂的多步骤任务
开源生态的新范式
更关键的是,这一切都不需要 Server 自己持有任何 LLM 的 API Key。
【传统模式】:
开源 Server 开发者 → 自备 LLM API Key → 为用户代付费用 → 无法持续
【Sampling 模式】:
开源 Server 开发者 → 贡献逻辑 → 用户提供算力 → 双赢
一个开源社区开发者可以发布一个功能强大的 MCP Server,用户只需要用自己已有的 AI 客户端连接它,所有的 LLM 调用都走用户自己的账号。
开发者贡献能力,用户提供算力,协议负责桥接。
这是 MCP 协议设计中最具前瞻性的部分之一。它把 Agent 的"认知能力"从一个需要自建的基础设施,变成了一个可以通过协议按需获取的服务。
产业影响
从产业视角看,Sampling 可能会催生以下变化:
- 开源 MCP Server 门槛降低——没有了 API Key 负担,开发者不必为用户代付模型费用
- AI 产品边界模糊化——Server 的 AI 能力不再绑定特定提供商
- 计费模式标准化——用户对所有 Sampling 统一计费,成本可见
- 模型即商品——Server 不再锁定某个模型提供商,竞争加剧
这些是基于 2025-11-25 设计的推演;如 17.1 节所述,2026-07-28 版已把 Sampling 标为 Deprecated,给出的迁移方向恰恰是 Server 直接对接 LLM 提供商的 API。
17.10 反模式清单
反模式一:滥用 Sampling
现象:本来能用确定性代码做的事,Server 也用 Sampling 让 LLM 做。
问题:浪费用户 API 额度、响应慢、不确定性。
对策:能用 if-else、正则、规则引擎做的,不要用 Sampling。
反模式二:Server 绕过审批
现象:Server 构造复杂的 prompt 试图让用户批准一个看起来无害但实际有害的请求。
对策:Client 应该做 prompt 安全扫描,高亮可疑内容。
反模式三:无限工具循环
现象:Server 不设置工具调用次数上限,LLM 陷入来回调用。
对策:自己设一个最大迭代次数,最后一轮传 toolChoice: {mode: "none"}。
反模式四:忽视 stopReason
现象:Server 不检查 stopReason,即使 LLM 返回 maxTokens 也当作正常结果用。
对策:每次都检查 stopReason,maxTokens/stopSequence 要特别处理。
17.10.1 两个 SDK 的 Sampling 类型设计:Base vs WithTools 双变体的真实实现
§17.4 讨论 Sampling 工具调用——但没有说两个 SDK 在类型层面如何处理"有工具 vs 无工具"两种调用形态。实测两份源码——
TS SDK(packages/server/src/server/server.ts:475-551,节选)——
// 三个 overload 声明 + 一个实现
async createMessage(params: CreateMessageRequestParamsBase, options?: RequestOptions): Promise<CreateMessageResult>;
async createMessage(params: CreateMessageRequestParamsWithTools, options?: RequestOptions): Promise<CreateMessageResultWithTools>;
async createMessage(
params: CreateMessageRequest['params'],
options?: RequestOptions
): Promise<CreateMessageResult | CreateMessageResultWithTools>;
// Implementation
async createMessage(
params: CreateMessageRequest['params'],
options?: RequestOptions
): Promise<CreateMessageResult | CreateMessageResultWithTools> {
// 带了 tools/toolChoice 就本地检查 sampling.tools——不看 enforceStrictCapabilities
if ((params.tools || params.toolChoice) && !this._clientCapabilities?.sampling?.tools) {
throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support sampling tools capability.');
}
// ...(省略:校验最后两条消息的 tool_use / tool_result 配对,不合规抛 ProtocolError(InvalidParams);
// 再按有无 tools 选用 CreateMessageResultWithToolsSchema 或 CreateMessageResultSchema 发请求)
}
对 sampling 能力本身的检查则在 assertCapabilityForMethod(同文件 :276),只有 enforceStrictCapabilities: true 时才在发送前执行(packages/core/src/shared/protocol.ts:816);带 tools 的这一项检查不受该选项影响(实测两种设置下都在本地抛 CAPABILITY_NOT_SUPPORTED,与第 2、9 章一致)。
类型定义(packages/core/src/types/types.ts:546-550)——
/**
* {@linkcode CreateMessageRequestParams} without tools - for backwards-compatible overload.
* Excludes tools/toolChoice to indicate they should not be provided.
*/
export type CreateMessageRequestParamsBase = Omit<CreateMessageRequestParams, 'tools' | 'toolChoice'>;
Base 通过 TypeScript 的 Omit 工具类型显式去掉 tools 和 toolChoice 两字段,而不是把它们留成 optional。效果体现在 overload 选择上:调用方用对象字面量传了 tools,多余属性检查让它匹配不上第一个 overload,TS 转而选第二个,返回类型收窄为 CreateMessageResultWithTools;不传 tools 就命中第一个,得到 CreateMessageResult。要注意反方向的一个漏洞:传入一个类型为 CreateMessageRequest['params'] 的变量时,非字面量不做多余属性检查,它照样能赋给 Base,于是命中第一个 overload、静态类型是 CreateMessageResult,即使运行时带了 tools、拿回的是数组内容(tsc 实测)。用 overload 按参数形态收窄返回类型,和《LangChain 设计与实现》专栏第 8 章 §8.4.4 讲的 @tool 四个 @overload 是同一思路。
Python SDK(src/mcp/types/_types.py:1372-1392)——
class CreateMessageResult(Result):
"""The client's response to a sampling/createMessage request from the server.
..."""
...
class CreateMessageResultWithTools(Result):
"""The client's response to a sampling/createMessage request when tools were provided.
..."""
...
在结果类型上,两个 SDK 其实是同一种做法:都是两个独立类型。TS 是两个 zod schema,即 CreateMessageResultSchema 与 CreateMessageResultWithToolsSchema(packages/core/src/types/schemas.ts:1665-1714);Python 是 CreateMessageResult(src/mcp/types/_types.py:1372)与 CreateMessageResultWithTools(同文件 :1389)两个类;不带 tools 的版本为了向后兼容,content 只能是单个 text / image / audio 块,带 tools 的才允许数组和 tool_use / tool_result。这比 2025-11-25 schema 严格——schema 里 CreateMessageResult 的 content 始终可以是数组。TS client 会按请求里有无 tools/toolChoice 选 schema 校验自己回调的返回值:请求没带 tools 而回调返回数组 content,client 直接回 -32602("Invalid sampling result",packages/client/src/client/client.ts:443,实测)。Python 这两个类都进 ClientResult 联合类型,客户端侧由 src/mcp/client/session.py:98 的 ClientResponse TypeAdapter 统一校验。
返回类型的分支两边也都靠 overload:Python src/mcp/server/session.py 里 create_message 有两个 @overload(:234 / :253,实现在 :272),第一个把参数标成 tools: None = None、第二个标成 tools: list[types.Tool],mypy 据此收窄——作用相当于 TS 的 Omit。真正的差别只在请求参数怎么表达"没有 tools":TS 用类型运算从完整参数类型派生出 Base,Python 直接在 overload 签名里写死 None。运行时两边的本地检查也基本对齐:都会检查 sampling.tools、都校验最后两条消息的 tool_use / tool_result 配对(Python 在 src/mcp/server/validation.py),只是抛出的异常不同——TS 是 SdkError(CAPABILITY_NOT_SUPPORTED) 与 ProtocolError(-32602),Python 是 MCPError(INVALID_PARAMS) 与 ValueError(实测)。
ModelPreferences 在两个 SDK 的字段对齐——4 字段 hints / costPriority / speedPriority / intelligencePriority(TS packages/core/src/types/schemas.ts:1526,Python src/mcp/types/_types.py:1264,Python 字段名为 snake_case)——是 §17.5 那套"hints + 三维优先级"两层抽象在源码层的直接印证。取值范围的校验不对齐:2025-11-25 schema 标注三个 priority 为 0 到 1,TS 用 z.number().min(0).max(1) 校验,Python 只声明 float | None,ModelPreferences(cost_priority=1.5) 能正常构造(实测)。
错误码也不对齐——和 §3.8.3 的对照表结合看:ResourceNotFound(-32002)只有 TS 定义了常量,Python MCPServer 读取未知资源时线上返回的 code 是 0(第 3、6 章实测);Sampling 的"用户拒绝" -1 两个 SDK 都没有常量,由应用自己构造(见 17.7 节)。
17.10.2 Python ClientSession 如何把 Sampling 变成能力
Sampling 的关键不是“服务端能调用模型”,而是客户端愿意代表用户处理这类反向请求。Python SDK 的 ClientSession 把这个边界写得很清楚:src/mcp/client/session.py:110-124 构造函数接受 sampling_callback、elicitation_callback、list_roots_callback 和仅限关键字的可选参数 sampling_capabilities;src/mcp/client/session.py:127-130 如果用户没有传回调,就使用默认回调;src/mcp/client/session.py:148-177 初始化时只有在 sampling callback 不是默认值时才声明 sampling 能力(声明的内容就是 sampling_capabilities,不传则为空的 SamplingCapability()——想接收带 tools 的请求,得显式传 SamplingCapability(tools=SamplingToolsCapability())),elicitation 和 roots 也是同一逻辑。能力不是“SDK 支持某类型”,而是“当前会话真的有处理该类型请求的用户代码”。
schema 对 Sampling 请求本身也施加了几道约束。modelcontextprotocol/schema/2025-11-25/schema.ts:1578-1610 要求 sampling/createMessage params 至少包含 messages 与 maxTokens,可以附带 modelPreferences、systemPrompt、includeContext、temperature、stopSequences 和 provider-specific metadata;modelcontextprotocol/schema/2025-11-25/schema.ts:1611-1621 允许请求携带 tools 与 toolChoice,并规定客户端若没有声明 ClientCapabilities.sampling.tools 却收到这两个字段,MUST 返回错误;modelcontextprotocol/schema/2025-11-25/schema.ts:1639-1647 进一步说明客户端对模型选择有完全裁量权,并应在开始 sampling 前让用户检查请求。
执行路径同样是“能力先行”。src/mcp/client/session.py:424-434 收到 CreateMessageRequest 后,如果 params 带 task 元数据,就走 experimental task handler;否则才调用 _sampling_callback,并用 ClientResponse adapter 校验返回值后 respond。也就是说,服务端不会直接拿到一个裸 LLM API;它拿到的是一个被客户端 policy、UI、人机审批、模型选择与类型校验包裹过的反向调用入口。客户端可以换模型、缩短 maxTokens、拒绝 tools、过滤 includeContext,仍然符合协议精神。
上面 schema 那条 MUST,两个 SDK 的 client 侧都没有替你检查(sampling.tools 的检查只出现在 server 侧),收到带 tools 的请求时要不要报错由回调自己判断。还要记住第 11 章 §11.8.2 的实测:这个回调是在接收循环里直接 await 的(src/mcp/shared/session.py:373),回调没返回之前,这个 session 读不到任何后续消息——等待用户审批期间本端发出的请求拿不到响应;在回调里再对同一个 session 发请求并等待结果会死锁;server 发来的 notifications/cancelled 也要等回调结束才被读到,并不能中途打断它。
这给 Server 设计带来几个硬边界。第一,Sampling 不适合替代普通 tool call:如果服务端只是想查自己的数据库,应该自己实现工具,而不是请求客户端再让模型猜。第二,Sampling 请求要足够短且可审计:messages、systemPrompt、includeContext 都可能暴露上下文,必须让用户能看懂。第三,带 tools 的 Sampling 要格外谨慎,因为它等于让“被服务端请求的模型调用”再次拥有工具使用能力,循环深度、权限与审计都必须由客户端控制。第四,Server 应接受客户端选择不同模型或返回拒绝,而不是把某个模型名当作强制要求。
和《Harness Engineering》专栏第 5 章的工具设计相呼应,Sampling 是一种“反向工具”:调用发起方是 server,但权限最终由 client 和用户掌握。它把模型能力从具体应用中抽离出来,却没有把安全责任外包给 server。理解这点,才能避免把 Sampling 写成“远程帮我调用 LLM”的便利 API,而是把它当成一种带审批、带能力声明、带上下文最小化要求的协议能力。
一个可靠客户端还应把 Sampling 纳入审计:记录发起 server、请求 method、messages 摘要、maxTokens、是否携带 tools、用户是否批准、最终模型名和 stopReason。审计不是为了窥探内容,而是为了在“服务端为什么突然要求模型生成代码”“为什么一次请求消耗过高”“为什么工具循环没有停住”时能复盘。没有审计的 Sampling,等价于给 server 开了一扇看不见的模型调用门。
Server 侧也要把 Sampling 当成稀缺能力:失败要能降级,拒绝要能继续流程,超时要能释放资源。把它设计成必经路径,会让用户审批、模型限流和客户端策略变成服务可用性的单点。
所以默认路径应是“不依赖 Sampling 也能完成核心任务”。
17.11 本章小结
Sampling 是 MCP 协议中最独特的设计之一。它反转了传统的请求方向,让 Server 可以通过 Client 间接调用 LLM,实现了以下关键能力:
- 零密钥架构:Server 无需持有 LLM API Key,消除了密钥管理和安全风险
- 模型选择权归用户:通过 hints 和 priorities 的两层抽象,Server 表达需求,Client 做最终决策
- 工具增强的 Agent 循环:Sampling 中支持工具定义和多轮调用,Server 可以实现完整的 Agent 行为
- 人机审批保障安全:用户对 prompt、工具调用、响应都有审查权,防止恶意 Server 滥用
- 跨提供商兼容:协议设计兼顾了 Claude、OpenAI、Gemini 等主流 LLM API 的差异
- 五层安全防线:能力协商、人工审批、Client 控制、迭代限制、内容验证
- 设想中的生态影响:开源 Server 开发者贡献逻辑,用户提供算力,双赢模式
核心心得:
Sampling 不是功能,是协议哲学的体现——权力始终在用户手中。