<!-- title: 用Elicitation给MCP工具加确认护栏 -->

## 概述

本教程聚焦 MCP 协议中 2025 年新增的 **Elicitation（信息启发）** 能力——让工具服务器在执行过程中主动向用户收集结构化输入。学完你将掌握：三种结果模型（accept/decline/cancel）的语义、JSON Schema 表单的设计约束、用 FastMCP 构建「确认护栏」工具的完整代码，以及用 MCP Inspector 与 Claude Code 联调的流程。适合已能跑通基础 MCP 服务器、想给危险操作（删库、部署、发邮件）加人机确认环节的中级开发者。

## 前置条件

- 已跑通过至少一个 MCP 服务器（任意语言），理解 tools/resources/prompts 三个基础原语
- Python 3.11+，会使用 `uv` 或 `pip` 管理虚拟环境
- 安装 Node.js 18+（用于运行 MCP Inspector）
- Claude Code 或 Claude Desktop 客户端（用于真实接入测试）

## 一、为什么需要 Elicitation：从单向调用到双向对话

传统的 MCP 工具调用是**单向**的：客户端（LLM）决定调用哪个工具、传什么参数，服务器只能默默执行并返回结果。这个模型在查询类操作上很好用，但在**有副作用的危险操作**上会出大问题——LLM 可能基于一段模糊的需求，自信地构造出 `drop table users` 这样的调用，而服务器没有任何机会在执行前停下来问一句「你确定吗？」。

在没有 Elicitation 之前，开发者只能在工具内部硬编码逻辑：要么拒绝执行并返回错误让 LLM 转告用户，要么用环境变量做总开关。前者用户体验割裂（错误信息和真正的确认在两个通道里），后者粒度太粗。更关键的是，**参数补全**这件事无法优雅完成——比如部署工具需要 `approval_level`（标准/紧急），但 LLM 手里没有这个信息，只能瞎猜或反复试错。

Elicitation 正是为这两类场景设计的：服务器在工具执行到一半时，**通过客户端向用户弹出一个结构化表单**，等用户填写并提交后，服务器再决定是否继续。整个流程对 LLM 是透明的（它看到的是工具最终返回的结果），但控制权回到了人和服务器手里。

理解 Elicitation 之前，先把它和另外两个「客户端能力（client capabilities）」放在一起看，这是 MCP 协议中三个让交互变得双向的机制：

| 能力 | 方向 | 作用 | 典型场景 |
|------|------|------|----------|
| **Sampling** | 服务器 → 客户端的 LLM | 服务器请求宿主大模型生成一段文本 | 工具内部需要对数据做摘要、分类、翻译 |
| **Elicitation** | 服务器 → 终端用户 | 服务器请求用户填写结构化表单 | 危险操作确认、补全缺失参数、OAuth 授权 |
| **Roots** | 客户端 → 服务器 | 客户端告知服务器当前可访问的目录/URI 范围 | 文件类服务器尊重作用域边界 |

三者都是「服务器反客为主」的能力，其中 **Elicitation 是唯一新增、且与人类用户直接交互的**。本教程深入这一项。

## 二、三种结果模型：accept / decline / cancel

Elicitation 的核心是一个请求-响应回合，但它的响应不是简单的「填好的数据」，而是三种离散状态。这是初学者最容易踩坑的地方——很多人只处理了「用户提交」这一种情况，导致用户点「拒绝」时服务器崩溃或继续执行。

服务器发出 `elicitation/create` 请求后，客户端会渲染一个表单。用户面对这个表单时有三种行为，对应三种响应：

1. **accept（接受）**：用户填完表单并提交。此时响应里带有 `content` 字段，里面是经过 schema 校验的数据。
2. **decline（拒绝）**：用户明确点击「拒绝/否」。这是一种**主动的否定决策**，响应只有 `action: "decline"`，没有数据。它和「取消」语义不同——decline 意味着「我知道你要干什么，我不同意」。
3. **cancel（取消）**：用户关闭弹窗、点击空白处、或者直接走开。这是一种**被动的中止**，响应是 `action: "cancel"`。

关键认知：**decline 和 cancel 不是错误**。它们是协议层定义的合法结果。如果你把用户拒绝当成异常抛出，会让 LLM 困惑（它会以为工具坏了，然后重试）。正确的做法是把三种结果都映射成工具的自然返回值，例如返回「用户已拒绝删除操作，未执行任何更改」。

底层的协议报文长这样（你用 FastMCP 时会被封装，但理解原始结构对调试有帮助）：

```json
// 服务器 → 客户端：发起启发
{
  "method": "elicitation/create",
  "params": {
    "message": "即将删除文件 /data/production.db，请确认",
    "requestedSchema": {
      "type": "object",
      "properties": {
        "confirm": { "type": "boolean", "title": "确认删除" },
        "reason":  { "type": "string",  "title": "删除原因" }
      },
      "required": ["confirm"]
    }
  }
}

// 客户端 → 服务器：三种可能的响应
{ "result": { "action": "accept",  "content": { "confirm": true, "reason": "清理过期数据" } } }
{ "result": { "action": "decline" } }
{ "result": { "action": "cancel"  } }
```

## 三、Schema 设计的约束：表单不是任意 JSON

Elicitation 的表单由 `requestedSchema` 描述，但它**不是完整的 JSON Schema 实现**。MCP 规范明确要求客户端支持一个受限的子集，目的是保证表单能在不同宿主（桌面应用、终端、Web）上一致渲染。设计 schema 时要记住三条铁律：

**第一，保持扁平与基础类型。** 推荐只用 `string`、`number`、`integer`、`boolean` 和带 `enum` 的字符串。避免深层嵌套对象、避免 `oneOf`/`anyOf` 这类组合关键字——很多客户端的表单渲染器不支持，会直接退化成空白或报错。

**第二，善用 `enum` 做枚举选择。** 当你希望用户在固定选项里选（比如审批级别「标准/紧急/回滚」），用 `enum` 比让用户手敲字符串安全得多，既防错又体验好。

**第三，把数据当不可信输入处理。** 规范反复强调：服务器**不能假设**用户填写的内容可信。即使有 schema 校验，也要在服务器侧对收到的值做二次验证（尤其是会被拼进命令或 SQL 的字段），防止用户绕过客户端直接构造请求注入恶意数据。这一点和工具参数注入防护是同一套思路。

下面这个对比能帮你快速判断 schema 是否合格：

```jsonc
// ✅ 合格：扁平、基础类型 + enum
{
  "type": "object",
  "properties": {
    "confirm":   { "type": "boolean", "title": "我确认执行此操作" },
    "ticket":    { "type": "string",  "title": "关联工单号" },
    "priority":  { "type": "string",  "enum": ["standard", "urgent"] }
  },
  "required": ["confirm"]
}

// ❌ 不合格：嵌套对象 + 组合关键字，多数客户端无法渲染
{
  "type": "object",
  "properties": {
    "target": {
      "type": "object",
      "properties": { "table": { "type": "string" } }
    },
    "options": { "oneOf": [ {"type": "string"}, {"type": "number"} ] }
  }
}
```

## 四、实战：构建 safe-ops 确认护栏服务器

接下来构建一个名为 `safe-ops` 的 MCP 服务器，提供两个带护栏的工具：一个普通确认型（删文件），一个高危型需要**输入文本匹配**才能放行（删表）。完整项目结构如下：

```
safe-ops-mcp/
├── pyproject.toml
└── server.py
```

### 步骤 1：初始化项目

用 `uv` 创建项目（也可以用 pip + venv，效果相同）：

```bash
mkdir safe-ops-mcp && cd safe-ops-mcp
uv init --python 3.11
uv add "fastmcp>=2.3.0" pydantic
```

`pyproject.toml` 中确认依赖已写入。Elicitation 是 FastMCP 2.x 的内置能力，不需要额外安装。

### 步骤 2：编写服务器骨架与第一个确认型工具

创建 `server.py`。第一个工具 `delete_file` 实现最经典的确认护栏：执行前弹出表单，要求用户勾选确认框并可选填原因。

```python
# server.py
from pathlib import Path
from fastmcp import FastMCP, Context
from pydantic import BaseModel, Field

mcp = FastMCP("safe-ops")

# ---- 1. 定义启发表单的数据模型（会自动转成 requestedSchema）----
class DeleteConfirmation(BaseModel):
    confirm: bool = Field(
        default=False, description="我已知晓此操作不可恢复"
    )
    reason: str | None = Field(
        default=None, description="删除原因（选填，用于审计）"
    )


@mcp.tool
async def delete_file(path: str, ctx: Context) -> str:
    """删除指定文件，执行前需用户在表单中确认。"""
    target = Path(path)

    # 前置校验：文件不存在就直接返回，不必打扰用户
    if not target.exists():
        return f"文件不存在，无需删除：{path}"

    # ---- 2. 发起启发，弹出表单 ----
    result = await ctx.elicit(
        message=f"即将删除文件 {target.resolve()}，此操作不可恢复。",
        schema=DeleteConfirmation,
    )

    # ---- 3. 分支处理三种结果 ----
    if result.action == "decline":
        return "用户已拒绝删除，未执行任何操作。"
    if result.action == "cancel":
        return "确认流程被取消，未执行删除。"
    if result.action == "accept":
        data: DeleteConfirmation = result.data
        if not data.confirm:
            # 用户提交了表单但没有勾选确认框
            return "未勾选确认框，已中止删除。"
        # 二次校验：确认框只是 UI 提示，真正的删除前再检查一次状态
        if not target.exists():
            return "文件已被其他进程移除。"
        target.unlink()
        audit = f"，原因：{data.reason}" if data.reason else ""
        return f"已删除 {path}{audit}。"

    # 理论上不会走到这里，防御性返回
    return "未知响应，操作中止。"


if __name__ == "__main__":
    mcp.run()  # 默认 stdio 传输
```

逐段拆解这段代码的关键点：

- **`schema=DeleteConfirmation`**：传入一个 Pydantic 模型，FastMCP 自动把它转成 MCP 规范要求的 `requestedSchema`，省去手写 JSON Schema。
- **`result.action`**：必须是三值分支，缺一不可。漏掉 `decline` 或 `cancel`，用户拒绝时 `result.data` 为 `None`，访问即报错。
- **`result.data` 的类型**：当 `action == "accept"` 时，`data` 已是 `DeleteConfirmation` 实例（FastMCP 帮你校验并反序列化），可以直接用属性访问。
- **双重校验**：表单里的 `confirm` 字段只是 UX 提示，服务器**不能信任**它。真正决定删不删的，是服务器自己再读一次 `result.data.confirm`，以及删除前再 `exists()` 一次。这两步防御是「表单即不可信输入」原则的落地。

预期行为：用户在 Claude 里说「删除 /tmp/old.log」，LLM 调用 `delete_file`，用户看到表单。勾选并提交 → 文件删除、返回成功；点拒绝 → 返回「用户已拒绝」。LLM 拿到的是一句自然语言结果，全程不需要理解表单细节。

### 步骤 3：高危工具的 type-to-confirm 模式

对于真正危险的操作（删表、格式化、生产部署），单纯的勾选框还不够——用户可能在弹窗上肌肉记忆地点确认。业界通用的更强护栏是 **type-to-confirm（输入文本匹配才能放行）**：要求用户手动输入目标名称，只有完全匹配才执行。下面的 `drop_table` 工具实现这个模式。

```python
class DropTableConfirmation(BaseModel):
    typed_name: str = Field(
        description="请输入要删除的表名以确认（必须完全一致）"
    )
    approver: str = Field(description="操作人/审批人姓名")


@mcp.tool
async def drop_table(table: str, ctx: Context) -> str:
    """删除数据库表，需用户输入完整表名二次确认。"""
    result = await ctx.elicit(
        message=(
            f"⚠️ 高危操作：即将删除表 `{table}`。\n"
            f"请在下方输入框中完整输入表名 `{table}` 以确认。"
        ),
        schema=DropTableConfirmation,
    )

    if result.action != "accept":
        return f"未确认，已中止删除表 {table}。"

    data: DropTableConfirmation = result.data
    # 核心护栏：输入文本必须与目标表名严格相等
    if data.typed_name.strip() != table:
        return (
            f"输入的名称 `{data.typed_name}` 与目标表名 `{table}` 不符，"
            f"已拒绝执行。"
        )

    # ===== 到这里才真正执行删除 =====
    # execute_drop(table)  ← 替换为你的真实数据库调用
    return f"已删除表 {table}，审批人：{data.approver}。"
```

`strip()` 处理用户输入的首尾空格（改善体验），但核心比较仍是严格相等。这个模式把「误触」的概率压到极低——LLM 可以调用工具，但只有真人在表单里准确打出表名，删除才会发生。

### 步骤 4：补全缺失参数的用法

第三种典型用法不是确认，而是**收集 LLM 没有的信息**。部署工具常常需要「回滚预案」字段，而 LLM 的上下文里没有。用 Elicitation 现场收集：

```python
class DeployConfig(BaseModel):
    approval_level: str = Field(
        default="standard",
        description="审批级别",
    )
    rollback_plan: str = Field(description="回滚预案摘要")


@mcp.tool
async def deploy(service: str, ctx: Context) -> str:
    """部署服务，需用户提供审批级别与回滚预案。"""
    result = await ctx.elicit(
        message=f"部署服务 {service} 前请填写部署信息。",
        schema=DeployConfig,
    )
    if result.action != "accept":
        return f"部署 {service} 已取消。"

    data: DeployConfig = result.data
    if data.approval_level == "urgent" and len(data.rollback_plan) < 10:
        return "紧急部署必须提供详细的回滚预案（至少 10 字）。"

    # deploy_service(service, data)  ← 真实部署
    return f"已提交 {service} 部署（{data.approval_level}），回滚预案已记录。"
```

注意这里的**业务规则校验**写在服务器里（紧急部署必须详细回滚预案），而不是依赖客户端 schema——客户端校验只是第一道筛，业务规则必须在服务器权威执行。

## 五、用 MCP Inspector 联调

在接入 Claude Code 前，先用官方的 MCP Inspector 隔离测试，因为它对 Elicitation 的支持最完整、最适合排错。

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

Inspector 会启动一个本地 Web UI（默认 `http://localhost:6274`）。操作流程：

1. 在左侧 **Tools** 列表点选 `delete_file`。
2. 填入 `path` 参数（指向一个真实存在的测试文件）。
3. 点 **Run Tool**。Inspector 会弹出一个表单——这就是 `requestedSchema` 渲染出来的结果。
4. 分别测试三种交互：勾选并提交、点拒绝、直接关闭弹窗，观察返回值是否符合预期。

常见排查点：

- **表单空白或字段缺失**：99% 是 schema 用了不支持的类型（嵌套对象/组合关键字）。回到第三节检查。
- **提交后报 schema 校验错**：`required` 数组里有字段，但客户端渲染时没给用户填写入口。把字段都设上 `default` 可缓解。
- **三个 action 只返回一种**：检查你是否真的写了三分支 `if`，而不是只判断 `accept`。

## 六、接入 Claude Code

确认 Inspector 下行为正确后，把服务器注册到 Claude Code。在项目根目录用 CLI 添加：

```bash
claude mcp add safe-ops -- uv run --directory /绝对路径/safe-ops-mcp server.py
```

`--directory` 必须用绝对路径，相对路径在某些 shell 下会解析失败。添加后用 `claude mcp list` 确认 `safe-ops` 状态为 connected。

然后直接用自然语言测试：「帮我删除 /tmp/test.log」。你会看到 Claude 调用工具后，界面弹出确认表单——这就是 Elicitation 在真实客户端里的表现。点拒绝，Claude 会基于返回值自然地回复「好的，没有删除」，而不是报错重试。

如果你用的是 `.mcp.json` 配置文件方式，对应写法是：

```json
{
  "mcpServers": {
    "safe-ops": {
      "command": "uv",
      "args": ["run", "--directory", "/绝对路径/safe-ops-mcp", "server.py"]
    }
  }
}
```

## 七、常见问题与解决方案

**Q1：客户端不支持 Elicitation 怎么办？**
Elicitation 是客户端可选能力。如果接入的客户端不支持，调用 `ctx.elicit` 会抛出能力缺失错误。生产代码应捕获这个错误并降级——例如回退到「默认拒绝执行危险操作」或「要求 LLM 自己向用户询问」。不要让缺能力的客户端直接崩溃。

**Q2：Elicitation 会阻塞工具多久？**
它会阻塞到用户响应或超时。不要在同一个工具里串行发起多次 Elicitation（用户体验差且容易超时），尽量用一次表单收集所有需要的字段。

**Q3：能否用 Elicitation 收集密码/密钥？**
规范明确提供了「URL 流」模式用于 OAuth 等敏感数据——这种场景下不应通过结构化表单收集明文密钥，而应让客户端跳转到浏览器完成授权。密码、token 类敏感输入走 URL 流，普通业务字段走表单。

**Q4：表单数据可信吗？**
不可信。无论客户端做了什么校验，服务器都必须把收到的值当作用户可构造的原始输入，做转义、参数化、白名单校验后再使用。这一条和工具参数防注入是同一个原则。

**Q5：Elicitation 和「让 LLM 用 AskUserQuestion 问用户」有什么区别？**
AskUserQuestion 是 LLM 主导的、非结构化的多轮提问；Elicitation 是服务器主导的、带 schema 校验的原子化表单。前者适合开放式澄清，后者适合「执行前一次性确认+收集」的确定性流程。两者正交互补。

## 实战案例回顾

本教程的 `safe-ops` 服务器浓缩了 Elicitation 的三种典型用法：

- **`delete_file`** —— 普通确认护栏（勾选框 + 可选原因）
- **`drop_table`** —— 高危 type-to-confirm（输入文本严格匹配）
- **`deploy`** —— 参数补全 + 业务规则校验（审批级别 + 回滚预案）

三个工具覆盖了真实生产里 90% 的「人机确认」需求。你可以在此基础上扩展：给文件服务器批量操作加「逐项确认」、给 CI/CD 工具加「生产环境双人审批」、给数据导出工具加「脱敏范围选择」。核心都是同一套三分支模式 + schema 设计 + 服务器侧二次校验。

## 工作流程图

下图展示一次 Elicitation 确认护栏的完整时序——从 LLM 触发工具，到用户响应，再到服务器执行或中止：

```mermaid
sequenceDiagram
    participant LLM as LLM (客户端)
    participant Server as safe-ops 服务器
    participant User as 终端用户

    LLM->>Server: 调用工具 delete_file(path)
    Server->>Server: 前置校验（文件是否存在）
    Server->>LLM: elicitation/create（弹出表单 + schema）
    LLM->>User: 渲染表单：确认框 + 原因
    alt 用户提交（accept）
        User->>LLM: 填写并提交
        LLM->>Server: action=accept, content={confirm, reason}
        Server->>Server: 二次校验 confirm 为真
        Server->>Server: 执行删除
        Server->>LLM: 返回「已删除」
    else 用户拒绝（decline）
        User->>LLM: 点击拒绝
        LLM->>Server: action=decline
        Server->>LLM: 返回「用户已拒绝」
    else 用户取消（cancel）
        User->>LLM: 关闭弹窗
        LLM->>Server: action=cancel
        Server->>LLM: 返回「流程已取消」
    end
    LLM->>User: 自然语言汇报结果
```

下面这张流程图聚焦服务器内部对三种结果的分支决策，方便你在写代码时对照：

```mermaid
flowchart TD
    A[工具被调用] --> B{前置校验通过?}
    B -- 否 --> R1[直接返回前置失败]
    B -- 是 --> C[发起 elicitation]
    C --> D{用户响应}
    D -- accept --> E{schema 二次校验}
    E -- 通过 --> F[执行真实操作]
    F --> G[返回成功 + 审计信息]
    E -- 不通过<br/>如未勾选/文本不匹配 --> R2[返回已中止]
    D -- decline --> R3[返回用户已拒绝]
    D -- cancel --> R4[返回流程已取消]

    style F fill:#fee,stroke:#c33
    style C fill:#eef,stroke:#33c
```

## 延伸阅读

- **Sampling 能力**：本教程聚焦 Elicitation，Sampling（服务器反向调用宿主 LLM）是姊妹能力，适合实现「工具内部对数据做摘要/分类」。官方规范：`modelcontextprotocol.io/specification/2025-06-18/client/sampling`
- **Roots 作用域**：文件类服务器应配合 Roots，只在客户端声明的目录范围内操作，与确认护栏配合可形成纵深防御。
- **OAuth URL 流**：敏感数据收集走 URL 流而非表单，详见 MCP 规范的 Authorization 章节。
- **MCP Inspector 源码**：研究 Inspector 如何渲染表单，能帮你写出兼容性最好的 schema。

## 训练营适用性评估

- **适用类型**：企业内部 AI 工程训练营、MCP 进阶专题营、运维/DBA 自动化安全训练营
- **学员基础**：需已完成 MCP 基础篇（能独立跑通一个 tools 服务器），否则 Elicitation 的价值难以体会
- **预计教学时长**：4 课时（讲解 1 + 跟练编码 2 + Inspector/Claude 联调与排错 1）
- **作业建议**：要求学员把一个自己写过的「无护栏工具」改造为带 type-to-confirm 的版本，并提交三种 action 的测试截图
- **难度定位**：⭐⭐⭐（中等偏上），难点不在代码量，而在「三分支思维」与「表单即不可信输入」的安全意识

## 参考来源

1. [Elicitation — Model Context Protocol Specification](https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation) — MCP 官方 Elicitation 规范，定义 create 请求、三种 action 结果与 schema 约束
2. [Sampling — Model Context Protocol Specification](https://modelcontextprotocol.io/specification/2025-06-18/client/sampling) — MCP 官方 Sampling 规范，本文用于对照三种客户端能力
3. [MCP Roots & Elicitation: Context & User Input — DevShelfHub](https://www.devshelfhub.com/tutorials/mcp/roots-elicitation/) — Roots 与 Elicitation 的对照讲解与表单/OAuth 区分
4. [Beyond Tool Calling: A Practical Tour of Advanced MCP Concepts — Akshay Parkhi](https://www.akshayparkhi.net/2026/Apr/9/beyond-tool-calling-a-practical-tour-of-advanced-mcp-concepts/) — 采样/启发/通知/根的实战综述
5. [MCP Sampling, Elicitation, and Tasks — TutorialsLogic](https://www.tutorialslogic.com/mcp/sampling-elicitation-tasks) — 双向与延迟工作流的概念串讲

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