传统 MCP 工具的所有参数必须在调用前由模型一次性想清楚——但现实中很多决定要等工具跑起来才知道:扫描出 120 个待删文件后,才该问"确定吗?";查到生产库连接串后,才该问"真要执行这条 DROP 吗?"。Elicitation(征询) 是 MCP 在 2025-06-18 规范中新增的第四大原语,让服务器在工具执行中途反向向人类用户提问,拿到结构化答复后再继续。本教程从零构建一个"删除前必须人工确认"的安全操作 MCP 服务器,覆盖标量、受限选项、结构化响应、多轮提问与三种测试路径。适合已经会用 @mcp.tool 写工具、想进阶到"人在回路"(human-in-the-loop)的开发者。
tools / resources / prompts 服务器;理解 JSON Schema。uv 管理虚拟环境(pip install uv 或 curl -LsSf https://astral.sh/uv/install.sh | sh)。很多人第一次接触会把它和"工具参数"或"Sampling"混淆。先把三者的边界画清楚,否则后面写的代码会"能跑但用错地方"。
| 维度 | 工具参数 Parameters | Sampling 采样 | Elicitation 征询 |
|---|---|---|---|
| 谁发起 | 客户端/模型 | 服务器 | 服务器 |
| 问谁 | 不问,直接传值 | 问 LLM/模型(要一段生成文本) | 问 人类用户(要结构化输入) |
| 时机 | 调用前 | 工具执行中 | 工具执行中 |
| 典型场景 | delete(path) 的 path |
服务器让模型总结一段日志 | 删 120 个文件前问"确定吗?" |
Elicitation 的杀手级场景:模型的"决定"必须基于运行时才能拿到的事实。模型在调用 safe_delete_files("/tmp/junk", "*.log") 时,根本不知道这个目录下到底有 3 个还是 3000 个文件——这个事实只有工具跑起来扫描后才知道。于是"删不删"这个决定,天然属于工具运行中的征询,而不是调用前的参数。
明白这一点,你就知道哪些需求不该用 elicitation:纯确定性的输入(路径、阈值)继续走参数;需要让模型"思考/生成"的走 sampling。只有"需要真人拍板且信息在运行中才浮现"的,才用 elicitation。
Elicitation 在底层 MCP SDK 上要自己处理 elicitation/create 请求与 JSON Schema,代码量不小。FastMCP 2.x 把它封装成了一个 await ctx.elicit(...) 调用,是最省事的实现路径。
# 1. 建项目目录
mkdir safe-ops-mcp && cd safe-ops-mcp
uv init .
uv venv
uv add fastmcp # 会同时装好 mcp 官方 SDK、pydantic、starlette 等
预期结果:uv add fastmcp 解析安装约 60+ 个包,其中关键是 fastmcp(≥ 2.12)和 mcp(官方 SDK,≥ 1.14)。看到 + fastmcp==2.x 与 + mcp==1.x 即成功。
常见问题:
- 报 fastmcp 找不到 elicit 属性:版本太老。Elicitation 是 FastMCP 2.10+ 引入的,确认 uv pip show fastmcp 版本号 ≥ 2.12。
- Python 版本过低:FastMCP 2.x 需要 Python ≥ 3.10,但 match/case 与新式泛型在 3.11+ 表现最好,建议直接 3.12。
先建一个最小可跑的骨架,体验标量(scalar)与受限选项(constrained options)两种最常用形态。新建 safe_ops_server.py:
from fastmcp import FastMCP, Context
from fastmcp.server.elicitation import (
AcceptedElicitation, DeclinedElicitation, CancelledElicitation,
)
mcp = FastMCP("Safe Ops Server")
@mcp.tool
async def greet(ctx: Context) -> str:
"""演示标量征询:问用户名字。"""
result = await ctx.elicit("你叫什么名字?", response_type=str)
# 用类型化的结果类做模式匹配,最稳
match result:
case AcceptedElicitation(data=name):
return f"你好,{name}!"
case DeclinedElicitation():
return "你选择了不提供信息。"
case CancelledElicitation():
return "操作已取消。"
操作说明:
- ctx: Context 是 FastMCP 注入的上下文,elicit() 是它的异步方法,只能在工具函数内调用。
- response_type=str 告诉客户端:期望一个字符串。底层 FastMCP 会自动把它包成 MCP 规范要求的对象 schema({"value": <string>}),再把回传的对象"拆包"成裸标量放进 result.data——你拿到的就是 "张三" 而不是 {"value": "张三"}。
- 返回值是个带 .action(accept / decline / cancel)和 .data 的对象。用 AcceptedElicitation 等类型化类做 match/case 比字符串比较 result.action == "accept" 更不容易写错。
常见问题:
- 报 elicitation is not supported:说明你接的客户端没实现 elicitation handler。这是预期的——本步骤先用步骤 4 的自带客户端测,先别接 Claude Code。
- 想限制选项怎么办? 把 response_type=str 换成受限形态(下一步实战里会用到):
- 字面量:response_type=["delete", "dry-run", "cancel"] 或 Literal["delete","dry-run","cancel"]
- 枚举:response_type=SomeEnum
- 多选:response_type=[["bug","feature","docs"]](注意是"列表的列表")
- 带中文标签的选项:response_type={"low": {"title": "低优先级"}, ...}
真实的安全工具往往要问好几轮,且第二轮要多个字段。MCP 规范规定 elicitation 的结构化响应只能是"浅对象 + 标量/枚举属性"(string / number / boolean / enum),不支持嵌套。FastMCP 允许你用 dataclass / TypedDict / Pydantic BaseModel 来描述这个浅对象:
from dataclasses import dataclass
@dataclass
class ConfirmDelete:
confirm: bool # 是否确认删除(标量 ✓)
reason: str # 删除原因,留作审计(标量 ✓)
# 注意:不能放嵌套对象/列表,规范不支持
字段还能带默认值(客户端会预填表单),用 Pydantic 时写 Field(default="...", description="...") 即可。这个 ConfirmDelete 会在步骤 4 的实战里作为"真正删除前的二次确认"出场。
Elicitation 的难点不在服务器,而在客户端必须实现征询处理器。下面给三条路径,从"100% 能跑"到"接入真实 AI 客户端"。
新建 client.py,它把服务器当子进程拉起,并自己实现一个命令行版的征询处理器:
import asyncio
from fastmcp import Client
from fastmcp.client.elicitation import ElicitResult
from fastmcp.client.transports import StdioTransport
async def elicitation_handler(message, response_type, params, ctx):
print("\n🔔 服务器向你提问:")
print(message)
# 受限选项:列出序号让用户选
if isinstance(response_type, list) and response_type:
opts = response_type
for i, o in enumerate(opts, 1):
print(f" {i}. {o}")
idx = int(input("选择序号: ")) - 1
return ElicitResult(action="accept", content={"value": opts[idx]})
# 结构化响应(dataclass/BaseModel):逐字段问
# FastMCP 会把 schema 拍平,这里简化为按字段名提示
if hasattr(response_type, "__annotations__"):
fields = response_type.__annotations__
data = {}
for name, typ in fields.items():
raw = input(f"{name} ({typ.__name__}): ").strip()
if typ is bool:
data[name] = raw.lower() in ("y", "yes", "true", "1")
elif typ is int:
data[name] = int(raw)
else:
data[name] = raw
return ElicitResult(action="accept", content=data)
# 标量
raw = input("你的回答: ").strip()
if response_type is int:
return ElicitResult(action="accept", content={"value": int(raw)})
return ElicitResult(action="accept", content={"value": raw})
async def main():
transport = StdioTransport(command="uv", args=["run", "safe_ops_server.py"])
client = Client(transport, elicitation_handler=elicitation_handler)
async with client:
res = await client.call_tool("greet", {})
print("\n=== 工具返回 ===")
print(res.content[0].text)
asyncio.run(main())
运行:
uv run client.py
预期结果:终端打印 🔔 服务器向你提问:你叫什么名字?,你输入 张三,回车后看到 === 工具返回 === 你好,张三!。这条路径不依赖任何外部客户端是否支持 elicitation,是排查问题的安全网。
关键细节:返回给服务器时,标量要包成 {"value": ...},结构化对象要按字段名给 dict。这是客户端侧最容易踩的坑。
npx @modelcontextprotocol/inspector uv run safe_ops_server.py
浏览器打开它给的本地地址,左侧选 Tools → greet → Run Tool。较新版本的 Inspector 会把服务器的 elicitation schema 渲染成一个表单弹窗。注意:早期 Inspector 不支持 elicitation,点了 Run 会一直转圈或报 unsupported——遇到这种情况回到路径 A。
# 方式一:命令行注册
claude mcp add safe-ops -- uv run /绝对路径/safe_ops_server.py
# 方式二:项目级 .mcp.json
.mcp.json 写法:
{
"mcpServers": {
"safe-ops": {
"command": "uv",
"args": ["run", "/绝对路径/safe_ops_server.py"]
}
}
}
注册后 claude mcp list 能看到 safe-ops,在对话里让 Claude "运行 greet 工具"即可。客户端能力确认:Elicitation 是较新的能力,部分客户端/版本尚未实现;若客户端不支持,ctx.elicit() 会抛 elicitation is not supported。Claude Code 在新版本中支持把征询请求转成对用户的提问——若你的版本报不支持,先用路径 A 验证服务器逻辑无误,再升级客户端。
用户不是每次都会乖乖回答。三种 action 必须都接住,否则一个 cancel 可能让工具带着空数据继续跑下去,造成事故:
match result:
case AcceptedElicitation(data):
... # 正常往下走
case DeclinedElicitation():
return "⏭️ 用户拒绝提供,按安全策略中止。" # 用户主动说"不"
case CancelledElicitation():
return "🛑 用户取消整个操作。" # 用户关掉弹窗/Esc
安全铁律:只要不是 AcceptedElicitation,就绝不进入任何有副作用的分支(删除、发请求、写库)。把这条写进肌肉记忆,是"会问人的工具"区别于"会闯祸的工具"的关键。
把前面所有片段拼成一个完整、可运行的项目。它的工具 safe_delete_files 会:扫描目录 → 征询选模式(删/演练/取消)→ 若选删,再用结构化表单二次确认并要删除原因 → 才真正 unlink。
safe_ops_server.py 完整代码:
from dataclasses import dataclass
from pathlib import Path
from fastmcp import FastMCP, Context
from fastmcp.server.elicitation import (
AcceptedElicitation, DeclinedElicitation, CancelledElicitation,
)
mcp = FastMCP("Safe Ops Server")
@dataclass
class ConfirmDelete:
"""结构化二次确认:MCP 规范只支持浅对象+标量属性,这里两个字段都合规。"""
confirm: bool
reason: str
@mcp.tool
async def safe_delete_files(ctx: Context, directory: str, pattern: str = "*") -> str:
"""递归删除 directory 下匹配 pattern 的文件;执行真正删除前必须经用户确认。"""
root = Path(directory).expanduser()
if not root.is_dir():
return f"❌ 目录不存在: {directory}"
# 运行时才知道的事实:到底匹配到多少文件
targets = [p for p in root.rglob(pattern) if p.is_file()]
if not targets:
return "✅ 没有匹配的文件,无需操作。"
preview = ", ".join(p.name for p in targets[:3])
await ctx.info(f"扫描完成:在 {directory} 下匹配到 {len(targets)} 个文件(如 {preview}...)")
# —— 第一轮:选模式(受限选项)——
mode = await ctx.elicit(
message=(
f"在 {directory} 下找到 {len(target_count)} 个匹配 '{pattern}' 的文件。\n"
f"样本: {preview}\n请选择操作模式:"
),
response_type=["delete", "dry-run", "cancel"],
)
if not isinstance(mode, AcceptedElicitation):
return "🛑 已取消。"
if mode.data == "cancel":
return "🛑 用户取消。"
# —— dry-run 分支:只预览,零副作用 ——
if mode.data == "dry-run":
lines = "\n".join(str(p) for p in targets[:20])
more = "" if len(targets) <= 20 else f"\n...(其余 {len(targets)-20} 个略)"
return f"🔍 [演练] 将删除 {len(targets)} 个文件:\n{lines}{more}"
# —— delete 分支:结构化二次确认 ——
conf = await ctx.elicit(
message=(
f"⚠️ 即将真正删除 {len(targets)} 个文件,操作不可逆!\n"
"请确认,并填写删除原因(写入审计日志):"
),
response_type=ConfirmDelete,
)
# 安全铁律:非 accept 一律中止
if not isinstance(conf, AcceptedElicitation) or not conf.data.confirm:
return "🛑 未获确认,已放弃删除。"
deleted, failed = 0, []
for p in targets:
try:
p.unlink()
deleted += 1
except OSError as e:
failed.append(f"{p}: {e}")
await ctx.warning(f"删除失败 {p}: {e}")
audit = f"原因[{conf.data.reason}]"
summary = f"✅ 已删除 {deleted}/{len(targets)} 个文件({audit})。"
if failed:
summary += f"\n⚠️ {len(failed)} 个失败:\n" + "\n".join(failed[:10])
return summary
if __name__ == "__main__":
mcp.run() # 默认 stdio 传输
上面
target_count是为提醒:把它换成len(targets)。写教程时留个"小坑 + 提示"是为了让你注意——真实代码里 elicit 的message用 f-string 拼运行时数据,是最常见的笔误点。
联调:把步骤 4 路径 A 的 client.py 里 call_tool 换成:
res = await client.call_tool("safe_delete_files",
{"directory": "/tmp/junk", "pattern": "*.log"})
print(res.content[0].text)
先造点测试数据:
mkdir -p /tmp/junk && cd /tmp/junk && for i in $(seq 1 5); do touch "app_$i.log"; done && touch keep.txt
跑 uv run client.py,交互过程会是:先问模式(选 2 = dry-run 看预览)→ 再跑一次选 1(delete)→ 填 confirm: y、reason: 清理临时日志 → 服务器真正删 .log、保留 keep.txt。
预期结果:返回 ✅ 已删除 5/5 个文件(原因[清理临时日志])。,且 keep.txt 仍在。这就是"人在回路"的完整闭环:模型只负责发起意图,真正不可逆的动作由人类在工具运行中拍板。
下图展示一次完整调用中,四方(用户 / 客户端 / 服务器 / 工具函数)的时序,重点看 elicitation/create 是由服务器主动反向发给客户端的:
sequenceDiagram
participant U as 用户
participant C as MCP客户端<br/>(Claude Code/Inspector/自带客户端)
participant S as MCP服务器(FastMCP)
participant T as safe_delete_files
U->>C: "清理 /tmp/junk 下日志"
C->>S: tools/call safe_delete_files
S->>T: 执行(directory, pattern)
T->>T: rglob 扫描 → 5 个 .log
T-->>S: 找到5个,需确认
Note over S,C: 关键:方向反过来,服务器→客户端
S->>C: elicitation/create (schema: enum[delete,dry-run,cancel])
C->>U: 渲染选项/提问
U-->>C: 选 "dry-run"
C-->>S: result: accept, data=dry-run
S-->>T: 返回 ElicitResult
T-->>S: 预览文本
S-->>C: 工具结果
C-->>U: 展示 5 个文件清单
工具内部的决策分支:
flowchart TD
A[工具被调用] --> B[扫描目录得到 targets]
B --> C{第一轮 elicit: 选模式}
C -->|cancel/非accept| Z[🛑 中止,零副作用]
C -->|dry-run| D[返回文件预览清单]
C -->|delete| E{第二轮 elicit:<br/>ConfirmDelete结构化}
E -->|未确认| Z
E -->|确认 confirm=true| F[执行 unlink 删除]
F --> G[返回删除统计+审计原因]
D --> H[结束]
G --> H
await 即可,FastMCP 帮你维持会话;若改用 streamable HTTP 传输,注意会话头不要丢。response_title= / response_description= 改名;结构化对象用 Field(title=..., description=...)。confirm、reason、被删文件清单落盘成审计日志,便于事后追溯——这恰恰是 elicit 相比"直接删"的额外价值。elicitation/create 的请求/响应结构、安全模型与 schema 限制,是实现的权威依据。ctx.elicit() 的完整参数、所有 response_type 形态与类型化结果类的速查表。ElicitResult 与 elicitation_handler 的契约,写测试客户端必读。accept 一律不产生副作用;④ 自带客户端能独立跑通。ctx.elicit() 全部参数、response_type 形态与类型化结果类的权威说明,本教程 API 细节的主要依据。elicitation/create 的请求/响应结构与 schema 限制(仅浅对象+标量/枚举)。claude mcp add / .mcp.json)的官方指引,路径 C 的联调依据。[!tip] 51学通信知识星球 星主:爱卫生(微信:gprshome201101)——公众号「51学通信」创办者,19 年通信行业运维与讲师经验,著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书(各大电商平台有售)。 星球定位:深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域,主打运维岗位与信令分析。 当前规模:5200 篇主题 / 990 个回答,198 元/年。 加入福利:加入星球后请加爱卫生微信,拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题,另有群内分享。 AI 加持:51学通信知识星球已作为 skill 接入 WorkBuddy,使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例,做总结、找文档和 PPT 等场景全覆盖。
