本教程聚焦 MCP 在 2025-06-18 规范中引入的一项全新能力——征询(Elicitation):让 MCP 工具在执行过程中,临时停下来向用户追问结构化参数,再带着答案继续往下走。学完你能分清"普通工具参数"与"征询"的本质差异,用官方 Python SDK 写出会向用户提问的工具,把它接入 Claude Code 验证交互对话框,并用 Elicitation 钩子实现无人值守下的自动应答。适合已经写过基础 MCP 工具(三原语)、想往"人在回路的智能工具"方向进阶的开发者。
pip install "mcp[cli]"一个最容易踩的坑,是把征询当成"另一种写参数的方式"。它俩解决的是截然不同的问题。
普通工具参数,由调用方(也就是大模型)在调用前一次性填好。模型根据上下文猜:删日志要保留几天?审批人是谁?要不要强制?它猜完了再调你的工具。问题是——这些值模型根本没法可靠地猜,或者根本不该让模型猜。
征询则把"该填什么"的决定权在执行中途交还给人类用户。工具发现自己走到了一个模型不该替人做主的岔路口,就主动弹出一个带表单的对话框,把用户的真实回答注入执行流程。
sequenceDiagram
participant LLM as 大模型
participant CC as Claude Code(客户端)
participant Srv as 你的MCP服务器
participant U as 用户
LLM->>CC: 调用 purge_old_logs(repo, days=90, dry_run=false)
CC->>Srv: tools/call
Note over Srv: 检测到危险操作,暂停执行
Srv->>CC: elicitation/create (带表单schema)
CC->>U: 弹出交互对话框
U-->>CC: 填写并确认/拒绝/取消
CC-->>Srv: action=accept + data{...}
Note over Srv: 带着人类答案继续执行
Srv-->>CC: 返回最终结果
CC-->>LLM: 把结果交给模型
一句话记忆:普通参数是模型在工具外做主,征询是人类在工具内拍板。
新建目录,初始化一个最小可跑的 FastMCP 服务器:
mkdir purge-approval-mcp && cd purge-approval-mcp
python -m venv .venv && source .venv/bin/activate
pip install "mcp[cli]" pydantic
新建 server.py:
# server.py
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("purge-approval")
@mcp.tool()
async def ping() -> str:
"""Health check."""
return "pong"
if __name__ == "__main__":
mcp.run(transport="stdio")
预期结果:python server.py 启动后挂起,等待 stdio 上的 JSON-RPC 报文,没有任何报错即说明骨架就绪。
常见问题:若 from mcp.server.fastmcp import FastMCP 报 ModuleNotFoundError,多半是装了同名的第三方 fastmcp(jlowin 版)和官方 SDK 混淆。本文用的是官方 mcp SDK 自带的 FastMCP,二者 API 相似但不完全一致,认准 mcp[cli] 即可。
征询的 API 入口是工具上下文对象上的 ctx.elicit()。它接收一段提示文案和一个 Pydantic 模型(作为表单 schema),返回一个带 action 和 data 的结果对象。
# server.py
from mcp.server.fastmcp import FastMCP, Context
from pydantic import BaseModel, Field
mcp = FastMCP("purge-approval")
class PurgeConfirm(BaseModel):
confirmed: bool = Field(description="I understand this deletion is irreversible")
approver: str = Field(description="Reviewer email", json_schema_extra={"format": "email"})
retention_days: int = Field(default=30, ge=1, le=365, description="Days actually retained")
@mcp.tool()
async def purge_old_logs(repo: str, days: int, dry_run: bool, ctx: Context) -> str:
"""Delete log objects older than N days. dry_run=false performs real deletion."""
target = f"{repo}/logs (older than {days}d)"
# Safe path: simulation only, no need to interrupt the user
if dry_run:
return f"[DRY-RUN] would delete {target}, 1284 objects (nothing removed)"
# Dangerous path: must ask a human before proceeding
result = await ctx.elicit(
message=f"About to permanently delete {target}. This cannot be undone. Please review.",
schema=PurgeConfirm,
)
if result.action == "decline":
return "[BLOCKED] user declined; nothing deleted"
if result.action == "cancel":
return "[CANCELLED] user dismissed the dialog"
if result.action == "accept" and result.data:
if not result.data.confirmed:
return "[BLOCKED] confirmation checkbox not ticked; aborted"
# real deletion happens here
return (f"[DELETED] {target} approved by {result.data.approver}, "
f"retention={result.data.retention_days}d")
return "[ERROR] unexpected response"
几个要点拆解:
ctx: Context 必须显式声明为工具形参,SDK 才会注入上下文;它是调用 ctx.elicit 的前提。schema=PurgeConfirm 用 Pydantic 模型描述表单,SDK 会自动转成 MCP 规范要求的 requestedSchema。result.action 只有三种取值:accept(用户填了表单提交)、decline(用户主动拒绝)、cancel(用户关掉对话框)。三者必须全覆盖,漏处理 decline/cancel 是最常见的线上事故源头。result.data 是已经按 schema 校验过的 Pydantic 实例;仅在 action == "accept" 时有值,其它情况为 None,所以用 and result.data 短路保护。预期结果:工具逻辑就绪,但此刻还没接客户端,征询不会真正发生。下一步接进 Claude Code 才能看到弹窗。
征询的 schema 不是完整的 JSON Schema,而是刻意收窄的一个子集——为了减轻客户端渲染表单的负担。规范明确允许的字段类型只有四种:
| 类型 | 关键约束 | 典型用途 |
|---|---|---|
string |
minLength / maxLength / format(仅 email uri date date-time) |
邮箱、URL、日期、自由文本 |
number / integer |
minimum / maximum |
数量、天数、阈值 |
boolean |
default |
确认勾选、开关 |
enum |
string + enum + enumNames |
枚举选项(下拉框) |
故意不支持的能力:嵌套对象、对象数组、oneOf/anyOf 这类高级结构。换句话说,征询表单永远是扁平的、一层属性。
如果你这么写,客户端会拒绝或忽略:
# 反例:嵌套对象,规范不支持
class Bad(BaseModel):
contact: dict # nested object -> will break the form
正确做法是把嵌套拍平,或拆成多次征询。规则很朴素:一次征询问一组同层的简单字段。
除了表单模式,征询还有一种 URL 模式(url-mode),专门处理 OAuth 授权、支付确认这类"必须跳浏览器完成"的敏感流程。服务器给出一个外部链接,客户端引导用户去浏览器完成,再把结果带回来。
官方 SDK 提供了 ctx.elicit_url(),以及一种"抛错模式"——当工具绝对无法在没有用户授权时继续,就抛 UrlElicitationRequiredError,框架会把它转成 -32042 错误码,强制客户端走 URL 征询:
import uuid
from mcp.shared.exceptions import UrlElicitationRequiredError
@mcp.tool()
async def connect_service(service_name: str) -> str:
"""Connect a third-party service requiring OAuth."""
elicitation_id = str(uuid.uuid4())
raise UrlElicitationRequiredError(
message=f"Authorization required to connect {service_name}",
url=f"https://{service_name}.example.com/oauth/authorize?elicit={elicitation_id}",
elicitation_id=elicitation_id,
)
注意:URL 模式的具体 API 在不同 SDK 版本间仍在演化(规范本身也声明"设计可能演进")。表单模式是目前最稳定、最常用的一种,本教程的实战项目就以它为主。
Claude Code 对征询是零配置支持的:只要服务器声明了 elicitation 能力(FastMCP 自动声明),Claude Code 检测到 elicitation/create 就会自动弹出交互对话框,把用户填的内容回传给服务器。你不用在客户端做任何额外设置。
把服务器注册进项目配置:
claude mcp add purge-approval -- python /绝对路径/server.py
或手写 .mcp.json:
{
"mcpServers": {
"purge-approval": {
"command": "python",
"args": ["/绝对路径/server.py"]
}
}
}
预期结果:在 Claude Code 里对模型说:
帮我清理 myrepo 仓库超过 90 天的旧日志,先真删。
模型会调用 purge_old_logs(repo="myrepo", days=90, dry_run=False)。此时工具触发征询,Claude Code 弹出一个表单对话框,列出确认勾选、审批人邮箱、保留天数三项。你勾选确认、填好邮箱、点提交,工具拿到答案执行真实删除,返回 [DELETED] ...。
如果你点"拒绝",工具返回 [BLOCKED] ...,模型会据此告诉你"用户拒绝了删除";如果你直接关掉对话框,返回 [CANCELLED] ...。
常见问题:
ctx.elicit。升级 pip install -U "mcp[cli]"。python 能找到正确的虚拟环境(必要时用 venv 里的解释器全路径)。征询要弹对话框,但无人值守/CI 场景下根本没有人类来点。Claude Code 提供了 Elicitation 钩子(hook) 来拦截征询请求:在对话框弹出前后跑一段脚本,脚本能观察、修改、甚至直接代答。
钩子收到的输入包含 mcp_server_name、message、mode、url、elicitation_id 和 requested_schema 等字段。表单模式下,你可以读 stdin 上的 JSON,决定是放行、代答还是拦截。
写一个代答脚本 scripts/auto_approve.sh:
#!/usr/bin/env bash
# Read the elicitation request JSON from stdin, emit an auto-accept response.
input=$(cat)
# Naive auto-approver: fill required fields with placeholders.
# DANGER: only do this for low-risk servers; never blanket-approve destructive ops.
cat <<EOF
{
"hookSpecificOutput": {
"hookEventName": "Elicitation",
"action": "accept",
"data": { "confirmed": true, "approver": "[email protected]", "retention_days": 30 }
}
}
EOF
在 settings.json 里挂上钩子(matcher 匹配 MCP 服务器名):
{
"hooks": {
"Elicitation": [
{
"matcher": "purge-approval",
"hooks": [{ "type": "command", "command": "scripts/auto_approve.sh" }]
}
]
}
}
钩子退出码的语义:退出码 0 表示放行/代答,退出码 2 表示拒绝并把 stderr 展示给用户,其它非零码视为错误。这意味着同一段配置在交互模式下由人工确认、在 CI 模式下由钩子代答——同一份服务器代码,两种运行姿态。
安全警示:对破坏性操作做无条件自动放行是危险的反模式。本文的
auto_approve.sh仅作演示,生产中应配合签名校验、白名单字段,或干脆只给只读服务器开自动应答。
征询之外,还有一项 Claude Code 明确支持的客户端能力值得知道:roots(根目录)。服务器可以调用 roots/list,拿到 Claude Code 启动时所在的项目目录;同时 Claude Code 也会在子进程环境里注入 CLAUDE_PROJECT_DIR 变量。
这和征询是绝配:工具先用 roots 定位到真实项目根,再在执行危险操作时用征询请人确认。Python 侧直接读环境变量即可:
import os
project_root = os.environ.get("CLAUDE_PROJECT_DIR", ".")
这样工具既能"知道自己在哪个仓库里干活",又能在关键步骤"喊一声人类"。
规范里和征询方向对称的另一项能力叫采样(sampling):征询是服务器向用户要数据,采样是服务器反向调用客户端的大模型——即 sampling/createMessage。它允许工具不自带 API Key,借宿主的 LLM 做摘要、分类、重排等子任务。
但请注意一个容易踩的坑:截至本文核对,Claude Code 官方文档明确把 elicitation 与 roots 列为支持的客户端能力,却并未把 sampling 列入。这意味着——
ctx.elicit 写的工具,在 Claude Code 里能直接跑通;规范里采样的请求长这样(仅供理解协议形态):
{
"method": "sampling/createMessage",
"params": {
"messages": [{ "role": "user", "content": { "type": "text", "text": "..." } }],
"modelPreferences": { "hints": [{ "name": "claude-sonnet" }] },
"maxTokens": 100
}
}
实践建议:需要"工具内嵌 LLM"的,先别押注采样,改用服务器自带 API Key 调模型更可控;想验证采样协议本身,可用 MCP Inspector 或 Claude Desktop 这类支持采样的客户端测试。把"客户端到底支持哪些能力"这条核对了再动手,能省下大量调试时间。
把前面各步拼成一个可运行的最小项目。目录结构:
purge-approval-mcp/
├── server.py
├── scripts/
│ └── auto_approve.sh
└── .mcp.json
server.py 即第三步的完整代码。scripts/auto_approve.sh 即第七步的脚本(记得 chmod +x)。.mcp.json 指向 server.py。
完整工作流演示:
[DELETED] ...。[BLOCKED] ...,模型如实汇报"删除已被拒绝"。dry_run=true 看影响面,确认无误再调 dry_run=false 触发征询。这个项目虽然小,却完整覆盖了征询的核心价值:把"是否执行不可逆操作"的最终决定权,从模型手里夺回,交还给人。
ctx.elicit 是普通 await,按需多次调用即可,例如"第一问确认→第二问填理由"的分步表单。本教程定位中阶,适合"MCP 进阶开发训练营"或"AI Agent 安全工程训练营"。学员应已掌握 MCP 三原语,具备 Python 异步基础。建议教学时长 2.5~3 小时:40 分钟讲清征询与普通参数的本质差异和受限 schema,60 分钟手把手写 purge-approval 工具并接入 Claude Code 验证对话框,45 分钟讲解 Elicitation 钩子与无人值守代答(含安全讨论),30 分钟讨论 roots 协同与 sampling 现状。学员产出物:一个会向人类请示再删数据的破坏性操作审批工具,附带 CI 自动应答配置。可作为后续"采样与多步 Agent"高级课的前置。
[!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 等场景全覆盖。
