首页 › tutorials › 用Elicitation给MCP工具加确认护栏

概述

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

前置条件

一、为什么需要 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 时会被封装,但理解原始结构对调试有帮助):

// 服务器 → 客户端:发起启发
{
  "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 是否合格:

// ✅ 合格:扁平、基础类型 + 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,效果相同):

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 实现最经典的确认护栏:执行前弹出表单,要求用户勾选确认框并可选填原因。

# 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 传输

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

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

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

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

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 现场收集:

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 的支持最完整、最适合排错。

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. 分别测试三种交互:勾选并提交、点拒绝、直接关闭弹窗,观察返回值是否符合预期。

常见排查点:

六、接入 Claude Code

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

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 配置文件方式,对应写法是:

{
  "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 的三种典型用法:

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

工作流程图

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

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: 自然语言汇报结果

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

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

延伸阅读

训练营适用性评估

参考来源

  1. Elicitation — Model Context Protocol Specification — MCP 官方 Elicitation 规范,定义 create 请求、三种 action 结果与 schema 约束
  2. Sampling — Model Context Protocol Specification — MCP 官方 Sampling 规范,本文用于对照三种客户端能力
  3. MCP Roots & Elicitation: Context & User Input — DevShelfHub — Roots 与 Elicitation 的对照讲解与表单/OAuth 区分
  4. Beyond Tool Calling: A Practical Tour of Advanced MCP Concepts — Akshay Parkhi — 采样/启发/通知/根的实战综述
  5. MCP Sampling, Elicitation, and Tasks — TutorialsLogic — 双向与延迟工作流的概念串讲

[!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学通信知识星球海报-扫码加入