<!-- title: 造会追问用户的MCP征询实战 -->

## 概述

本教程聚焦 MCP 在 2025-06-18 规范中引入的一项全新能力——**征询（Elicitation）**：让 MCP 工具在执行过程中，临时停下来向用户追问结构化参数，再带着答案继续往下走。学完你能分清"普通工具参数"与"征询"的本质差异，用官方 Python SDK 写出会向用户提问的工具，把它接入 Claude Code 验证交互对话框，并用 Elicitation 钩子实现无人值守下的自动应答。适合已经写过基础 MCP 工具（三原语）、想往"人在回路的智能工具"方向进阶的开发者。

## 前置条件

- 已装好 Node.js（用于 Claude Code 与 MCP Inspector）和 Python 3.10+
- 已读过 MCP 三原语（工具 / 资源 / 提示），能跑通一个 stdio MCP 服务器
- 安装官方 Python SDK：`pip install "mcp[cli]"`
- 已有可用的 Claude Code（用以充当 MCP 客户端验证交互）

## 第一步：理解征询和普通工具参数到底差在哪

一个最容易踩的坑，是把征询当成"另一种写参数的方式"。它俩解决的是**截然不同**的问题。

普通工具参数，由调用方（也就是大模型）在**调用前一次性填好**。模型根据上下文猜：删日志要保留几天？审批人是谁？要不要强制？它猜完了再调你的工具。问题是——这些值模型根本没法可靠地猜，或者根本不该让模型猜。

征询则把"该填什么"的决定权**在执行中途交还给人类用户**。工具发现自己走到了一个模型不该替人做主的岔路口，就主动弹出一个带表单的对话框，把用户的真实回答注入执行流程。

```mermaid
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 服务器：

```bash
mkdir purge-approval-mcp && cd purge-approval-mcp
python -m venv .venv && source .venv/bin/activate
pip install "mcp[cli]" pydantic
```

新建 `server.py`：

```python
# 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` 的结果对象。

```python
# 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 的"受限子集"

征询的 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` 这类高级结构。换句话说，征询表单永远是**扁平的、一层属性**。

如果你这么写，客户端会拒绝或忽略：

```python
# 反例：嵌套对象，规范不支持
class Bad(BaseModel):
    contact: dict   # nested object -> will break the form
```

正确做法是把嵌套拍平，或拆成多次征询。规则很朴素：**一次征询问一组同层的简单字段**。

## 第五步：URL 模式——把敏感操作交给浏览器

除了表单模式，征询还有一种 **URL 模式（url-mode）**，专门处理 OAuth 授权、支付确认这类"必须跳浏览器完成"的敏感流程。服务器给出一个外部链接，客户端引导用户去浏览器完成，再把结果带回来。

官方 SDK 提供了 `ctx.elicit_url()`，以及一种"抛错模式"——当工具**绝对无法**在没有用户授权时继续，就抛 `UrlElicitationRequiredError`，框架会把它转成 `-32042` 错误码，强制客户端走 URL 征询：

```python
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，亲眼看对话框弹出来

Claude Code 对征询是**零配置**支持的：只要服务器声明了 `elicitation` 能力（FastMCP 自动声明），Claude Code 检测到 `elicitation/create` 就会自动弹出交互对话框，把用户填的内容回传给服务器。你不用在客户端做任何额外设置。

把服务器注册进项目配置：

```bash
claude mcp add purge-approval -- python /绝对路径/server.py
```

或手写 `.mcp.json`：

```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] ...`。

**常见问题**：

- **对话框没弹，工具直接报错**：多半是 SDK 版本太旧，不支持 `ctx.elicit`。升级 `pip install -U "mcp[cli]"`。
- **表单字段缺失或顺序乱**：检查 Pydantic 模型字段是否都在"受限子集"内，嵌套结构会被客户端丢弃。
- **客户端没有任何反应**：确认启动命令里的路径是绝对路径，且 `python` 能找到正确的虚拟环境（必要时用 `venv` 里的解释器全路径）。

## 第七步：用 Elicitation 钩子实现自动应答

征询要弹对话框，但**无人值守/CI 场景下根本没有人类来点**。Claude Code 提供了 **Elicitation 钩子（hook）** 来拦截征询请求：在对话框弹出前后跑一段脚本，脚本能观察、修改、甚至直接代答。

钩子收到的输入包含 `mcp_server_name`、`message`、`mode`、`url`、`elicitation_id` 和 `requested_schema` 等字段。表单模式下，你可以读 stdin 上的 JSON，决定是放行、代答还是拦截。

写一个代答脚本 `scripts/auto_approve.sh`：

```bash
#!/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": "ci-bot@example.com", "retention_days": 30 }
  }
}
EOF
```

在 `settings.json` 里挂上钩子（`matcher` 匹配 MCP 服务器名）：

```json
{
  "hooks": {
    "Elicitation": [
      {
        "matcher": "purge-approval",
        "hooks": [{ "type": "command", "command": "scripts/auto_approve.sh" }]
      }
    ]
  }
}
```

钩子退出码的语义：**退出码 0 表示放行/代答**，**退出码 2 表示拒绝**并把 stderr 展示给用户，其它非零码视为错误。这意味着同一段配置在交互模式下由人工确认、在 CI 模式下由钩子代答——**同一份服务器代码，两种运行姿态**。

> 安全警示：对**破坏性操作**做无条件自动放行是危险的反模式。本文的 `auto_approve.sh` 仅作演示，生产中应配合签名校验、白名单字段，或干脆只给只读服务器开自动应答。

## 第八步：让服务器感知项目目录——顺带认识 roots

征询之外，还有一项 Claude Code 明确支持的客户端能力值得知道：**roots（根目录）**。服务器可以调用 `roots/list`，拿到 Claude Code 启动时所在的项目目录；同时 Claude Code 也会在子进程环境里注入 `CLAUDE_PROJECT_DIR` 变量。

这和征询是绝配：工具先用 roots 定位到真实项目根，再在执行危险操作时用征询请人确认。Python 侧直接读环境变量即可：

```python
import os
project_root = os.environ.get("CLAUDE_PROJECT_DIR", ".")
```

这样工具既能"知道自己在哪个仓库里干活"，又能在关键步骤"喊一声人类"。

## 必须说清的一件事：采样（sampling）在 Claude Code 的现状

规范里和征询**方向对称**的另一项能力叫**采样（sampling）**：征询是服务器向用户要数据，采样是服务器**反向调用客户端的大模型**——即 `sampling/createMessage`。它允许工具不自带 API Key，借宿主的 LLM 做摘要、分类、重排等子任务。

但请注意一个**容易踩的坑**：截至本文核对，**Claude Code 官方文档明确把 `elicitation` 与 `roots` 列为支持的客户端能力，却并未把 `sampling` 列入**。这意味着——

- 你用 `ctx.elicit` 写的工具，在 Claude Code 里**能**直接跑通；
- 但如果你想用采样让工具"反向调大模型"，在 Claude Code 里**很可能不被响应**。

规范里采样的请求长这样（仅供理解协议形态）：

```json
{
  "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 完整串联

把前面各步拼成一个可运行的最小项目。目录结构：

```
purge-approval-mcp/
├── server.py
├── scripts/
│   └── auto_approve.sh
└── .mcp.json
```

`server.py` 即第三步的完整代码。`scripts/auto_approve.sh` 即第七步的脚本（记得 `chmod +x`）。`.mcp.json` 指向 `server.py`。

完整工作流演示：

1. **交互模式**：开发者对 Claude Code 说"清理 myrepo 超 90 天日志，真删"。工具触发征询 → 对话框弹出 → 开发者勾选确认、填邮箱 → 工具返回 `[DELETED] ...`。
2. **拒绝路径**：同上但开发者点"拒绝" → 工具返回 `[BLOCKED] ...`，模型如实汇报"删除已被拒绝"。
3. **无人值守模式**：在 CI 里跑同一工具，Elicitation 钩子自动代答 → 工具拿到预设答案执行 → 流水线不被对话框阻塞。
4. **dry-run 安全网**：模型先调 `dry_run=true` 看影响面，确认无误再调 `dry_run=false` 触发征询。

这个项目虽然小，却完整覆盖了征询的核心价值：**把"是否执行不可逆操作"的最终决定权，从模型手里夺回，交还给人。**

## 常见问题速查

- **征询和提示词资源（prompts）有啥区别？** prompts 是用户**主动**触发的预设模板，征询是**工具执行中**被动发起的追问。前者面向会话起点，后者嵌入工具流程。
- **一次工具调用能征询多次吗？** 可以。`ctx.elicit` 是普通 await，按需多次调用即可，例如"第一问确认→第二问填理由"的分步表单。
- **征询能要密码、密钥吗？** 规范**明确禁止**用征询索取敏感信息。敏感凭证走 URL 模式（OAuth）或环境变量注入，绝不要塞进征询表单。
- **表单字段太多怎么办？** 规范限制是扁平结构。字段超过 5~7 个就该考虑拆成多步征询，或重新审视这个工具是否该一次性问这么多。

## 延伸阅读

- [MCP Elicitation 规范（2025-06-18）](https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation) — 征询的协议级权威定义、受限 schema 子集
- [MCP Sampling 规范（2025-06-18）](https://modelcontextprotocol.io/specification/2025-06-18/client/sampling) — 与征询对称的反向 LLM 调用协议
- [Claude Code Hooks 文档](https://code.claude.com/docs/en/hooks) — Elicitation 钩子的输入字段与退出码语义
- [MCP Python SDK elicitation 示例](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/elicitation.py) — 官方 form/url 模式与抛错模式的参考实现
- [A Practical Guide to MCP Elicitation with FastAPI + FastMCP](https://medium.com/data-science-collective/a-practical-guide-to-mcp-elicitation-with-fastapi-fastmcp-65f57fd91896) — 第三方实战补充

## 训练营适用性评估

本教程定位**中阶**，适合"MCP 进阶开发训练营"或"AI Agent 安全工程训练营"。学员应已掌握 MCP 三原语，具备 Python 异步基础。建议教学时长 **2.5~3 小时**：40 分钟讲清征询与普通参数的本质差异和受限 schema，60 分钟手把手写 purge-approval 工具并接入 Claude Code 验证对话框，45 分钟讲解 Elicitation 钩子与无人值守代答（含安全讨论），30 分钟讨论 roots 协同与 sampling 现状。学员产出物：一个会向人类请示再删数据的破坏性操作审批工具，附带 CI 自动应答配置。可作为后续"采样与多步 Agent"高级课的前置。

## 参考来源

1. [Elicitation — Model Context Protocol（2025-06-18 规范）](https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation) — 征询协议、受限 JSON Schema 子集、accept/decline/cancel 三态定义
2. [Sampling — Model Context Protocol（2025-06-18 规范）](https://modelcontextprotocol.io/specification/2025-06-18/client/sampling) — sampling/createMessage 协议形态与人在回路约束
3. [Connect Claude Code to tools via MCP](https://code.claude.com/docs/en/mcp) — Claude Code 对 elicitation、roots/list 的客户端支持声明
4. [Claude Code Hooks — Elicitation hook](https://code.claude.com/docs/en/hooks) — 钩子输入字段、matcher、退出码语义
5. [MCP Python SDK — elicitation 示例](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/elicitation.py) — 官方 ctx.elicit / ctx.elicit_url / UrlElicitationRequiredError 实现

> [!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)
