首页 › tutorials › MCP Elicitation:服务器执行中向用户要输入

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

概述

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

前置条件


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

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

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

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

消息流(规范官方时序图):

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 初始化一个最小工程:

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

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

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:

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 有两个字段:

预期结果:在 Claude Code 里让模型调用 drop_table(table_name="orders"),你会看到一个确认弹窗,标注来源是"确认守卫"服务器;点"确认"才真正执行。

常见问题:


第 4 步:结构化数据收集(多字段表单)

确认型只够点"是/否"。要一次收多个字段,把 response_type 设为一个 Pydantic 模型(也支持 dataclass / TypedDict):

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 实例,字段类型自动转换。

常见问题:


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

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

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 模式:

@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 真正的威力在串行多轮:一次工具调用里发起多次征询,逐步收敛。下面是一个会议安排工具,三轮收集标题、时长、是否紧急:

@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 语法非常清爽:

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(写入用户配置):

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

随后在 Claude Code 中:

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

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

常见问题:


第 9 步:用 MCP Inspector 联调

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

uv run fastmcp dev server.py

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

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

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

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

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

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


实战案例:变更审批守卫(完整可运行)

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

# 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 二次确认),用它来收集口令则是反模式。

训练营适用性评估

延伸阅读

参考来源

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

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