首页 › tutorials › 造会追问用户的MCP征询实战

概述

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

前置条件

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

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

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

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

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 服务器:

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

新建 server.py:

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

# 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"

几个要点拆解:

预期结果:工具逻辑就绪,但此刻还没接客户端,征询不会真正发生。下一步接进 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 这类高级结构。换句话说,征询表单永远是扁平的、一层属性。

如果你这么写,客户端会拒绝或忽略:

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

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

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

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

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

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 就会自动弹出交互对话框,把用户填的内容回传给服务器。你不用在客户端做任何额外设置。

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

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

或手写 .mcp.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] ...。

常见问题:

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

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

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

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

#!/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": "[email protected]", "retention_days": 30 }
  }
}
EOF

在 settings.json 里挂上钩子(matcher 匹配 MCP 服务器名):

{
  "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 侧直接读环境变量即可:

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

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

必须说清的一件事:采样(sampling)在 Claude Code 的现状

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

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

规范里采样的请求长这样(仅供理解协议形态):

{
  "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 触发征询。

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

常见问题速查

延伸阅读

训练营适用性评估

本教程定位中阶,适合"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 规范) — 征询协议、受限 JSON Schema 子集、accept/decline/cancel 三态定义
  2. Sampling — Model Context Protocol(2025-06-18 规范) — sampling/createMessage 协议形态与人在回路约束
  3. Connect Claude Code to tools via MCP — Claude Code 对 elicitation、roots/list 的客户端支持声明
  4. Claude Code Hooks — Elicitation hook — 钩子输入字段、matcher、退出码语义
  5. MCP Python SDK — elicitation 示例 — 官方 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学通信知识星球海报-扫码加入