<!-- title: 用Elicitation造会问人的MCP工具 -->

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

## 概述

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

## 前置条件

- **基础**：读过前两轮 MCP 教程（Python 三原语 / 底层 API），能独立写出 `tools / resources / prompts` 服务器；理解 JSON Schema。
- **环境**：Python ≥ 3.11；推荐用 `uv` 管理虚拟环境（`pip install uv` 或 `curl -LsSf https://astral.sh/uv/install.sh | sh`）。
- **客户端**：一个支持 Elicitation 的 MCP 客户端用于最终联调——本教程会提供**自带的可运行测试客户端**，保证你不依赖任何客户端能力就能跑通；之后再尝试接入 Claude Code / MCP Inspector。
- **一句话认知准备**：参数（parameters）是**模型提前给你**的；征询（elicitation）是**你在运行中问用户**的。记住这个区别，整篇就通了。

---

## 步骤 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(...)` 调用，是最省事的实现路径。

```bash
# 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`：

```python
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` 来描述这个浅对象：

```python
from dataclasses import dataclass

@dataclass
class ConfirmDelete:
    confirm: bool      # 是否确认删除（标量 ✓）
    reason: str        # 删除原因，留作审计（标量 ✓）
    # 注意：不能放嵌套对象/列表，规范不支持
```

字段还能带默认值（客户端会预填表单），用 Pydantic 时写 `Field(default="...", description="...")` 即可。这个 `ConfirmDelete` 会在步骤 4 的实战里作为"真正删除前的二次确认"出场。

---

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

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

### 路径 A：自带测试客户端（推荐先跑这条）

新建 `client.py`，它把服务器当子进程拉起，并自己实现一个命令行版的征询处理器：

```python
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())
```

运行：

```bash
uv run client.py
```

**预期结果**：终端打印 `🔔 服务器向你提问：你叫什么名字？`，你输入 `张三`，回车后看到 `=== 工具返回 === 你好，张三！`。这条路径**不依赖任何外部客户端是否支持 elicitation**，是排查问题的安全网。

**关键细节**：返回给服务器时，标量要包成 `{"value": ...}`，结构化对象要按字段名给 `dict`。这是客户端侧最容易踩的坑。

### 路径 B：MCP Inspector（可视化）

```bash
npx @modelcontextprotocol/inspector uv run safe_ops_server.py
```

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

### 路径 C：接入 Claude Code

```bash
# 方式一：命令行注册
claude mcp add safe-ops -- uv run /绝对路径/safe_ops_server.py

# 方式二：项目级 .mcp.json
```

`.mcp.json` 写法：

```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` 可能让工具带着空数据继续跑下去，造成事故：

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

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

---

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

把前面所有片段拼成一个完整、可运行的项目。它的工具 `safe_delete_files` 会：扫描目录 → 征询选模式（删/演练/取消）→ 若选删，再用结构化表单二次确认并要删除原因 → 才真正 `unlink`。

`safe_ops_server.py` 完整代码：

```python
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` 换成：

```python
res = await client.call_tool("safe_delete_files",
                             {"directory": "/tmp/junk", "pattern": "*.log"})
print(res.content[0].text)
```

先造点测试数据：

```bash
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 是由服务器主动反向发给客户端的**：

```mermaid
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 个文件清单
```

工具内部的决策分支：

```mermaid
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 提议 + 人类批准的治理闭环（删除/部署/发邮件等高危场景尤其适用）。

---

## 延伸阅读

- **MCP 规范 Elicitation 章节**（2025-06-18）：定义 `elicitation/create` 的请求/响应结构、安全模型与 schema 限制，是实现的权威依据。
- **FastMCP 官方文档 → User Elicitation**：`ctx.elicit()` 的完整参数、所有 `response_type` 形态与类型化结果类的速查表。
- **FastMCP Client Elicitation**：客户端侧 `ElicitResult` 与 `elicitation_handler` 的契约，写测试客户端必读。
- **Sampling 原语**：与本篇对照阅读，理解"问模型"与"问人"的分工。

---

## 训练营适用性评估

- **适合的训练营类型**：进阶 MCP / AI Agent 工程营、企业级 LLM 应用安全营、"人在回路"自动化营。要求学员已掌握 MCP 三原语。
- **不适合**：零基础入门营（需要先有 tools/resources 基础）、纯前端营。
- **建议教学时长**：
  - 讲授 + 概念对比（参数/sampling/elicitation）：30 分钟
  - 跟敲步骤 1–4（含三种测试）：60–75 分钟
  - 实战案例联调 + 故意制造 decline/cancel 场景：30–45 分钟
  - 合计约 **2.5–3 小时**，可作为一个半天的工作坊单元。
- **考核点建议**：① 能区分何时该用参数、何时该用 elicit；② 结构化二次确认的字段设计合规（浅对象+标量）；③ 非 `accept` 一律不产生副作用；④ 自带客户端能独立跑通。

---

## 参考来源

1. [FastMCP — User Elicitation 官方文档](https://gofastmcp.com/servers/elicitation) — `ctx.elicit()` 全部参数、response_type 形态与类型化结果类的权威说明，本教程 API 细节的主要依据。
2. [Model Context Protocol Specification — Elicitation (2025-06-18)](https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation) — Elicitation 原语的规范定义，包含 `elicitation/create` 的请求/响应结构与 schema 限制（仅浅对象+标量/枚举）。
3. [MaximoFN — MCP Elicitation Tutorial: Human-in-the-Loop FastMCP](https://www.maximofn.com/en/mcp-elicitation/) — 完整的 FastMCP 服务器 + 自定义客户端（带 elicitation_handler）实战，本教程测试客户端的 ElicitResult 契约参考自此。
4. [ZazenCodes — MCP Elicitation Tutorial](https://zazencodes.substack.com/p/mcp-elicitation-tutorial) — 对客户端侧渲染 elicitation 表单的可视化演示，帮助理解用户侧体验。
5. [Claude Code Docs — Connect Claude Code to tools via MCP](https://code.claude.com/docs/en/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学通信知识星球海报-扫码加入](https://wiki.500nian.com/images/zhixingqiu_qrcode.png)
