首页 › tutorials › 用Elicitation造会问人的MCP工具

用 Elicitation 原语造会问人的 MCP 工具

概述

传统 MCP 工具的所有参数必须在调用前由模型一次性想清楚——但现实中很多决定要等工具跑起来才知道:扫描出 120 个待删文件后,才该问"确定吗?";查到生产库连接串后,才该问"真要执行这条 DROP 吗?"。Elicitation(征询) 是 MCP 在 2025-06-18 规范中新增的第四大原语,让服务器在工具执行中途反向向人类用户提问,拿到结构化答复后再继续。本教程从零构建一个"删除前必须人工确认"的安全操作 MCP 服务器,覆盖标量、受限选项、结构化响应、多轮提问与三种测试路径。适合已经会用 @mcp.tool 写工具、想进阶到"人在回路"(human-in-the-loop)的开发者。

前置条件


步骤 0:先想清楚 Elicitation 到底解决了什么

很多人第一次接触会把它和"工具参数"或"Sampling"混淆。先把三者的边界画清楚,否则后面写的代码会"能跑但用错地方"。

维度 工具参数 Parameters Sampling 采样 Elicitation 征询
谁发起 客户端/模型 服务器 服务器
问谁 不问,直接传值 问 LLM/模型(要一段生成文本) 问 人类用户(要结构化输入)
时机 调用前 工具执行中 工具执行中
典型场景 delete(path) 的 path 服务器让模型总结一段日志 删 120 个文件前问"确定吗?"

Elicitation 的杀手级场景:模型的"决定"必须基于运行时才能拿到的事实。模型在调用 safe_delete_files("/tmp/junk", "*.log") 时,根本不知道这个目录下到底有 3 个还是 3000 个文件——这个事实只有工具跑起来扫描后才知道。于是"删不删"这个决定,天然属于工具运行中的征询,而不是调用前的参数。

明白这一点,你就知道哪些需求不该用 elicitation:纯确定性的输入(路径、阈值)继续走参数;需要让模型"思考/生成"的走 sampling。只有"需要真人拍板且信息在运行中才浮现"的,才用 elicitation。


步骤 1:建项目,装 FastMCP

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。


步骤 2:写出第一个"会提问"的工具

先建一个最小可跑的骨架,体验标量(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": "低优先级"}, ...}


步骤 3:多轮提问 + 结构化响应

真实的安全工具往往要问好几轮,且第二轮要多个字段。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 的实战里作为"真正删除前的二次确认"出场。


步骤 4:三种方式测试——保证一定能跑通

Elicitation 的难点不在服务器,而在客户端必须实现征询处理器。下面给三条路径,从"100% 能跑"到"接入真实 AI 客户端"。

路径 A:自带测试客户端(推荐先跑这条)

新建 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。这是客户端侧最容易踩的坑。

路径 B:MCP Inspector(可视化)

npx @modelcontextprotocol/inspector uv run safe_ops_server.py

浏览器打开它给的本地地址,左侧选 Tools → greet → Run Tool。较新版本的 Inspector 会把服务器的 elicitation schema 渲染成一个表单弹窗。注意:早期 Inspector 不支持 elicitation,点了 Run 会一直转圈或报 unsupported——遇到这种情况回到路径 A。

路径 C:接入 Claude Code

# 方式一:命令行注册
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 验证服务器逻辑无误,再升级客户端。


步骤 5:兜底——优雅处理 decline 与 cancel

用户不是每次都会乖乖回答。三种 action 必须都接住,否则一个 cancel 可能让工具带着空数据继续跑下去,造成事故:

match result:
    case AcceptedElicitation(data):
        ...            # 正常往下走
    case DeclinedElicitation():
        return "⏭️ 用户拒绝提供,按安全策略中止。"   # 用户主动说"不"
    case CancelledElicitation():
        return "🛑 用户取消整个操作。"              # 用户关掉弹窗/Esc

安全铁律:只要不是 AcceptedElicitation,就绝不进入任何有副作用的分支(删除、发请求、写库)。把这条写进肌肉记忆,是"会问人的工具"区别于"会闯祸的工具"的关键。


实战案例:safe_ops 安全删除服务器

把前面所有片段拼成一个完整、可运行的项目。它的工具 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

进阶要点

  1. Mcp-Session-Id 与多轮:多轮 elicit 在同一工具调用内顺序 await 即可,FastMCP 帮你维持会话;若改用 streamable HTTP 传输,注意会话头不要丢。
  2. 字段标签本地化:VS Code 等客户端会把 schema 的字段名直接当 UI 标签渲染。标量可用 response_title= / response_description= 改名;结构化对象用 Field(title=..., description=...)。
  3. 不要把 elicit 当万能弹窗:它每次都是一次人机往返,频繁提问会拖垮体验。原则是"只在不可逆/有歧义/信息运行时才浮现时问"。能用参数确定的别用 elicit。
  4. 审计与可重放:把 confirm、reason、被删文件清单落盘成审计日志,便于事后追溯——这恰恰是 elicit 相比"直接删"的额外价值。
  5. 与 Sampling 组合:复杂工具可"用 sampling 让模型分析 → 用 elicitation 让人确认",形成 AI 提议 + 人类批准的治理闭环(删除/部署/发邮件等高危场景尤其适用)。

延伸阅读


训练营适用性评估


参考来源

  1. FastMCP — User Elicitation 官方文档 — ctx.elicit() 全部参数、response_type 形态与类型化结果类的权威说明,本教程 API 细节的主要依据。
  2. Model Context Protocol Specification — Elicitation (2025-06-18) — Elicitation 原语的规范定义,包含 elicitation/create 的请求/响应结构与 schema 限制(仅浅对象+标量/枚举)。
  3. MaximoFN — MCP Elicitation Tutorial: Human-in-the-Loop FastMCP — 完整的 FastMCP 服务器 + 自定义客户端(带 elicitation_handler)实战,本教程测试客户端的 ElicitResult 契约参考自此。
  4. ZazenCodes — MCP Elicitation Tutorial — 对客户端侧渲染 elicitation 表单的可视化演示,帮助理解用户侧体验。
  5. Claude Code Docs — Connect Claude Code to tools via MCP — Claude Code 注册与管理 MCP 服务器(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 等场景全覆盖。

51学通信知识星球海报-扫码加入