<!-- title: MCP Elicitation：服务器执行中向用户要输入 -->

# 让 MCP 服务器在执行中"反向"向用户要输入：Elicitation 实战

## 概述

本教程聚焦 MCP（Model Context Protocol）2025-06-18 规范引入的最新原语——**Elicitation（征询）**。它打破了"客户端问、服务器答"的单向工具调用范式，让 MCP 服务器在工具执行过程中**主动向用户请求结构化输入**，实现人在回路（human-in-the-loop）的交互式工具。学完本教程，你将能用 FastMCP 构建一个需要人类审批、分步收集信息、支持二次确认的可交互 MCP 服务器，并在 Claude Code 中挂载运行。适用于已掌握基础 MCP 工具开发、希望构建高破坏性操作护栏与交互式工作流的中高级开发者。

## 前置条件

- **基础**：能独立写出一个返回字符串的 MCP `@mcp.tool`，理解 JSON-RPC over stdio 的基本通信模型。
- **环境**：Python 3.11+；推荐用 `uv`（一个命令搞定虚拟环境与依赖）；Claude Code CLI 或 MCP Inspector 用于联调。
- **依赖**：`fastmcp>=2.10.0`（Elicitation 服务端 API 自该版本引入；结构化字段标题等特性在 3.3.0+）。

---

## 第 1 步：理解 Elicitation 的"反向"数据流

传统 MCP 工具是**同步收口**的：调用方（LLM）在 `tools/call` 时一次性把所有参数塞满，服务器跑完返回结果。很多真实场景这套模型兜不住：

- 删除生产数据库前要人点"确认"——但工具已经被调起来了，参数已齐备，没有"暂停下来问一句"的通道。
- 参数太多， upfront 全问一遍体验差，最好是**渐进式披露**，按上一轮答案决定下一轮问什么。
- 某些选择（如选哪个环境部署）由人类拍板，不该交给 LLM 猜。

Elicitation 就是为这些场景设计的。它本质是一条**反向 JSON-RPC 请求**：服务器在处理 `tools/call` 的过程中，向客户端发 `elicitation/create`，客户端把它翻译成一个用户可见的表单/确认框，用户填完后客户端把答案回传，服务器继续执行。

关键认知：**发起方是服务器，但交互主权在客户端**。客户端负责渲染 UI、做隐私提示、提供"拒绝/取消"按钮，并控制是否响应。服务器无权绕过客户端直接弹窗。

消息流（规范官方时序图）：

```mermaid
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 都是它的语法糖。

---

## 第 2 步：搭建项目骨架

用 `uv` 初始化一个最小工程：

```bash
mkdir mcp-elicitation && cd mcp-elicitation
uv init --python 3.11
uv add "fastmcp>=2.10"
```

创建 `server.py`，先写一个不交互的"hello"工具，确认链路通：

```python
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+ |

---

## 第 3 步：第一次 `ctx.elicit`——纯确认型

最简单的征询是**不要数据、只要一个"是否同意"**。把 `response_type` 设为 `None`，用户点"确认"即返回 `accept`：

```python
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 is not supported"**：说明当前客户端没声明 `elicitation` capability。换 Claude Code / Claude Desktop / VS Code（Copilot Chat）这类已实现的客户端即可。规范要求支持方在初始化时声明 `"capabilities": {"elicitation": {}}`。
- **想自己改字段标签**：纯确认型无需标签，跳到第 4 步看结构化字段。

---

## 第 4 步：结构化数据收集（多字段表单）

确认型只够点"是/否"。要一次收多个字段，把 `response_type` 设为一个 **Pydantic 模型**（也支持 dataclass / TypedDict）：

```python
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}）"
```

注意三处规范约束，踩坑高发：

1. **只支持浅层对象**：`requestedSchema` 在协议层被限制为"扁平对象 + 原始属性"，不支持嵌套对象、对象数组。FastMCP 会校验，嵌套结构会直接报错。
2. **字段元数据用 `Field(title=, description=)`**：客户端（如 VS Code）会把 `title` 直接当输入框标签渲染，写好 `title` 体验差别巨大。
3. **数值约束照常生效**：`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/枚举）包装时生效。

---

## 第 5 步：约束选项、枚举与多选

让用户从有限选项里挑，是征询最高频的形态。FastMCP 支持多种写法：

```python
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` 模式：

```python
@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 "未选择"
```

**预期结果**：客户端渲染出下拉/单选/复选控件，而非自由文本框，降低输入错误。

---

## 第 6 步：多轮编排——按上一轮答案决定下一轮

Elicitation 真正的威力在**串行多轮**：一次工具调用里发起多次征询，逐步收敛。下面是一个会议安排工具，三轮收集标题、时长、是否紧急：

```python
@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` 就该优雅退出，**不要**继续向下征询，否则会连环弹窗骚扰用户。

---

## 第 7 步：用模式匹配干净地处理三态

`if/elif` 在多分支里会显得啰嗦。FastMCP 提供三个类型化结果类，配合 Python 3.10+ 的 `match` 语法非常清爽：

```python
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` 是**中途放弃**（通常重试或静默退出）。

---

## 第 8 步：在 Claude Code 中挂载并实跑

服务端写好了，接入 Claude Code。在项目根目录建 `.mcp.json`（团队共享）或用 `claude mcp add`（写入用户配置）：

```json
{
  "mcpServers": {
    "interactive-server": {
      "command": "uvx",
      "args": ["--from", "fastmcp", "fastmcp", "run", "/绝对路径/server.py"]
    }
  }
}
```

随后在 Claude Code 中：

```
> 帮我排一个会议，主题是"Q3 复盘"
```

模型会调用 `plan_meeting`，触发第一轮征询弹窗；你填完点确认，第二轮自动弹出，依此类推。**全程你无需在 prompt 里交代细节**——这正是 Elicitation 相比"把所有参数塞进 prompt"的体验优势。

**常见问题**：

- **看不到弹窗，工具直接返回**：确认 Claude Code 版本支持 elicitation（较新版本已支持）；老版本会静默把 `ctx.elicit` 当作不支持而报错。
- **路径用相对路径不生效**：stdio 启动的工作目录不一定是你以为的，`.mcp.json` 里 `args` 的脚本路径建议用绝对路径。

---

## 第 9 步：用 MCP Inspector 联调

开发期别在 Claude Code 里盲调。FastMCP 内置一键拉起官方 Inspector：

```bash
uv run fastmcp dev server.py
```

浏览器打开 Inspector，左侧选 `Tools` → 点目标工具 → `Call Tool`。当工具走到 `ctx.elicit`，Inspector 会渲染出与真实客户端一致的表单，你可以手动填、手动点 decline/cancel，观察服务器对三态的响应。这是定位"参数没传进去""约束没生效"最快的方式。

如果想看**协议层**到底传了什么，开 Inspector 的日志，你会看到服务器发出的 `elicitation/create` 原始报文：

```json
{
  "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"]
    }
  }
}
```

以及客户端回的三态之一：

```json
{ "jsonrpc": "2.0", "id": 1, "result": { "action": "accept", "content": { "service": "billing", "env": "prod" } } }
```

对照报文调 schema，比猜 API 直观百倍。

---

## 实战案例：变更审批守卫（完整可运行）

把前 9 步合起来，做一个 DevOps 场景的**变更审批 MCP 服务器**：模型提交变更摘要 → 人类确认 → 收集结构化详情 → 若命中生产环境则二次确认 → 全程写审计日志。这是一个真实可落地的"高破坏性操作护栏"。

```python
# 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 红线，写服务器时必须内化：

1. **绝不通过征询收集敏感信息**（密码、私钥、支付凭证）。征询表单的隐私边界由客户端控制，且会留痕，不适合承载机密。
2. **客户端应允许用户随时拒绝/取消**，服务器不得把拒绝当作错误重试轰炸。
3. **客户端应做速率限制**——服务器若滥用 `elicitation/create` 高频弹窗，客户端有权熔断。
4. **双方都应对 schema 做校验**，不要无条件信任对端回传的 `content`。
5. 把征询用于"破坏性操作的二次确认"是最佳实践（如本例的 prod 二次确认），用它来收集口令则是反模式。

---

## 训练营适用性评估

- **适用类型**：MCP 工具开发进阶训练营、LLM Agent 工程化训练营、DevOps 自动化训练营中"人在回路审批"模块。
- **教学时长**：建议 **3~4 学时**。第 1~2 步概念与环境（0.5h）、第 3~7 步五种征询形态动手练（1.5h）、第 8~9 步客户端联调（0.5h）、实战案例完整实现与联调（1~1.5h）。
- **前置衔接**：本主题排在"基础 MCP 工具开发""MCP 三原语（工具/资源/提示词）"之后最佳；后续可接"MCP Sampling（服务器反向调用客户端 LLM）""远程 MCP 与 OAuth"形成完整进阶线。
- **评估方式**：要求学员独立实现一个"删除前确认 + 结构化收集 + 审计日志"的工具，并通过 Inspector 演示三态响应，是验收是否真正掌握的硬指标。

## 延伸阅读

- **MCP Sampling**：与 Elicitation 并列的另一条反向原语——服务器请求客户端的 LLM 代为推理，适合"服务器需要 AI 判断"而非"需要人类输入"的场景。
- **远程 MCP 与 Streamable HTTP**：把本例的 stdio 服务器搬到远程，配合 OAuth2.1 实现企业级托管。
- **SEP-1330 表单模式**：理解 `oneOf`/`const`/`title` 如何让客户端生成更友好的下拉控件。
- **MCP Inspector 源码**：官方调试器，是学习协议报文细节的最佳现场。

## 参考来源

1. [Elicitation — Model Context Protocol 官方规范（2025-06-18）](https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation) — Elicitation 原语的权威定义，含 JSON-RPC 报文、三态响应模型、schema 约束与安全考量。
2. [User Elicitation — FastMCP 服务端文档](https://gofastmcp.com/servers/elicitation) — `ctx.elicit()` API、response_type 类型系统（纯量/结构化/枚举/多选）、类型化结果类与模式匹配用法。
3. [modelcontextprotocol/specification（GitHub 仓库）](https://github.com/modelcontextprotocol/modelcontextprotocol) — 规范源文件，可对照阅读 `elicitation.mdx` 原始 MDX。
4. [PrefectHQ/fastmcp（GitHub 仓库）](https://github.com/PrefectHQ/fastmcp) — FastMCP 实现，`docs/servers/elicitation.mdx` 含版本演进（2.10 引入、2.14 多选、3.3 字段标题）。
5. [MCP Elicitation Tutorial: Human-in-the-Loop FastMCP — MaximoFN](https://www.maximofn.com/en/mcp-elicitation/) — 第三方实战教程，补充端到端可运行示例与客户端配合细节。

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