本教程聚焦 MCP(Model Context Protocol)2025-06-18 规范引入的最新原语——Elicitation(征询)。它打破了"客户端问、服务器答"的单向工具调用范式,让 MCP 服务器在工具执行过程中主动向用户请求结构化输入,实现人在回路(human-in-the-loop)的交互式工具。学完本教程,你将能用 FastMCP 构建一个需要人类审批、分步收集信息、支持二次确认的可交互 MCP 服务器,并在 Claude Code 中挂载运行。适用于已掌握基础 MCP 工具开发、希望构建高破坏性操作护栏与交互式工作流的中高级开发者。
@mcp.tool,理解 JSON-RPC over stdio 的基本通信模型。uv(一个命令搞定虚拟环境与依赖);Claude Code CLI 或 MCP Inspector 用于联调。fastmcp>=2.10.0(Elicitation 服务端 API 自该版本引入;结构化字段标题等特性在 3.3.0+)。传统 MCP 工具是同步收口的:调用方(LLM)在 tools/call 时一次性把所有参数塞满,服务器跑完返回结果。很多真实场景这套模型兜不住:
Elicitation 就是为这些场景设计的。它本质是一条反向 JSON-RPC 请求:服务器在处理 tools/call 的过程中,向客户端发 elicitation/create,客户端把它翻译成一个用户可见的表单/确认框,用户填完后客户端把答案回传,服务器继续执行。
关键认知:发起方是服务器,但交互主权在客户端。客户端负责渲染 UI、做隐私提示、提供"拒绝/取消"按钮,并控制是否响应。服务器无权绕过客户端直接弹窗。
消息流(规范官方时序图):
sequenceDiagram
participant U as 用户
participant C as 客户端(Claude Code)
participant S as MCP 服务器
Note over S: tools/call 执行到一半
S->>C: elicitation/create (message + requestedSchema)
Note over C,U: 客户端渲染表单
C->>U: 展示征询 UI(标注来源服务器)
U-->>C: 填写 / 拒绝 / 取消
C-->>S: 返回 result {action, content}
Note over S: 根据 action 继续或中止
理解了这条反向流,后面所有 API 都是它的语法糖。
用 uv 初始化一个最小工程:
mkdir mcp-elicitation && cd mcp-elicitation
uv init --python 3.11
uv add "fastmcp>=2.10"
创建 server.py,先写一个不交互的"hello"工具,确认链路通:
from fastmcp import FastMCP
mcp = FastMCP("交互式服务器")
@mcp.tool
def ping() -> str:
return "pong"
if __name__ == "__main__":
mcp.run()
预期结果:uv run fastmcp dev server.py 会拉起 MCP Inspector,能看到名为 ping 的工具,调用返回 pong。
常见问题:
| 现象 | 原因与解决 |
|---|---|
fastmcp: command not found |
用 uv run fastmcp ... 而非裸 fastmcp,或 uv tool install fastmcp 全局装一份 |
| Inspector 打不开 | 端口被占,加 --port 换一个;或检查 Node 版本需 18+ |
ctx.elicit——纯确认型最简单的征询是不要数据、只要一个"是否同意"。把 response_type 设为 None,用户点"确认"即返回 accept:
from fastmcp import FastMCP, Context
mcp = FastMCP("确认守卫")
@mcp.tool
async def drop_table(ctx: Context, table_name: str) -> str:
"""危险操作:删除一张表,需人类确认。"""
result = await ctx.elicit(
message=f"⚠️ 即将删除表 `{table_name}`,此操作不可逆。确认吗?",
response_type=None,
)
if result.action == "accept":
# 这里放真正的删除逻辑
return f"已删除表 {table_name}"
return f"操作未执行(用户{result.action})"
返回的 result 有两个字段:
result.action:三态之一——accept(同意并提交)、decline(明确拒绝)、cancel(关闭/取消)。result.data:None 类型时不携带数据,恒为 None。预期结果:在 Claude Code 里让模型调用 drop_table(table_name="orders"),你会看到一个确认弹窗,标注来源是"确认守卫"服务器;点"确认"才真正执行。
常见问题:
elicitation capability。换 Claude Code / Claude Desktop / VS Code(Copilot Chat)这类已实现的客户端即可。规范要求支持方在初始化时声明 "capabilities": {"elicitation": {}}。确认型只够点"是/否"。要一次收多个字段,把 response_type 设为一个 Pydantic 模型(也支持 dataclass / TypedDict):
from pydantic import BaseModel, Field
from typing import Literal
class DeployDetail(BaseModel):
service: str = Field(..., title="服务名", description="要部署的微服务")
env: Literal["dev", "staging", "prod"] = Field("dev", title="目标环境")
replicas: int = Field(2, ge=1, le=10, title="副本数", description="1~10")
@mcp.tool
async def collect_deploy(ctx: Context) -> str:
result = await ctx.elicit(
message="请补充部署详情",
response_type=DeployDetail,
)
if result.action == "accept":
d = result.data
return f"将部署 {d.service} 到 {d.env},副本数 {d.replicas}"
return f"未收集到信息({result.action})"
注意三处规范约束,踩坑高发:
requestedSchema 在协议层被限制为"扁平对象 + 原始属性",不支持嵌套对象、对象数组。FastMCP 会校验,嵌套结构会直接报错。Field(title=, description=):客户端(如 VS Code)会把 title 直接当输入框标签渲染,写好 title 体验差别巨大。ge/le/minLength/maxLength/format=email|uri|date|date-time 都会被翻译进 JSON Schema,客户端做即时校验。当用户 accept 时,result.data 就是一个填好的 DeployDetail 实例,字段类型自动转换。
常见问题:
TypeError: passing response_title alongside a model type:结构化响应不能用顶层 response_title,字段标题必须写在各字段 Field(title=...) 里。顶层 response_title/response_description 只对纯量(str/int/bool/枚举)包装时生效。让用户从有限选项里挑,是征询最高频的形态。FastMCP 支持多种写法:
from typing import Literal
from enum import Enum
# 写法 A:字符串列表(最省事)
@mcp.tool
async def set_priority(ctx: Context) -> str:
r = await ctx.elicit("优先级?", response_type=["low", "medium", "high"])
return r.data if r.action == "accept" else "未设置"
# 写法 B:Literal 类型(更类型安全)
@mcp.tool
async def set_priority_lit(ctx: Context) -> str:
r = await ctx.elicit("优先级?", response_type=Literal["low", "medium", "high"])
return str(r.data) if r.action == "accept" else "未设置"
# 写法 C:多选 —— 再包一层 list
@mcp.tool
async def pick_tags(ctx: Context) -> str:
r = await ctx.elicit(
"选择要启用的特性标签(可多选)",
response_type=[["feature-x", "feature-y", "feature-z"]], # 注意是 list 套 list
)
if r.action == "accept":
return "已启用:" + ", ".join(r.data)
return "未选择"
想让下拉框显示人类可读名称?用带标题的字典,FastMCP 会生成 SEP-1330 兼容的 oneOf 模式:
@mcp.tool
async def titled(ctx: Context) -> str:
r = await ctx.elicit(
"选择环境",
response_type={
"dev": {"title": "开发环境"},
"staging": {"title": "预发环境"},
"prod": {"title": "生产环境"},
},
)
return f"选中 {r.data}" if r.action == "accept" else "未选择"
预期结果:客户端渲染出下拉/单选/复选控件,而非自由文本框,降低输入错误。
Elicitation 真正的威力在串行多轮:一次工具调用里发起多次征询,逐步收敛。下面是一个会议安排工具,三轮收集标题、时长、是否紧急:
@mcp.tool
async def plan_meeting(ctx: Context) -> str:
title_r = await ctx.elicit("会议主题?", response_type=str)
if title_r.action != "accept":
return "已取消"
dur_r = await ctx.elicit("时长(分钟)?", response_type=int)
if dur_r.action != "accept":
return "已取消"
urg_r = await ctx.elicit("紧急吗?", response_type=["yes", "no"])
if urg_r.action != "accept":
return "已取消"
urgent = urg_r.data == "yes"
return f"已排会『{title_r.data}』,{dur_r.data} 分钟,紧急={urgent}"
每一轮 action 都要单独检查——任意一轮被 decline/cancel 就该优雅退出,不要继续向下征询,否则会连环弹窗骚扰用户。
if/elif 在多分支里会显得啰嗦。FastMCP 提供三个类型化结果类,配合 Python 3.10+ 的 match 语法非常清爽:
from fastmcp.server.elicitation import (
AcceptedElicitation,
DeclinedElicitation,
CancelledElicitation,
)
@mcp.tool
async def robust(ctx: Context) -> str:
r = await ctx.elicit("你的名字?", response_type=str)
match r:
case AcceptedElicitation(data=name):
return f"你好,{name}!"
case DeclinedElicitation():
return "用户拒绝透露姓名" # 明确点了"拒绝"
case CancelledElicitation():
return "操作被取消" # 关闭了弹窗
decline 和 cancel 的语义区别要传达到产品逻辑里:decline 是用户主动否决(应记录、可能触发替代流程);cancel 是中途放弃(通常重试或静默退出)。
服务端写好了,接入 Claude Code。在项目根目录建 .mcp.json(团队共享)或用 claude mcp add(写入用户配置):
{
"mcpServers": {
"interactive-server": {
"command": "uvx",
"args": ["--from", "fastmcp", "fastmcp", "run", "/绝对路径/server.py"]
}
}
}
随后在 Claude Code 中:
> 帮我排一个会议,主题是"Q3 复盘"
模型会调用 plan_meeting,触发第一轮征询弹窗;你填完点确认,第二轮自动弹出,依此类推。全程你无需在 prompt 里交代细节——这正是 Elicitation 相比"把所有参数塞进 prompt"的体验优势。
常见问题:
ctx.elicit 当作不支持而报错。.mcp.json 里 args 的脚本路径建议用绝对路径。开发期别在 Claude Code 里盲调。FastMCP 内置一键拉起官方 Inspector:
uv run fastmcp dev server.py
浏览器打开 Inspector,左侧选 Tools → 点目标工具 → Call Tool。当工具走到 ctx.elicit,Inspector 会渲染出与真实客户端一致的表单,你可以手动填、手动点 decline/cancel,观察服务器对三态的响应。这是定位"参数没传进去""约束没生效"最快的方式。
如果想看协议层到底传了什么,开 Inspector 的日志,你会看到服务器发出的 elicitation/create 原始报文:
{
"jsonrpc": "2.0",
"id": 1,
"method": "elicitation/create",
"params": {
"message": "请补充部署详情",
"requestedSchema": {
"type": "object",
"properties": {
"service": { "type": "string", "title": "服务名" },
"env": { "type": "string", "enum": ["dev","staging","prod"], "default": "dev" }
},
"required": ["service"]
}
}
}
以及客户端回的三态之一:
{ "jsonrpc": "2.0", "id": 1, "result": { "action": "accept", "content": { "service": "billing", "env": "prod" } } }
对照报文调 schema,比猜 API 直观百倍。
把前 9 步合起来,做一个 DevOps 场景的变更审批 MCP 服务器:模型提交变更摘要 → 人类确认 → 收集结构化详情 → 若命中生产环境则二次确认 → 全程写审计日志。这是一个真实可落地的"高破坏性操作护栏"。
# change_approval.py
import json
from pathlib import Path
from typing import Literal
from pydantic import BaseModel, Field
from fastmcp import FastMCP, Context
from fastmcp.server.elicitation import (
AcceptedElicitation, DeclinedElicitation, CancelledElicitation,
)
mcp = FastMCP("变更审批守卫")
AUDIT = Path("audit.jsonl")
class ChangeRequest(BaseModel):
service: str = Field(..., title="服务名", description="要变更的微服务")
reason: str = Field(..., title="变更原因")
env: Literal["dev", "staging", "prod"] = Field("dev", title="目标环境")
rollback_on_failure: bool = Field(True, title="失败自动回滚")
def audit(action: str, payload: dict) -> None:
record = {"action": action, **payload}
with AUDIT.open("a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
@mcp.tool
async def approve_change(ctx: Context, summary: str) -> str:
"""提交一次变更申请,需要人类审批后才执行。
Args:
summary: 一句话变更摘要,如"升级 billing 服务到 v2.3"
"""
# 第 1 轮:入口确认
go = await ctx.elicit(
message=f"变更申请:\n{summary}\n\n是否继续补充详情?",
response_type=None,
)
if go.action != "accept":
audit("aborted", {"summary": summary, "reason": go.action})
return f"变更未提交(用户{go.action})"
# 第 2 轮:结构化收集
detail = await ctx.elicit(
message="请补充变更详情",
response_type=ChangeRequest,
)
match detail:
case AcceptedElicitation(data=req):
# 第 3 轮:生产环境强制二次确认
if req.env == "prod":
final = await ctx.elicit(
message=f"⚠️ 生产环境变更:{req.service}\n原因:{req.reason}\n确认执行?",
response_type=bool,
response_title="生产二次确认",
response_description="此为高破坏性操作,请慎重",
)
if not (final.action == "accept" and final.data is True):
audit("prod_blocked", {"service": req.service, "reason": final.action})
return "生产变更已被拒绝执行"
audit("executed", {"service": req.service, "env": req.env,
"rollback": req.rollback_on_failure})
return (f"✅ 变更已执行:{req.service} -> {req.env}"
f"(自动回滚={req.rollback_on_failure})")
case DeclinedElicitation():
audit("declined", {"summary": summary})
return "用户拒绝提供详情,变更取消"
case CancelledElicitation():
return "操作已取消"
if __name__ == "__main__":
mcp.run()
这个例子把本教程的全部要点串成了一条真实业务流:纯确认型(第 1 轮)→ 结构化收集(第 2 轮)→ 条件分支二次确认(第 3 轮,仅 prod 触发)→ 三态模式匹配 → 审计落盘。挂到 Claude Code 后,让模型"帮我把 billing 服务升级到 v2.3,走正式变更流程",整条交互链会自动展开。
规范对 Elicitation 有明确的 trust & safety 红线,写服务器时必须内化:
elicitation/create 高频弹窗,客户端有权熔断。content。oneOf/const/title 如何让客户端生成更友好的下拉控件。ctx.elicit() API、response_type 类型系统(纯量/结构化/枚举/多选)、类型化结果类与模式匹配用法。elicitation.mdx 原始 MDX。docs/servers/elicitation.mdx 含版本演进(2.10 引入、2.14 多选、3.3 字段标题)。[!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 等场景全覆盖。
