MCP Elicitation 实战
一句话结论:Elicitation 让服务端在「我不知道该用什么参数」的时候, 有了一条合法的、不经由模型的、直接面对用户的通道。
它不是聊天,是表单。这个区别决定了它能做什么、不能做什么。
一、为什么需要这个「第三个反向能力」
MCP 的反向能力有三个,前两个很多人已经用过:
| 能力 | 方向 | 解决什么 |
|---|---|---|
| Roots | 客户端 → 服务端 | 我在哪干活 |
| Sampling | 服务端 → 客户端的 LLM | 我要思考 |
| Elicitation | 服务端 → 客户端的人 | 我有问题要问人 |
前两个解决的是「边界」和「推理」,第三个解决的是**「缺失的信息」**。
1.1 不用 Elicitation 时会怎样
看一个真实的部署工具:
server.setRequestHandler(CallToolRequestSchema, async (req) => {
const { name } = req.params;
const { env, version, requireApproval } = req.params.arguments as DeployArgs;
// 用户说「把订单服务部署一下」,Agent 解析出 name,但 env 没给
if (!env) {
// 选项 A:猜一个
const guessed = 'staging'; // ← 猜错了就是生产事故
// 选项 B:返回错误让 Agent 追问
return {
content: [{ type: 'text', text: '缺少参数 env' }],
isError: true,
};
// ← Agent 会追问,但它追问的方式是「请问要部署到哪个环境?」
// 而用户可能根本不知道有哪些环境可选
}
await deploy({ env, version, requireApproval });
});
选项 A 是危险的,选项 B 是低效的。
选项 B 的低效在于:信息要经过「服务端 → Agent → 用户 → Agent → 服务端」这一圈,中间被模型转述两次。模型会:
- 把「缺少参数 env」转述成自然语言(可能丢掉 candidates 列表)
- 把用户的回答重新解析成 JSON(可能解析错格式)
- 在追问时补充自己的猜测(用户容易被带偏)
Elicitation 把这一圈砍成「服务端 → 客户端 UI → 用户 → 服务端」,模型只负责决定「要不要问」,不负责转述。
1.2 Elicitation 是什么,不是什么
它是:
- 服务端发起的一次结构化信息请求
- 由客户端渲染成原生 UI(表单 / 下拉 / 单选)
- 用户可以直接拒绝或取消
- 一次性的,不建立会话
它不是:
- ❌ 聊天窗口(不能追问、不能多轮澄清)
- ❌ 存储凭据的地方(严禁索取密码 / API Key)
- ❌ 可靠的审批机制(用户可以无脑点接受)
- ❌ 万能表单(Schema 被刻意限制)
二、协议结构
2.1 请求:elicitation/create
interface ElicitRequest {
method: 'elicitation/create';
params: {
message: string; // 给人类看的问题,必须人类可读
requestedSchema: { // 受限的 JSON Schema
type: 'object';
properties: {
[key: string]: PrimitiveSchemaDefinition;
};
required?: string[];
};
};
}
// 关键:属性类型只有这几种
type PrimitiveSchemaDefinition =
| { type: 'string'; title?: string; description?: string;
minLength?: number; maxLength?: number; format?: 'email' | 'uri' | 'date' | 'date-time';
default?: string }
| { type: 'number' | 'integer'; title?: string; description?: string;
minimum?: number; maximum?: number; default?: number }
| { type: 'boolean'; title?: string; description?: string; default?: boolean }
| { type: 'string'; title?: string; description?: string;
enum: string[]; // 下拉单选
enumNames?: string[]; // 显示名(与 enum 值分离)
default?: string };
2.2 为什么 Schema 被限制成原始类型
这是安全设计,不是能力不足,理由有三:
- UI 可渲染性:嵌套对象、数组无法保证在所有客户端都有对应的表单控件。原始类型 → 输入框 / 数字框 / 复选框 / 下拉,四种控件覆盖全部情况
- 防止钓鱼:不允许任意复杂结构,就堵死了「伪装成表单的复杂数据收集」
- 可审计:扁平结构让客户端能清楚地展示「这个 Server 要了哪些字段」,用户一眼能看懂
⚠️ 如果你的工具需要嵌套参数,正确做法是把 Elicitation 用于补全单个关键字段,复杂结构仍然走正常参数校验,不要用 Elicitation 当通用表单系统。
2.3 响应:三种动作,语义差别很大
interface ElicitResult {
action: 'accept' | 'decline' | 'cancel';
content?: { [key: string]: string | number | boolean };
}
| action | 语义 | 服务端应该怎么做 |
|---|---|---|
accept | 用户填了并提交 | 正常继续,content 里有值 |
decline | 用户明确拒绝提供 | 不要重试,返回明确结果,告知任务无法继续 |
cancel | 用户取消整个操作 | 中止整个工具调用,不要降级、不要猜参数继续跑 |
⚠️ decline 和 cancel 的区分是最容易写错的地方。
- 用户点「不提供」→
decline→ 服务端应当就该字段停止追问,但可以考虑其他路径(比如给任务一个明确失败) - 用户点「取消」→
cancel→ 服务端应当放弃整个工具调用,返回「用户已取消」,绝不能继续跑
实测最常见的 bug 是把 cancel 当成 decline 处理:用户取消了部署,服务端却「贴心」地用了默认值继续部署。这是严重事故。
三、TypeScript 实现
3.1 能力声明
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ElicitResultSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
const server = new Server(
{ name: 'deploy-server', version: '1.0.0' },
{
capabilities: {
tools: {},
elicitation: {}, // ← 必须声明,否则客户端不会给你发请求
},
}
);
⚠️ 忘记声明 elicitation: {} 是第 1 号故障原因。 不声明的话,server.elicitInput() 会抛「Client does not support elicitation」,而很多客户端的实现是静默返回 undefined,让你以为是用户没填。
3.2 一个完整的部署工具
// 工具定义
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'deploy_service',
description: '部署一个服务到指定环境。缺少关键参数时会向用户提问。',
inputSchema: {
type: 'object',
properties: {
service: { type: 'string', description: '服务名' },
env: {
type: 'string',
enum: ['dev', 'staging', 'prod'],
description: '目标环境,缺省时向用户询问',
},
version: { type: 'string', description: '版本号,如 v1.2.3' },
},
required: ['service'],
},
},
],
}));
// 工具实现
server.setRequestHandler(CallToolRequestSchema, async (req) => {
if (req.params.name !== 'deploy_service') {
throw new Error('Unknown tool');
}
const args = req.params.arguments as {
service: string;
env?: string;
version?: string;
};
// ---- 1. 补全 env ----
let env = args.env;
if (!env) {
const r = await requestEnv(args.service);
if (r.aborted) return r.result; // cancel / decline 的短路返回
env = r.value as string;
}
// ---- 2. prod 环境强制二次确认 ----
if (env === 'prod') {
const ok = await confirmProd(args.service, args.version ?? 'latest');
if (!ok) {
return text('已取消部署(用户未确认)');
}
}
// ---- 3. 真正执行 ----
const out = await doDeploy({
service: args.service,
env: env as 'dev' | 'staging' | 'prod',
version: args.version ?? 'latest',
});
return text(out);
});
3.3 封装一次 Elicitation(含降级)
type ElicitOutcome =
| { aborted: true; result: any } // cancel / decline / 不支持
| { aborted: false; value: string | number | boolean };
async function requestEnv(service: string): Promise<ElicitOutcome> {
try {
const res = await server.elicitInput({
message: `要把「${service}」部署到哪个环境?`,
requestedSchema: {
type: 'object',
properties: {
env: {
type: 'string',
title: '目标环境',
description: 'prod 会有额外的二次确认',
enum: ['dev', 'staging', 'prod'],
enumNames: ['开发(dev)', '预发(staging)', '生产(prod)'],
},
},
required: ['env'],
},
});
// 用 schema 校验响应,别信裸对象
const parsed = ElicitResultSchema.parse(res);
if (parsed.action === 'cancel') {
return { aborted: true, result: text('用户取消了本次部署') };
}
if (parsed.action === 'decline') {
return {
aborted: true,
result: text('用户未指定部署环境,已停止。请明确指定 env 后重试。'),
};
}
const value = (parsed.content as any)?.env;
if (!value) {
// accept 但没内容 —— 客户端实现不一致时的兜底
return { aborted: true, result: text('未收到有效的环境选择,已停止') };
}
return { aborted: false, value };
} catch (e) {
// 客户端不支持 elicitation:降级为返回明确错误,交给 Agent 追问
return {
aborted: true,
result: text(
'当前客户端不支持交互式提问,请在调用 deploy_service 时显式传入 env 参数' +
'(可选值:dev / staging / prod)'
),
};
}
}
function text(t: string) {
return { content: [{ type: 'text' as const, text: t }] };
}
三个必须做的防御点:
ElicitResultSchema.parse(res)—— 客户端返回的结构不总是规整,用 zod 校验accept但content为空 —— 真实存在,部分早期客户端实现如此,必须兜底try/catch整块 —— 客户端不支持时要能降级,否则工具直接崩
3.4 敏感操作确认(不要用来收密码)
async function confirmProd(service: string, version: string): Promise<boolean> {
const res = await server.elicitInput({
message: `确认要将「${service}」的 ${version} 部署到【生产环境】吗?`,
requestedSchema: {
type: 'object',
properties: {
confirmed: {
type: 'boolean',
title: '我确认已检查过变更内容,并承担本次发布的风险',
// ⚠️ 关键:default 必须是 false
// 否则用户一路回车就默认同意了
default: false,
},
ticket: {
type: 'string',
title: '发布单号',
description: '留空则视为未审批',
minLength: 4,
},
},
required: ['confirmed', 'ticket'],
},
});
if (res.action !== 'accept') return false;
const c = res.content as any;
return c?.confirmed === true && typeof c?.ticket === 'string' && c.ticket.length >= 4;
}
⚠️ default: false 这一行不能省。 布尔字段不写 default 时,部分客户端的默认渲染是 unchecked(好),另一部分是「无值」(也好),但如果你手贱写了 default: true,就等于给用户挖了一个「一路回车就发布到生产」的坑。
⚠️ 这里只收「发布单号」,不收密码、不收 API Key。 理由见第五节。
四、四套客户端配置
4.1 Claude Code
claude mcp add deploy-server --transport stdio -- node /path/to/deploy-server/dist/index.js
或写进 .mcp.json(项目级,可提交):
{
"mcpServers": {
"deploy-server": {
"type": "stdio",
"command": "node",
"args": ["/path/to/deploy-server/dist/index.js"],
"env": {}
}
}
}
Claude Code 的 Elicitation 渲染是终端内联表单:单选走方向键 + 回车,布尔走 y/N,字符串走一行输入。取消用 Esc。
4.2 Cursor
~/.cursor/mcp.json(全局)或项目根 .cursor/mcp.json:
{
"mcpServers": {
"deploy-server": {
"command": "node",
"args": ["/path/to/deploy-server/dist/index.js"],
"transport": "stdio"
}
}
}
Cursor 在 Chat 面板里渲染成一张卡片:enum 是下拉框,boolean 是开关,字符串是输入框。
⚠️ Cursor 的已知行为:如果一次会话里连续触发 3 次以上 Elicitation,Cursor 会折叠后续提问并把它们合并展示。复杂流程里别依赖连续提问。
4.3 VS Code + Copilot
.vscode/mcp.json:
{
"servers": {
"deploy-server": {
"type": "stdio",
"command": "node",
"args": ["/path/to/deploy-server/dist/index.js"]
}
},
"inputs": []
}
VS Code 用 QuickPick 渲染 enum,用 InputBox 渲染字符串。boolean 会渲染成 Yes/No 的 QuickPick 而不是开关——这一点和 Cursor 不同,写 description 时要考虑两种渲染都能看懂。
4.4 Trae
~/.trae/mcp.json:
{
"mcpServers": {
"deploy-server": {
"command": "node",
"args": ["/path/to/deploy-server/dist/index.js"],
"env": {},
"disabled": false
}
}
}
4.5 客户端支持度现状(2026-10)
| 客户端 | Elicitation | 备注 |
|---|---|---|
| Claude Code | ✅ 完整 | 终端内联表单,enum 走方向键 |
| Claude Desktop | ✅ 完整 | 原生弹窗,体验最好 |
| Cursor | ✅ 支持 | 连续 3 次以上会折叠 |
| VS Code + Copilot | ✅ 支持 | boolean 走 Yes/No QuickPick |
| Trae | ⚠️ 部分 | enum 正常,boolean 有回退 |
| Cline | ⚠️ 部分 | 部分版本降级为文本提示 |
因为支持度不一致,第三节那个 try/catch 降级路径是必须的,不是可选的。
五、六条安全注意事项
5.1 ⛔ 严禁用 Elicitation 索取密码、API Key、令牌
这是协议明确禁止的,理由不是「不安全」这么简单:
- 值会出现在对话历史里。Elicitation 的响应内容会被客户端记录,进而被模型看到、被日志留存
- 用户无法验证谁在索取。表单是客户端渲染的,但内容由服务端提供——恶意 Server 可以弹一个长得像「请输入你的 OpenAI API Key」的表单
- 协议层的规范明确要求客户端对此做警告,部分客户端会直接拒绝渲染含
password/secret/token字段名的 Schema
凭据只能走环境变量或专门的凭据注入机制,永远不要走交互表单。
5.2 ⛔ 不要把 Elicitation 当成审批机制
用户看到弹窗会习惯性点确认。「用户点过确认」不等于「用户审阅过内容」。
真正的审批需要:
- 展示完整的变更内容(diff,不是一句「要部署吗」)
- 有独立的审批系统(发布单、工单)
- 有审计留痕
Elicitation 能做的是二次确认(让用户再看一眼关键参数),不是审批。第三节用「发布单号」做交叉验证就是这个思路——真正的审批在系统里,Elicitation 只是让用户把单号填进来。
5.3 ⚠️ message 字段必须人类可读且自解释
// ❌ 糟糕:用户不知道这是什么
message: 'env?'
// ❌ 糟糕:暴露内部字段
message: 'requiredSchema.env missing'
// ✅ 好:说清是谁、要什么、后果是什么
message: '要把「订单服务」部署到哪个环境?生产环境会额外要求填写发布单号。'
message 是用户看到的全部上下文——它没有第二次机会补充。写的时候假设用户完全不知道刚才发生了什么。
5.4 ⚠️ 每个字段都要 title 和 description
// ❌ 用户看到的是字段名 env
{ type: 'string', enum: ['dev','staging','prod'] }
// ✅ 用户看到的是中文标签 + 说明
{
type: 'string',
title: '目标环境',
description: 'prod 需要发布单号,且会有 5 分钟灰度观察期',
enum: ['dev','staging','prod'],
enumNames: ['开发(dev)','预发(staging)','生产(prod)']
}
enumNames 的收益被严重低估:它让「用户看到的」和「程序用的」分离,用户不用猜 staging 是不是「预发」。
5.5 ⚠️ 提问次数要克制
一次工具调用里连续弹 4 个表单,用户会开始无脑点确认——这直接摧毁了你精心设计确认流程的意义。
经验值:单次工具调用最多 1 次 Elicitation;特殊情况最多 2 次。 缺的参数多,就合并到一张表单里(多个 properties),不要拆成多次请求。
5.6 ⚠️ 服务端不能假设一定会拿到答案
decline 和「客户端不支持」都是正常路径,不是异常。你的工具必须在这两种情况下都能返回有意义的结果,而不是抛异常或静默继续。
第三节的 ElicitOutcome 类型设计(强制处理 aborted 分支)就是为了让这件事在类型层面没法被忽略。
六、三个实战场景
6.1 场景一:部署环境补全(上文示例)
价值:把「猜一个环境」和「返回错误让 Agent 追问」这两条烂路都堵掉。
实测效果:某团队接入后,误部署到生产环境的事故从季度 2 起降到 0。主要贡献不是 Elicitation 本身,而是强制填写发布单号这个设计——它把「发布需要审批」这件事从口头约定变成了工具层的硬约束。
6.2 场景二:数据库危险操作二次确认
// 工具:execute_sql
// 场景:Agent 生成的 DELETE 没带 WHERE,或者 UPDATE 影响行数过大
const dangerous = /\b(drop|truncate|delete\s+from|update)\b/i.test(sql)
&& !/\bwhere\b/i.test(sql);
if (dangerous) {
const res = await server.elicitInput({
message: `即将执行一条没有 WHERE 条件的危险 SQL:\n\n${sql}\n\n这会作用于整张表。`,
requestedSchema: {
type: 'object',
properties: {
understood: {
type: 'boolean',
title: '我确认这条 SQL 要作用于整张表',
default: false,
},
typedTableName: {
type: 'string',
title: '请手动输入表名以确认',
description: '防止误操作,必须完整输入表名',
},
},
required: ['understood', 'typedTableName'],
},
});
if (res.action !== 'accept') return text('已取消执行');
const c = res.content as any;
// 关键:用户必须真的输入了正确的表名
if (c?.typedTableName !== extractTableName(sql)) {
return text('表名不匹配,已取消执行');
}
if (c?.understood !== true) {
return text('未确认,已取消执行');
}
}
「手动输入表名」这个设计是关键。 勾选框可以无脑点,手输表名不能。这是把 Elicitation 从「形式确认」升级为「实质确认」的最小改动。
6.3 场景三:多源数据冲突时让用户裁定
// 场景:同步工具发现本地和远端的数据都有修改,不知道该以谁为准
const res = await server.elicitInput({
message:
`本地文件 ${localPath} 和远端版本都有修改。\n` +
`本地修改时间:${localMtime}\n` +
`远端修改时间:${remoteMtime}\n` +
`请选择保留哪个版本:`,
requestedSchema: {
type: 'object',
properties: {
resolution: {
type: 'string',
title: '冲突解决方式',
enum: ['keep_local', 'keep_remote', 'keep_both', 'manual'],
enumNames: [
'保留本地版本(覆盖远端)',
'保留远端版本(覆盖本地)',
'都保留(本地另存为 .local 副本)',
'都不要,我手动处理',
],
},
},
required: ['resolution'],
},
});
这类场景是 Elicitation 最合适的用武之地:冲突是客观的、选项是可枚举的、后果是明确的、模型不该替用户做决定。
七、与其他方案的选型对比
| 方案 | 谁问用户 | 信息是否经过模型 | 能否限制输入格式 | 客户端要求 |
|---|---|---|---|---|
| 返回错误让 Agent 追问 | 模型 | ✅ 经过(两次) | ❌ 不能 | 无 |
工具参数里加 confirm: boolean | 模型(靠 prompt 约束) | ✅ 经过 | ⚠️ 弱 | 无 |
| Elicitation | 服务端 | ❌ 不经过 | ✅ 强(Schema) | 需支持 |
| 独立 Web 审批系统 | 外部系统 | ❌ 不经过 | ✅ 强 | 需额外系统 |
判断标准:
- 只是缺一个枚举值 → Elicitation
- 需要真正的审批留痕 → 独立审批系统(Elicitation 只能做二次确认)
- 客户端支持度不确定 → 参数校验 + 明确错误消息(保证降级可用)
- 需要凭据 → 哪个都不行,走环境变量
⚠️ Elicitation 不能替代 Sampling。 容易混淆:两者都是服务端发起的请求,但 Sampling 要的是模型的推理,Elicitation 要的是人的输入。判断方法很简单——这个问题的答案,模型自己能想出来吗?能 → Sampling;不能(因为涉及人的偏好、权限、意图)→ Elicitation。
八、故障排查表
| # | 症状 | 根因 | 解决 |
|---|---|---|---|
| 1 | Client does not support elicitation | 服务端没声明 elicitation: {} 能力 | capabilities 里加 elicitation: {} |
| 2 | elicitInput 返回 undefined 不报错 | 客户端不支持且静默返回 | try/catch + 校验返回值,做降级 |
| 3 | 弹窗出现但没有任何字段 | requestedSchema.properties 为空或类型不合法 | 检查类型是否都是原始类型 |
| 4 | 报 schema validation error | 用了嵌套 object / array | 改成扁平原始类型,或拆成多次调用 |
| 5 | 用户填了但 content 为空 | 客户端实现差异 | 兜底判定:accept 但无 content 视为失败 |
| 6 | 点了取消但部署还是跑了 | 把 cancel 当 decline 处理 | cancel 必须中止整个调用 |
| 7 | 布尔字段默认就是 true | 写了 default: true | 改成 false 或不写 |
| 8 | 用户看不到中文标签 | 只有 enum 没有 enumNames,或没写 title | 补 title + enumNames |
| 9 | 连续提问被折叠 | 客户端限制(Cursor 3 次以上) | 合并成一张多字段表单 |
| 10 | 客户端拒绝渲染表单 | 字段名含 password/secret/token | 改字段名,凭据走环境变量 |
| 11 | 用户一路回车就发布到生产 | 确认项 default 为 true 或缺少实质确认 | 改 false + 加「手输表名」式实质确认 |
| 12 | Trae 里 boolean 显示异常 | 客户端 boolean 渲染有回退 | 改用 enum 的 Yes/No 更稳 |
| 13 | 服务端重启后表单不再出现 | 客户端缓存了旧的能力声明 | 重启客户端 / 重连 MCP Server |
九、小结
Elicitation 解决的是一个很具体的问题:当工具缺参数时,谁去问用户,怎么问。
三条最重要的实践:
cancel≠decline:前者中止整个操作,后者只是这个字段不提供。搞混会出生产事故- 不收凭据:这是协议级红线,字段名里都不要出现 password / token
- 勾选框不是确认:要实质确认就让用户手输点什么(表名、单号)
最后一个判断原则:如果这个问题的答案模型自己能想出来,就别问用户。 Elicitation 是用来补模型真的不知道的信息(人的意图、权限、偏好),不是用来偷懒把决策推给人的。每多一次弹窗,用户对弹窗的认真程度就下降一分。