传统工具调用是一问一答:Agent 发起 tools/call,服务器执行完毕返回结果。但只要工具涉及「缺一个参数就跑不下去」的场景,这个单向模型就断了——比如部署工具需要用户确认目标环境,订票工具需要用户从三个航班里选一个,配置工具需要用户填一段只有他知道的 Webhook 地址。让 LLM 猜?它只能编。让服务器自己弹窗?MCP 服务器通常是 stdio 或远程进程,根本没有 UI 通道。
Elicitation(征询)就是为了补上这块拼图:它允许服务器在执行过程中,通过客户端向真实用户索取一段结构化输入,拿到结果后继续把工具跑完。本文要回答的是:Elicitation 的请求长什么样、requestedSchema 能用哪些字段类型、三态返回如何影响服务器分支、哪些信息绝对不能用它来收集、以及客户端在 UI 与超时上要做哪些兜底。
1. Elicitation 解决什么问题
1.1 单向工具调用的缺口
Agent: tools/call deploy_service { service: "api", env: ? }
↑ 环境是"生产"还是"预发",LLM 无从得知
三条常见出路各有代价:
| 出路 | 问题 |
|---|---|
| 让 LLM 猜默认值 | 猜错就是生产事故 |
| 参数缺失直接报错 | 把确认责任推回给 Agent,多一轮往返且语义模糊 |
| 工具自己弹窗 | stdio 服务器无 UI,远程服务器更不可能 |
Elicitation 提供第四条路:服务器在 handler 内部挂起,请求客户端弹一个表单给用户,用户提交后服务器拿值继续执行,整个过程对 Agent 只表现为「这次 tools/call 稍微慢了点」。
1.2 与 Sampling、Roots 的方向对比
| 能力 | 方向 | 目的 | 用户是否直接参与 |
|---|---|---|---|
| Tools | Agent → 服务器 | 执行操作 | 否 |
| Sampling | 服务器 → 客户端 | 借 LLM 生成补全 | 间接(审批) |
| Elicitation | 服务器 → 客户端 | 借用户提供结构化输入 | 是 |
| Roots | 服务器 → 客户端(查询) | 了解工作区边界 | 否 |
一句话区分:Sampling 是「服务器借 LLM 的脑子」,Elicitation 是「服务器借用户的输入」。前者补的是推理,后者补的是只有人知道的事实。
2. 协议形态:elicitation/create
2.1 请求结构
服务器发起 elicitation/create 请求,params 由三部分组成:message(给用户看的人话)、requestedSchema(要收集的字段)、以及可选的 _meta。
{
"method": "elicitation/create",
"params": {
"message": "部署到生产环境前,请确认目标集群与变更窗口",
"requestedSchema": {
"type": "object",
"properties": {
"cluster": {
"type": "string",
"title": "目标集群",
"enum": ["prod-shanghai", "prod-beijing", "staging"]
},
"window": {
"type": "string",
"title": "变更窗口",
"description": "格式 HH:MM-HH:MM,24 小时制"
},
"confirm": {
"type": "boolean",
"title": "我已知晓变更影响",
"default": false
}
},
"required": ["cluster", "confirm"]
}
}
}
2.2 三态响应
客户端的返回不是「成功/失败」,而是三态:
// 接受:用户填了值
{ "action": "accept", "content": { "cluster": "prod-shanghai", "window": "02:00-03:00", "confirm": true } }
// 拒绝:用户明确表示不同意
{ "action": "decline" }
// 取消:用户关掉了表单 / 超时 / 无法展示
{ "action": "cancel" }
三态的语义差别是故意的:decline 是「我不愿意」,cancel 是「我没做决定」。服务器必须分别处理——拒绝通常意味着终止整个工具流程并向上汇报;取消则可能允许重试或降级到安全默认值。把它们合并成 success: false 是最常见的实现错误。
2.3 服务器端实现
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
server.registerTool(
"deploy_service",
{ description: "部署服务到指定集群", inputSchema: { service: z.string(), env: z.string() } },
async ({ service, env }, extra) => {
const res = await extra.server.elicitInput({
message: `即将把 ${service} 部署到 ${env},请确认目标集群与窗口`,
requestedSchema: {
type: "object",
properties: {
cluster: { type: "string", enum: ["prod-shanghai", "prod-beijing", "staging"] },
window: { type: "string" },
confirm: { type: "boolean", default: false },
},
required: ["cluster", "confirm"],
},
});
if (res.action !== "accept") {
return { content: [{ type: "text", text: `部署已${res.action === "decline" ? "被拒绝" : "取消"}` }], isError: true };
}
if (!res.content.confirm) {
return { content: [{ type: "text", text: "用户未勾选确认项,部署中止" }], isError: true };
}
return { content: [{ type: "text", text: await doDeploy(service, env, res.content) }] };
}
);
要点:Elicitation 是服务器侧发起的请求,所以它要求客户端在 initialize 时声明 elicitation 能力;服务器也必须声明自己会用(在能力协商里由客户端告知支持与否)。
3. requestedSchema 的受限子集
MCP 没有直接放开完整 JSON Schema,而是规定了一个扁平、无嵌套的子集。原因很实际:客户端要用通用 UI 渲染这张表单,嵌套对象和 $ref 会让渲染器复杂度爆炸,也会让「用户在 30 秒内填完」变得不现实。
3.1 允许的字段类型
| 类型 | 约束关键字 | UI 映射 |
|---|---|---|
string | enum、minLength、maxLength、pattern、format | 文本框 / 下拉 / 日期选择 |
number / integer | minimum、maximum | 数字输入或滑块 |
boolean | default | 复选框 |
array(仅 enum 项) | items.enum、minItems、maxItems | 多选列表 |
3.2 明确禁止的用法
- 嵌套 object(properties 里再放 properties)
- $ref / allOf / oneOf / anyOf
- 动态默认值、远程 options 加载
- 服务端预填敏感值(如已存在的 API Key)
3.3 用 format 表达语义
{
"email": { "type": "string", "format": "email", "title": "通知邮箱" },
"ticketUrl": { "type": "string", "format": "uri", "title": "工单链接" },
"startAt": { "type": "string", "format": "date-time", "title": "开始时间" }
}
format 是给客户端 UI 的提示(用日期选择器、做邮箱校验),但服务器不能依赖它做校验——客户端可以不支持,也可以被恶意实现绕过。真正的校验必须在服务器 handler 里再做一遍。
4. 能力协商与降级
4.1 客户端能力声明
// initialize 响应中客户端声明
{
"capabilities": {
"elicitation": { "form": {}, "url": {} }
}
}
服务器在 initialize 时记录客户端是否声明 elicitation。未声明时调用 elicitInput 会得到 MethodNotFound 错误,因此健壮的服务器必须先查能力再决定路径。
4.2 无 Elicitation 时的降级策略
| 策略 | 做法 | 适用 |
|---|---|---|
| 参数化 | 把必填项提升为工具入参,让 Agent 先问用户 | 参数数量少、Agent 侧已有交互 |
| 安全默认 | 使用只读/预发等保守默认值 | 默认值不会造成破坏 |
| 硬失败 | 直接返回 isError 并说明缺失信息 | 生产变更等高风险操作 |
| 双通道 | 有 elicitation 用表单,无则返回待填清单 | 需要兼容多客户端 |
const canElicit = extra.server.getClientCapabilities()?.elicitation != null;
if (!canElicit) {
return {
content: [{ type: "text", text: "当前客户端不支持交互征询,请以 cluster/window 参数重新调用" }],
isError: true,
};
}
4.3 超时与取消
Elicitation 是阻塞式的:服务器 handler 在等待用户响应期间不会推进。这意味着必须设超时。
建议基线:
表单类征询 60s(用户需要阅读与选择)
确认类征询 30s
超时后客户端返回 action: "cancel"
服务器收到 cancel → 走降级路径,不要无限等待
如果工具本身有外层超时(见 https://plumephp.com/mcp-tool-call-reliability/ 中的超时预算章节),征询耗时必须计入总预算,否则会出现「用户刚点提交,工具已被客户端判超时取消」的竞态。
5. 安全与边界
Elicitation 的定位是「收集非敏感的补充信息」。这不是建议,而是规范级的约束。
5.1 禁止收集的信息
- 密码、API Key、访问令牌、私钥
- 完整信用卡号、身份证号、社保号
- 任何"用户不该在第三方工具表单里输入"的凭据
原因很直接:Elicitation 表单由服务器定义的 schema 渲染,用户无法分辨这是官方客户端的表单还是某个恶意服务器的钓鱼表单。凭据一旦进入工具返回值,就等于直接交给了服务器。凭据的正确获取方式是走带用户授权的 OAuth 流程,可参考 OAuth 2.0 与 JWT 安全实践 ;MCP 侧的完整授权链路见 https://plumephp.com/mcp-oauth-authorization-session/。
5.2 服务器侧的四道检查
| 检查 | 说明 |
|---|---|
| 来源可信 | 只对已授权/已信任的服务器发起征询 |
| 内容最小 | message 不泄露其他用户数据、内部主机名 |
| 频率限制 | 单次工具调用内征询次数设上限(如 3 次),防表单轰炸 |
| 结果校验 | content 回来后按 schema 再校验一遍类型与取值范围 |
5.3 客户端侧的责任
客户端是唯一能保护用户的环节,至少要提供:
1. 明确标注发起方——"服务器 X 请求你提供以下信息"
2. 展示 requestedSchema 的 title/description 原文,不做美化误导
3. decline / cancel 按钮始终可见,且默认焦点不在"接受"上
4. 表单内容不进日志、不进遥测、不进模型上下文
5. 用户可对某服务器永久关闭 elicitation
第 4 条最容易被忽略:很多客户端会把交互记录一起塞进 LLM 上下文做「记忆」,这会让用户填的邮箱、工单号变成模型上下文的一部分,进而出现在后续的补全里。
5.4 URL 模式
除了表单模式,规范还定义了 URL 模式:服务器给出一个 URL,用户在浏览器里完成交互(如 OAuth 授权、支付确认),客户端只拿到「完成/未完成」。判断标准很简单:
能用扁平表单收集的 → 表单模式
需要在外部站点完成、或涉及凭据的 → URL 模式
6. 交互设计实践
6.1 message 怎么写
message 是唯一由服务器完全控制的用户可见文本,写法直接决定用户能否做对决策。
差:请提供部署参数
好:即将把 payment-api v2.3.1 部署到生产集群,
请选择目标集群并确认变更窗口(影响约 1200 QPS)
差:请输入值
好:检测到 3 个候选工单,请选择要关联的工单编号
要素:对象(对什么做)、动作(做什么)、影响(后果多大)、期望输入(要什么)。
6.2 字段设计
{
"environment": {
"type": "string",
"title": "环境",
"enum": ["staging", "prod"],
"description": "prod 会立即影响线上流量"
},
"reason": {
"type": "string",
"title": "变更原因",
"minLength": 10,
"maxLength": 200,
"description": "将写入审计日志"
}
}
三条经验:枚举优于自由文本(减少拼写错误与校验成本)、必填项控制在 3 个以内、description 里写清后果(它会显示在字段下方)。
6.3 与审计的衔接
Elicitation 天然是决策点,把它的结果写进审计日志能解决「谁批准了这次生产变更」的追溯问题:
audit_record = {
tool: "deploy_service",
elicitation: {
requested_at, responded_at, action,
fields: ["cluster", "window", "confirm"], // 只记字段名,不记值
user: "<客户端提供的身份>"
}
}
只记字段名与动作,不记值——值可能包含业务敏感信息,而审计需要的是「有人批准了」这个事实。更完整的治理要求(RBAC、审批链、策略即代码)见 https://plumephp.com/mcp-enterprise-governance/,面向提示注入的输入侧防护可参考 LLM 护栏与提示注入防护 。
7. 常见陷阱
| 陷阱 | 症状 | 解决 |
|---|---|---|
| 合并 decline 与 cancel | 用户取消被当成拒绝,误终止 | 分别处理,cancel 走降级 |
| 未查客户端能力 | MethodNotFound 直接抛出 | 先读 capabilities 再决定路径 |
| 用 elicitation 收凭据 | 凭据落到服务器侧 | 改用 OAuth / URL 模式 |
| 无超时 | handler 永久挂起,连接被占死 | 客户端设超时,服务器设外层预算 |
| schema 嵌套对象 | 客户端渲染失败或降级为 JSON 文本框 | 保持扁平,拆成多次征询 |
| 结果不校验 | 恶意客户端返回越界值 | 服务器侧按 schema 二次校验 |
| 交互记录进上下文 | 用户输入被模型复述 | 客户端隔离交互数据 |
8. 小结
Elicitation 的价值可以概括为「把只有人知道的事实,在人还在场的时候问出来」:
| 层面 | 要点 |
|---|---|
| 协议 | elicitation/create 请求 + requestedSchema 扁平子集 |
| 三态 | accept 取值、decline 拒绝、cancel 未决,语义不可合并 |
| 边界 | 只收非敏感补充信息,凭据走 OAuth |
| 降级 | 无能力时参数化 / 安全默认 / 硬失败三选一 |
| 预算 | 征询耗时计入工具总超时,客户端设交互超时 |
| 治理 | 结果入审计(记字段名与动作,不记值) |
它补上的是 MCP 里唯一一条「服务器 ↔ 用户」的直连通道。用好它的关键是克制:只问必须问的、只收不该敏感的、把拒绝与取消当成正常路径而不是异常。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。