MCP 协议设计与实现

第18章 Elicitation、Roots 与辅助机制

作者 杨艺韬 · 8,862 字 · 发布于 · 更新于

协议的优雅,不在于让机器尽可能少地打扰人,而在于让机器在恰当的时刻,用恰当的方式,向人提出恰当的问题。

本章要点

  • 理解 Elicitation 的两种模式:Form Mode(结构化表单)与 URL Mode(带外交互)背后的数据隔离哲学
  • 掌握 Roots 机制如何让 Server 感知 Client 的文件系统边界,以及它为何是"引导"而非"授权"
  • 学会使用 Completion 为 Prompt 和 Resource 参数提供上下文感知的自动补全
  • 理解 Progress、Cancellation、Logging 三大操作生命周期工具在长操作中的协同
  • 认识这些特性如何与能力协商机制深度配合,共同织成 MCP 的"第二层协议"

18.1 被忽视的反向通道

18.1.1 从一段真实对话谈起

某位用户向集成了 Jira Server 的 Claude Code 发出指令:"帮我开一个关于登录失败的 Bug 工单"。在没有 Elicitation 的世界里,这个请求注定失败——Server 的 create_issue 工具涉及 5 个字段(其中 2 个必填):

project_key   (required)  → 哪个项目?
issue_type    (required)  → Bug? Task? Story?
priority                  → P0/P1/P2?
assignee                  → 分给谁?
labels                    → 打什么标签?

Server 只知道"登录失败"这四个字。它可以:

  1. 拒绝并返回错误:"缺少 project_key 参数" → 用户要重新措辞
  2. LLM 猜测填充:模型基于上下文胡乱填一个 → 创建到错误项目,灾难
  3. 返回占位结果:创建草稿再让用户修改 → 破坏了工具的原子语义

这三条路都不优雅。MCP 的 Elicitation 提供了第四条路:Server 在处理请求的过程中暂停,向用户弹出一个表单,收集缺失的字段,然后继续执行。一次工具调用,一次跨越 Client/Server/User 三方的嵌套对话——这就是本章要讲的核心能力。

18.1.2 为什么叫"反向通道"

在本专栏前面的章节里,我们深入剖析过 MCP 的三大核心原语——Tools、Resources、Prompts,以及让 Server 借用 Client LLM 算力的 Sampling。如果你画一张请求方向图,会发现一个强烈的模式:

Tools/Resources/Prompts:   Client ──request──▶ Server
                                  ◀─response──

大多数请求都是 Client 发起的。这当然没问题——Client 通常代表"用户主动意图",Server 是被动服务方。但现实世界的交互远比这复杂:

  • Server 处理到一半发现需要用户的 API Token,它该问谁?
  • Server 要搜索代码库,它该搜哪里?用户只开了 VSCode 里的一个 workspace,Server 怎么知道?
  • Server 在跑一个 10 分钟的编译任务,用户怎么知道它是卡死了还是在努力干活?

这些场景都需要 Server → Client → User 方向的消息流,也就是所谓的"反向通道"。本章覆盖的六个协议特性分属下图的第二层(反向通道)和第三层(操作生命周期),我把这些建在核心原语之上的机制统称为"第二层协议":

graph TB
    subgraph "第一层:核心原语(Client → Server 正向)"
        TL[Tools]
        RC[Resources]
        PR[Prompts]
    end
    subgraph "第二层:反向通道(Server → Client)"
        E["Elicitation<br/>向用户提问"]
        R["Roots<br/>感知文件边界"]
        S["Sampling<br/>借用 LLM(第17章)"]
    end
    subgraph "第三层:操作生命周期(双向信号)"
        C["Completion<br/>参数自动补全"]
        P["Progress<br/>进度追踪"]
        CA["Cancellation<br/>请求取消"]
        L["Logging<br/>结构化日志"]
    end
    E --> |"嵌套在"| TL
    R --> |"嵌套在"| TL
    S --> |"嵌套在"| TL
    P --> |"附着于"| TL
    CA --> |"终止"| TL
    C --> |"辅助填写"| PR
    L --> |"伴随"| TL
    style E fill:#db2777,color:#fff,stroke:none
    style R fill:#7c3aed,color:#fff,stroke:none
    style S fill:#6366f1,color:#fff,stroke:none
    style C fill:#2563eb,color:#fff,stroke:none
    style P fill:#10b981,color:#333,stroke:none
    style CA fill:#f59e0b,color:#333,stroke:none
    style L fill:#64748b,color:#fff,stroke:none
    style TL fill:#0ea5e9,color:#333,stroke:none
    style RC fill:#0ea5e9,color:#333,stroke:none
    style PR fill:#0ea5e9,color:#333,stroke:none

理解这三层的边界非常重要:第一层定义"Server 能做什么",第二层定义"Server 如何与人协作",第三层定义"长操作的可观测性"。绝大多数 MCP 教程只讲第一层,而真正区分"能用"和"好用"的恰恰是后两层。

18.2 Elicitation:Server 向用户提问

18.2.1 一个根本性的安全追问

在进入语法细节前,先问一个架构性问题:当 Server 需要用户的 GitHub Personal Access Token,它该如何收集?

候选方案:

方案 数据路径 风险
A. Server 打印提示,让用户复制粘贴到 Client 聊天框 User → Client → LLM → Server Token 进了 LLM 上下文,可能落进日志或被模型复述出去
B. Server 返回一个表单,Client 渲染,用户填完传回 User → Client(UI)→ Server Client 看得见明文,恶意 Client 可窃取
C. Server 返回一个 URL,用户在浏览器里输入,直接回传 Server User → 浏览器 → Server(绕过 Client) 需要 Client 打开外部浏览器

Elicitation 的两种模式正是 B 和 C 的协议化体现。这不是 API 设计层面的权衡,而是威胁模型驱动的架构决策:

  • Form Mode(方案 B)适合低敏感度数据——用户名、偏好、枚举选项。规范只禁止用 Form Mode 索取秘密与凭证,其余数据是否走表单由 Server 自行判断——前提是接受 Client 能看到明文。
  • URL Mode(方案 C)适合高敏感度数据——密码、OAuth Token、支付凭证。这些绝不能经过 MCP Client 和 LLM 上下文。

把这条铁律刻在脑子里:密码、API Key、访问令牌、支付凭证这类秘密与凭证,规范明确禁止用 Form Mode 索取(MUST NOT),必须走 URL Mode。

18.2.2 两种模式的全景对比

维度 Form Mode URL Mode
典型场景 选择分支、填写标题、打标签 OAuth、API Key、支付、文件上传
数据流向 用户 → Client → Server 用户 → 浏览器 → Server(带外)
Schema 支持 JSON Schema 扁平子集 无(外部页面自定义)
Client 可见数据 全部明文 仅 URL 本身
安全级别 常规 高敏感
Client 实现复杂度 需要动态表单渲染 只需打开浏览器
网络要求 仅 MCP 通道 用户的浏览器要能打开该 URL(通常是 Server 的网页端点)
UX 连贯性 嵌入 Client,流畅 跳出 Client,有中断感
专用错误码 无 可另用 -32042 URLElicitationRequiredError 在正向请求的错误响应里要求 URL Elicitation

18.2.3 能力协商:从第一行代码就对齐

Elicitation 的使用必须通过能力协商。Client 在 initialize 请求中声明:

{
  "capabilities": {
    "elicitation": {
      "form": {},
      "url": {}
    }
  }
}

向后兼容规则是一个反直觉的点:空的 elicitation: {} 对象等价于仅声明 form 模式。这是为了兼容老版本 Client(只实现了 Form)。所以 Server 在检查能力时必须:

function supportsUrlMode(clientCaps: ClientCapabilities): boolean {
  return clientCaps.elicitation?.url !== undefined;
}

function supportsFormMode(clientCaps: ClientCapabilities): boolean {
  return clientCaps.elicitation !== undefined;  // 注意:空对象也算
}

TypeScript SDK 的 Server.elicitInput() 在发请求前先做本地检查:form 模式看 this._clientCapabilities?.elicitation?.form(packages/server/src/server/server.ts:573),url 模式看 elicitation?.url(同文件 :565),不满足就直接抛 CapabilityNotSupported。空对象并不会因此被拒:解析 initialize 请求时,ElicitationCapabilitySchema 的 z.preprocess 已把空的 elicitation: {} 改写成 { form: {} }(packages/core/src/types/schemas.ts:318-323)。实测用裸 JSON-RPC 声明 "elicitation": {} 后调用走 form 的工具,TS Server 照常发出 elicitation/create;只声明 { "url": {} } 的 Client 则会让 form 请求在本地失败。Python SDK 的 elicit_form() / elicit_url()(src/mcp/server/session.py:380-448)发送前不检查客户端能力——实测对没有声明 elicitation 的 Python Client 照样发出请求,由 Client 默认回调回一条 Elicitation not supported 错误。

绝对禁止的反模式:Server 不管 Client 能力,直接发 URL Mode 请求。按规范,Client 收到未声明模式的请求要回 -32602(Invalid params),用户看到的是神秘的"操作失败"。正确做法:能力不支持时提供降级路径(如提示用户手动配置)。

18.2.4 Form Mode 深入

Form Mode 的请求通过 elicitation/create 方法,核心是 requestedSchema 字段:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "elicitation/create",
  "params": {
    "mode": "form",
    "message": "请提供联系信息",
    "requestedSchema": {
      "type": "object",
      "properties": {
        "name": {
          "type": "string",
          "title": "姓名",
          "description": "你的全名",
          "minLength": 2,
          "maxLength": 50
        },
        "email": {
          "type": "string",
          "format": "email",
          "title": "邮箱"
        },
        "role": {
          "type": "string",
          "title": "角色",
          "oneOf": [
            { "const": "dev", "title": "开发者" },
            { "const": "pm", "title": "产品经理" },
            { "const": "design", "title": "设计师" }
          ]
        },
        "notify": {
          "type": "boolean",
          "title": "接收通知",
          "default": true
        }
      },
      "required": ["name", "email"]
    }
  }
}

Schema 被刻意限制为扁平的原始类型对象——不支持嵌套对象、不支持对象数组(枚举数组除外)。这不是技术限制,而是协议设计者做出的UX 妥协:

规范给的理由只有一句"to simplify client user experience"(client/elicitation.mdx 第 114、235 行)。展开来说:每个 Client 都需要把 Schema 转成可交互表单,如果允许任意嵌套,Client 的表单引擎会迅速变成一个迷你 JSON Schema Form 框架,复杂度爆炸。限制扁平结构让 Client 实现可以简单到"for 循环 + 字段映射"。

支持的类型一览:

类型 修饰符 UI 渲染建议
string minLength, maxLength, pattern, format 单行/多行输入框,format=email 显示邮箱图标
number / integer minimum, maximum 数字输入框,有 min/max 时显示滑块
boolean default Checkbox 或 Toggle
string + enum 数组列举 Radio 或 Dropdown
string + oneOf[const] 带 title 的枚举 带标签的 Radio(更友好)
array + items.enum / items.anyOf[const] minItems, maxItems 多选 Checkbox

枚举的这几种写法(无标题/带标题 × 单选/多选)是 2025-11-25 按 SEP-1330 统一的,带标题单选从旧的 enum + enumNames 改为 oneOf[const],旧写法在 schema.ts 里保留为 LegacyTitledEnumSchema 并标注弃用;同一版还按 SEP-1034 让所有原始类型都可带 default。另外,pattern 只出现在规范正文的 String 示例里,schema/2025-11-25/schema.ts 的 StringSchema 和 TS SDK 的 StringSchemaSchema 都没有这个字段。

format 字段的特殊含义:email、uri、date、date-time——Client 可以据此做专门的输入控件和校验。这套约定兼顾了 JSON Schema 标准和 MCP 的实用性。

18.2.5 三态响应模型

用户面对表单有三种选择,这是协议层面的明确区分:

// Accept — 用户提交了数据
{ "action": "accept", "content": { "name": "张三", "email": "z@example.com" } }

// Decline — 用户明确拒绝
{ "action": "decline" }

// Cancel — 用户关闭了对话框(未做选择)
{ "action": "cancel" }

三态而非二态的设计,源于对真实用户意图光谱的尊重:

状态 语义 推荐 Server 响应
accept "我愿意提供,数据在这里" 继续执行原工具调用
decline "我看到了请求,但拒绝" 返回友好错误,不要重复弹窗,可以提供替代方案
cancel "我还没想好就关了" 可以稍后再问,或认为操作中止

很多 Server 实现犯的错:把 decline 和 cancel 都当作"失败"处理,结果对 decline 的用户反复弹窗。这违反用户意愿,会被投诉。Decline 是边界——尊重它。

18.2.6 URL Mode 的安全炼金术

先交代版本背景:Elicitation 本身在 2025-06-18 版规范里就已引入(当时只有表单一种形态,三态响应模型也是那时定下的);URL Mode 是 2025-11-25 新增的(SEP-1036),规范原文还专门加了一条提示:"Its design and implementation may change in future protocol revisions."

当涉及 API 密钥、OAuth 授权、支付流程时,数据绝不能经过 MCP Client。URL Mode 的完整时序:

sequenceDiagram
    participant U as 用户
    participant B as 浏览器
    participant C as MCP Client
    participant S as MCP Server
    participant T as 第三方服务
    C->>S: tools/call "连接 GitHub"
    Note over S: 需要 GitHub OAuth
    S->>C: elicitation/create (mode: url,<br/>url: https://mcp.x/connect?id=xxx)
    C->>U: "Server 请求打开 URL,是否同意?"
    U->>C: 同意
    C->>B: 打开 https://mcp.x/connect?id=xxx
    C->>S: { action: "accept" }
    B->>S: 加载连接页面
    Note over S: 验证请求方身份
    S->>B: 重定向到 GitHub OAuth
    B->>T: GitHub 授权页面
    U->>T: 授权
    T->>S: 回调,返回 authorization code
    S->>T: 用 code 换取 token
    Note over S: 安全存储 token,绑定用户身份
    S-->>C: notifications/elicitation/complete
    Note over S: 原 tools/call 继续执行
    S->>T: 使用存储的 token 调用 GitHub API
    S->>C: 返回工具结果

URL Mode 的五条安全铁律:

  1. 不在 URL 中携带敏感信息——URL 会出现在浏览器历史、HTTP Referer、Server 访问日志中
  2. 不提供预认证 URL——防止 URL 泄露后被任何持有者滥用
  3. Client 必须显示完整 URL 并获用户明确同意——防 Server 把用户导向钓鱼网站
  4. Client 必须以安全方式打开 URL,让 Client 和 LLM 都看不到页面内容与用户输入——规范举的例子是 iOS 上用 SFSafariViewController 而不是 WKWebView;Android 的 Custom Tabs、桌面的系统默认浏览器是同理推断。绝不能用可被宿主 App 嗅探的 WebView
  5. Server 必须验证打开 URL 的用户与发起 Elicitation 的用户是同一人——否则攻击者可以把自己触发的 elicitation URL 转给受害者,诱骗对方授权

第 5 条尤其微妙。规范描述的钓鱼攻击是:攻击者把自己触发的 elicitation URL 发给同一 Server 的另一位用户,受害者以为在给自己授权,拿到的第三方 token 却绑定到攻击者的会话和身份上,造成账户接管。规范给的非规范性示例是:URL 指向 Server 自己的 connect 页面,该页面检查浏览器里的 session cookie,比对其对应的 sub 与发起 elicitation 的用户是否一致,确认后再重定向到第三方授权端点。

18.2.7 URLElicitationRequiredError 错误码

有时 Server 在收到 tools/call 时才发现需要外部授权。协议为此定义了错误码 -32042,错误的 data.elicitations 必须列出所需的 Elicitation,且只能是带 elicitationId 的 URL 模式:

{
  "error": {
    "code": -32042,
    "message": "需要授权才能继续",
    "data": {
      "elicitations": [
        {
          "mode": "url",
          "elicitationId": "550e8400-e29b-41d4-a716-446655440000",
          "url": "https://mcp.example.com/connect?id=550e8400",
          "message": "需要授权访问你的 GitHub 仓库"
        }
      ]
    }
  }
}

这种"失败-引导-重试"模式赋予了工具调用自愈能力:

stateDiagram-v2
    [*] --> Calling: tools/call
    Calling --> Success: 有凭证
    Calling --> NeedsAuth: 缺凭证 (-32042)
    NeedsAuth --> Authorizing: Client 显示 URL
    Authorizing --> UserApproved: 用户同意
    Authorizing --> UserDeclined: 用户拒绝
    UserApproved --> Retrying: 完成 OAuth
    Retrying --> Success: 重试成功
    Success --> [*]
    UserDeclined --> [*]

以上是本专栏锚定的 2025-11-25 版。2026-07-28 版规范已去掉 -32042(basic/index.mdx 注明它仅用于 2025-11-25),也去掉了 notifications/elicitation/complete 与 URL 请求里的 elicitationId:在 Multi Round-Trip Requests 模式下,Client 通过重试原请求得知带外交互的结果,需要跨重试关联的 Server 自己把标识编进 requestState。

18.2.8 SDK 实战:TypeScript 与 Python

TypeScript SDK 通过 server.elicitInput() 发起请求:

mcpServer.registerTool(
  'register_user',
  { description: '注册新用户' },
  async () => {
    const result = await mcpServer.server.elicitInput({
      mode: 'form',
      message: '请提供注册信息:',
      requestedSchema: {
        type: 'object',
        properties: {
          username: { type: 'string', title: '用户名', minLength: 3 },
          email: { type: 'string', format: 'email', title: '邮箱' }
        },
        required: ['username', 'email']
      }
    });

    switch (result.action) {
      case 'accept':
        return {
          content: [{ type: 'text', text: `注册成功:${result.content?.username}` }]
        };
      case 'decline':
        return {
          content: [{ type: 'text', text: '您拒绝了提供信息,注册取消。' }]
        };
      case 'cancel':
        return {
          content: [{ type: 'text', text: '注册窗口已关闭,操作中止。' }]
        };
    }
  }
);

Python SDK 基本对齐:

@mcp.tool()
async def register_user(ctx: Context) -> str:
    # elicit_form() 是当前 API;旧的 elicit() 仍在,但已在 SDK 内标注
    # "deprecated in favor of elicit_form()"(src/mcp/server/session.py:358-378)
    result = await ctx.session.elicit_form(
        message="请提供注册信息",
        requested_schema={
            "type": "object",
            "properties": {
                "username": {"type": "string", "minLength": 3},
                "email": {"type": "string", "format": "email"}
            },
            "required": ["username", "email"]
        }
    )
    if result.action == "accept":
        return f"注册成功:{result.content['username']}"
    return "注册取消"

这段代码在 pin 版本上实跑通过(STDIO)。更高层的写法是 ctx.elicit(message, schema=某个 Pydantic 模型)(src/mcp/server/mcpserver/context.py:120):它先检查模型字段只含原始类型(或字符串序列),发请求时自动带上当前工具调用的 related_request_id,accept 时再用模型校验 content,返回 AcceptedElicitation / DeclinedElicitation / CancelledElicitation(src/mcp/server/elicitation.py:105-142)。直接调 ctx.session.elicit_form() 而不传 related_request_id 时,在 Streamable HTTP 下这条请求不会挂在当前 POST 的响应流上。

URL Mode 在 Python 侧对应 ctx.session.elicit_url()(src/mcp/server/session.py:412)或 ctx.elicit_url()(src/mcp/server/mcpserver/context.py:153)。elicitInput / elicit_form 都是 async 阻塞调用——它会挂起当前工具执行,直到用户响应。在 SDK 内部,这靠按请求 ID 登记的等待点实现:TS 把 resolve 回调登记在响应处理表里,Python 为每个请求建一个 anyio 内存流(src/mcp/shared/session.py:257-258),收到对应 id 的响应再唤醒挂起的调用。

18.2.9 反向请求与正向请求的关联

规范描述 Elicitation 与 Sampling 的交互模型时,用的词是 nested——"enabling user input requests to occur nested inside other MCP server features"(client/elicitation.mdx 的 User Interaction Model 一节;client/sampling.mdx 同一节的措辞是 "enabling LLM calls to occur nested inside other MCP server features")。也就是说,典型形态是 Server 在处理 tools/call、resources/read、prompts/get 的过程中发起反向请求,让这次提问明确挂在某个用户可见的动作上。

但要说清楚:这是描述性的交互模型,不是 MUST 级别的协议禁令。传输层留了独立通道:

  • STDIO 是全双工的,Server 可以随时发消息
  • Streamable HTTP 除了 POST 的响应流,还有一条 GET 打开的 SSE 流,规范明确说这条流上的消息"SHOULD be unrelated to any concurrently-running JSON-RPC request from the client"(basic/transports.mdx)——正是为脱离正向请求的 Server 主动消息准备的

工程上的建议仍然成立:把反向请求挂在正向请求上,用户才能理解"这个弹窗是我刚才那次操作引起的"。脱离上下文的突兀提问,即使协议允许,也是糟糕的产品设计。

2026-07-28 版把这条建议变成了协议结构:elicitation/create、roots/list、sampling/createMessage 这类服务端发起的请求改为 Multi Round-Trip Requests(SEP-2322),Server 在正向请求的结果里返回 InputRequiredResult,Client 带着 inputResponses 重试原请求,反向请求不再有脱离正向请求的独立发送路径。

18.2.10 Elicitation 反模式

反模式 问题 正确做法
密集弹窗:每次工具调用都弹 用户弹窗疲劳,Decline 率飙升 缓存用户历史输入,只在真正缺失时弹
Form Mode 收集密码 密码暴露在 Client 和潜在日志 必须用 URL Mode 走带外流程
超大 Schema:一次要 20 个字段 用户填到一半放弃 拆成多步 Elicitation,或用默认值减少必填项
忽略 Decline:反复弹同一个表单 骚扰用户 Decline 后立即返回错误,在相同会话内不再询问
URL 里塞参数:?secret=xxx 日志泄露 用随机 ID 引用 Server 端状态
没有 timeout:Server 永远等 Client 响应 Server 资源泄漏 设一个与人工填表节奏匹配的超时(协议未规定具体值),超时后释放资源。Python 服务端默认不设超时;TS 的 elicitInput 沿用请求默认的 60 秒(DEFAULT_REQUEST_TIMEOUT_MSEC),对人工填表可能偏短,可用 options.timeout 调整

18.3 Roots:文件系统边界感知

18.3.1 什么是 Roots

Roots 是 MCP 中一个看似简洁但极其重要的概念:Client 告诉 Server "我关心哪些目录"。

{
  "roots": [
    {
      "uri": "file:///home/user/projects/frontend",
      "name": "Frontend Repository"
    },
    {
      "uri": "file:///home/user/projects/backend",
      "name": "Backend Repository"
    }
  ]
}

看到这里你可能疑惑:"Server 既然被授权访问文件系统,为什么还要知道 Roots?"

关键区别:

问题 谁来回答
Server 能访问哪里? OS 权限、进程沙箱规则(协议之外)
Server 应该访问哪里? Roots

Roots 是语义边界,不是安全边界。它解决的是"让 Server 做出更精准决策"的问题:

  • 用户在 VSCode 里开了 myapp-frontend 和 myapp-backend 两个 workspace
  • Server 有个 search_code 工具
  • 没有 Roots:Server 只能从 $HOME 这类宽泛起点开始扫,噪声大、耗时长
  • 有 Roots:Server 知道只需搜这两个目录,搜索范围大幅收窄

18.3.2 信息性引导 vs 访问控制

2025-11-25 版规范把 Roots 描述为"servers can operate"的边界,但只要求 Server SHOULD 在操作中尊重这些边界,协议本身不做强制——Server 并非不能访问 Roots 外的路径。2026-07-28 版把话说得更直白:Roots 是"informational guidance rather than an access-control mechanism"。一个行为良好的 Server 应当:

  1. 默认限定:搜索、分析、操作默认限定在 Roots 内
  2. 越界提示:要操作 Roots 外的路径时,向用户确认
  3. 缓存友好:Roots 内的文件可以安全缓存,外部文件保守对待

这种"软约束"哲学与 robots.txt 异曲同工:让良民更容易做正确的事,而不是试图阻止恶人。真正的安全由沙箱和权限系统保证。

18.3.3 动态变更通知

Roots 不是静态的。用户可能在工具执行过程中打开新项目、关闭 workspace。Client 应发送变更通知:

{
  "jsonrpc": "2.0",
  "method": "notifications/roots/list_changed"
}

Server 收到通知后,通过 roots/list 重新查询。这种"通知 + 拉取"模式在 MCP 中无处不在:

变更通知 拉取方法
notifications/tools/list_changed tools/list
notifications/resources/list_changed resources/list
notifications/prompts/list_changed prompts/list
notifications/roots/list_changed roots/list

为什么不直接在通知里塞新数据?答案是幂等性和版本一致性——如果 Server 收到多个变更通知,拉取最终状态而非叠加增量,不会出现状态错乱。

需要交代的是:2026-07-28 版删除了 notifications/roots/list_changed,并按 SEP-2577 把整个 Roots 特性标为弃用(至少保留十二个月),建议改用工具参数、资源 URI 或 Server 配置传递目录;本节按 2025-11-25 版讲述。

18.3.4 能力声明

{
  "capabilities": {
    "roots": {
      "listChanged": true
    }
  }
}

listChanged: true 表示 Client 会发送变更通知。反之 Server 要轮询(不推荐)或假设 Roots 在会话内不变。

18.3.5 Roots 的安全考量

Client 侧:

  • 用户同意:暴露 Roots 前应征得用户同意(规范为 SHOULD;通常隐式于 workspace 打开动作)
  • 路径规范化:必须验证 URI 防路径穿越(file:///home/user/../etc/passwd)
  • 符号链接处理:决定是否展开软链接要谨慎

Server 侧:

  • 缓存策略:Root 列表可缓存,但应在 list_changed 通知时失效
  • 优雅降级:Roots 能力不存在时应有合理默认(如当前工作目录)
  • 隔离边界:不要把 Root 信息暴露给其他 Session

18.4 Completion:参数自动补全

18.4.1 提升用户输入体验

当用户在 Client UI 中填写 Prompt 参数或 Resource 模板的 URI 时,Server 可以提供实时自动补全——就像 IDE 中的代码补全。

// 请求
{
  "method": "completion/complete",
  "params": {
    "ref": { "type": "ref/prompt", "name": "code_review" },
    "argument": { "name": "language", "value": "py" }
  }
}

// 响应
{
  "result": {
    "completion": {
      "values": ["python", "pytorch", "pyside"],
      "total": 10,
      "hasMore": true
    }
  }
}

补全结果最多返回 100 项,按相关性排序,hasMore 标记是否有更多结果。

18.4.2 上下文感知补全

Prompt 有多个参数时,已填字段可作为上下文传递,让补全更精准:

{
  "method": "completion/complete",
  "params": {
    "ref": { "type": "ref/prompt", "name": "code_review" },
    "argument": { "name": "framework", "value": "fla" },
    "context": {
      "arguments": { "language": "python" }
    }
  }
}
// 返回 ["flask"] 而非 ["flash", "flatbuffers", ...]

这个小细节体现了协议设计的纵深感:作者预见到"同一个 value 前缀在不同上下文下含义不同"的需求,把上下文字段内置到协议里。如果没有这个机制,Server 作者会被迫在 value 字段里编码上下文("python::fla"),丑陋且脆弱。

18.4.3 TypeScript SDK 的 completable 魔法

TypeScript SDK 提供了 completable 函数,将补全逻辑直接绑定到 Schema 定义:

import { completable, McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';

server.registerPrompt(
  'review-code',
  {
    title: 'Code Review',
    argsSchema: z.object({
      language: completable(
        z.string().describe('Programming language'),
        (value) => ['typescript', 'javascript', 'python', 'rust', 'go']
          .filter(lang => lang.startsWith(value))
      ),
      framework: completable(
        z.string().describe('Framework'),
        (value, context) => {
          // 根据 language 动态决定候选集
          const byLang: Record<string, string[]> = {
            python: ['flask', 'django', 'fastapi'],
            javascript: ['react', 'vue', 'svelte'],
            typescript: ['react', 'vue', 'nest'],
          };
          const lang = context?.arguments?.language;
          const candidates = byLang[lang] ?? [];
          return candidates.filter(f => f.startsWith(value));
        }
      )
    })
  },
  ({ language, framework }) => ({
    messages: [{
      role: 'user',
      content: { type: 'text', text: `Review this ${language}/${framework} code.` }
    }]
  })
);

completable 通过 Symbol 将补全回调附加到 Zod Schema 上——Schema 既是类型定义,又携带行为。这是一种优雅的元编程技巧,让"类型系统驱动的运行时"成为可能。

18.4.4 Completion 的性能考量

补全通常是高频请求:用户每敲一个字符就会触发。Server 实现必须注意:

  • 防抖:Client 侧应做输入防抖,Server 侧可限流
  • 低延迟:补全要做到"敲下一个字符之前就返回",否则用户体验劣化
  • 缓存:对枚举类补全可 Server 侧缓存候选列表
  • 提前截断:命中 100 项就返回,别把数据库查干净

18.5 Progress:进度追踪

18.5.1 长操作的用户体验

当工具执行耗时较长(代码分析、大文件处理、CI 构建),用户需要知道"系统还在工作"。MCP 的进度追踪通过 progressToken 机制:

// Client 在请求中附带 progressToken
{
  "method": "tools/call",
  "params": {
    "name": "analyze_codebase",
    "arguments": { "path": "/src" },
    "_meta": { "progressToken": "abc123" }
  }
}

// Server 发送进度通知
{
  "method": "notifications/progress",
  "params": {
    "progressToken": "abc123",
    "progress": 50,
    "total": 100,
    "message": "正在分析第 50/100 个文件..."
  }
}

18.5.2 设计要点

规则 含义
progressToken 可为字符串或整数 由请求方生成,Server 原样回传
Token 必须在所有活跃请求中唯一 否则 Client 无法区分哪个请求的进度
progress 单调递增 即使 total 未知,也不能倒退
progress 和 total 可为浮点 支持"已处理字节数/总字节数"这类精细进度
message 人类可读 UI 直接显示,比如"正在下载 foo.tar.gz"
请求完成后停止进度通知 Server 不能在 response 后继续发 progress

18.5.3 百分比陷阱

新手常犯的错:把 progress 等同于百分比。正确心智模型是:

  • progress + total 是一个分数,Client 自己算百分比
  • total 可以省略(流式任务不知道总量)
  • 多阶段任务不能每个阶段从 0 重新计数——规范要求同一 token 的 progress 每次通知都必须增加(MUST)。把各阶段折算进同一个 total,阶段信息放进 message

优雅实现:

// ❌ 错误:阶段2从 0 重新计数,progress 倒退,违反单调递增
await report(100, 100, '阶段1:构建完成');
await report(0, 100, '阶段2:开始测试');

// ✅ 正确:两个阶段折算进同一个 total,progress 只增不减,用 message 表达阶段
await report(50, 100, '阶段1/2:构建完成');
await report(75, 100, '阶段2/2:测试 50%');
await report(100, 100, '阶段2/2:测试完成');

18.6 Cancellation:优雅取消

18.6.1 基本语法

用户可能在工具执行中途改变主意。MCP 通过 notifications/cancelled 支持请求取消:

{
  "method": "notifications/cancelled",
  "params": {
    "requestId": "123",
    "reason": "用户请求取消"
  }
}

18.6.2 取消的竞态本质

取消是"发射后遗忘"的——发送方不能假设取消一定成功。由于网络延迟、处理中的操作无法中断,取消通知可能在请求完成后才到达:

sequenceDiagram
    participant C as Client
    participant S as Server
    C->>S: tools/call (ID: 42, progressToken: "t1")
    Note over S: 开始处理
    S-->>C: progress (25/100)
    S-->>C: progress (50/100)
    C--)S: notifications/cancelled (ID: 42)
    alt 取消成功(常见)
        Note over S: 停止处理,释放资源
        S--xC: (不再发送响应)
    else 已经完成(竞态)
        S->>C: 返回结果
        Note over C: Client 应忽略结果
    else 无法中断(如已提交的事务)
        Note over S: 完成不可中断部分
        S->>C: 返回结果
        Note over C: Client 应忽略结果
    end

18.6.3 两条"铁律"

  1. 不能取消 initialize 请求——协议握手是原子的
  2. 收到 cancel 的一方不再发对应响应(规范为 SHOULD)——但允许已发出的响应到达(发起取消的一方应忽略)。Python SDK 实际会回一条 code: 0、message: "Request cancelled" 的错误响应(src/mcp/shared/session.py:151-155,详见 §3.8)

实现上的技巧:Server 在处理 cancel 时,通过 AbortController/asyncio.CancelledError 取消内部任务,但要在一个统一位置处理最终响应的发送——如果这个位置检测到已被取消,就跳过发送。

18.7 Logging:结构化日志

18.7.1 Server 到 Client 的日志流

Server 可通过 notifications/message 向 Client 发送结构化日志,遵循 RFC 5424 的 syslog 级别:

级别 含义 典型场景
debug 详细调试信息 函数入口/出口、中间变量
info 常规信息 操作进度、状态变化
notice 正常但值得注意 配置变更、版本信息
warning 警告 使用了已废弃特性
error 错误 操作失败、可恢复异常
critical 严重错误 组件故障
alert 需要立即处理 数据损坏、配置不一致
emergency 系统不可用 完全崩溃

Client 可通过 logging/setLevel 动态调整日志级别,Server 只发送不低于设定级别的日志。(2026-07-28 版删除了 logging/setLevel,改为每个请求在 _meta 里携带日志级别,并按 SEP-2577 把 Logging 特性标为弃用。)

{
  "method": "notifications/message",
  "params": {
    "level": "error",
    "logger": "database",
    "data": {
      "error": "Connection failed",
      "details": { "host": "localhost", "port": 5432, "attempt": 3 }
    }
  }
}

data 字段是任意 JSON——Server 可以传递丰富的结构化上下文,而不仅仅是文本消息。这让 Client 可以做高级渲染(如把 error 用红色、把 details 折叠显示)。

18.7.2 安全约束

日志是数据泄露的重灾区。2025-11-25 规范 server/utilities/logging.mdx 的 Security 一节规定日志消息不得(MUST NOT)包含:

  • 凭证与秘密(Token、密码,哪怕是已过期的)
  • 个人身份信息(PII):邮箱、手机号、身份证、地址
  • 可能帮助攻击者的内部系统细节:完整堆栈、内部 IP、进程 PID、源码路径

规范之外,本专栏建议也别把用户输入的完整内容写进日志(可能含敏感信息)。

实现建议:

// ❌ 危险
log('error', { message: 'Auth failed', token: userToken, body: requestBody });

// ✅ 安全
log('error', {
  message: 'Auth failed',
  hasToken: Boolean(userToken),   // 连前缀也不记:凭证属于 MUST NOT
  bodySize: requestBody.length,
  bodyHash: hash(requestBody),
});

18.7.3 速率限制

日志可以成为 DoS 向量——Server 恶意或 bug 触发海量日志会打爆 Client。规范在 Logging 的安全小节里只写了一句 "Rate limit log messages",没有给出具体阈值——限速参数留给实现自己定。Client 也应有保护:超限时丢弃而非崩溃。

18.8 特性间的协同:部署场景全景

这些特性不是孤立的,它们在实际场景中深度协同。考虑一个"部署到生产环境"的完整工具调用:

sequenceDiagram
    participant U as 用户
    participant C as Client
    participant S as MCP Server
    participant G as GitHub API
    C->>S: tools/call "deploy_to_production"
    Note over S: 需要多个上下文信息
    rect rgb(240,240,255)
        Note over S,C: 阶段 1: Roots 感知
        S->>C: roots/list
        C->>S: roots: [file:///home/user/myapp]
    end
    rect rgb(255,240,240)
        Note over S,C: 阶段 2: Form Elicitation
        S->>C: elicitation/create (form)<br/>"选择部署分支和环境"
        C->>U: 显示表单
        U->>C: { branch: "main", env: "staging" }
        C->>S: accept
    end
    rect rgb(255,255,240)
        Note over S,C: 阶段 3: URL Elicitation (授权)
        S->>C: elicitation/create (url)<br/>"请授权 GitHub 访问"
        C->>U: 显示 URL 确认
        U->>C: 同意
        C->>S: accept
        Note over S: 等待 OAuth 完成...
        S-->>C: notifications/elicitation/complete
    end
    rect rgb(240,255,240)
        Note over S,C: 阶段 4: 执行与进度
        S-->>C: progress (10/100, "拉取代码...")
        S-->>C: message (info, "构建开始")
        S-->>C: progress (50/100, "运行测试...")
        alt 用户改主意
            C--)S: notifications/cancelled
            Note over S: 回滚并退出
        else 正常继续
            S-->>C: progress (90/100, "推送镜像...")
            S-->>C: message (info, "部署完成")
            S->>C: 返回部署结果
        end
    end

在这个流程里:

  • Roots 提供了上下文("在哪里部署")
  • Form Elicitation 收集了用户决策("部署什么、去哪")
  • URL Elicitation 完成了安全授权("用谁的身份")
  • Progress 追踪了长操作
  • Logging 提供了实时文字反馈
  • Cancellation 保留了用户的否决权

每个特性各司其职,共同构成了完整的交互体验。这就是为什么我把它们放在同一章——它们是一个整体。

18.9 向前演进:Elicitation 的未来

写到这里我想留几个问题给读者思考——它们是 MCP 协议未来可能演进的方向:

Q1:Elicitation 能否做到多轮对话? 在 2025-11-25 版里 Elicitation 是单次问答。如果 Server 要和用户做多轮交互(比如询问→确认→再询问),只能通过多次 Elicitation 实现。2026-07-28 版的 Multi Round-Trip Requests 让 Server 可以连续返回多次 InputRequiredResult,并用 requestState 在重试之间携带状态,已经朝这个方向走了一步。

Q2:URL Mode 的完成信号能否更可靠? 2025-11-25 版里 Client 在用户点"同意"后就返回 accept,Server 是否真正完成授权是另一码事。notifications/elicitation/complete 是一种补救,但它是 MAY 级别的可选通知,只带 elicitationId、不区分成败,规范还建议(SHOULD)Client 在通知迟迟不来时提供手动重试或取消的控件。2026-07-28 版干脆删掉了这条通知,改由 Client 重试原请求得知结果。

Q3:Roots 能否带能力标记? 比如"这个目录只读"、"这个目录包含机密"。目前协议里没有这层语义,但真实 IDE 有(.gitignore、.vscode/settings.json)。

Q4:Progress 能否支持并行任务? 一个工具调用内部可能并发跑 10 个子任务,目前的 progressToken 只能描述单线性进度。未来可能支持嵌套/并行 token。

这些问题都不是理论问题——MCP 规范仓库有正式的 SEP(Specification Enhancement Proposal)流程,seps/ 目录下能读到已提案的改动,比如 URL Mode Elicitation 就是 SEP-1036、表单默认值是 SEP-1034。协议的演进是一件令人兴奋的事,读者可以顺着 SEP 编号去看讨论。

18.9.1 反向通道在 ClientSession 里的共同入口

Elicitation 与 Roots 在概念上不同,但 Python 客户端把它们放在同一条反向请求处理路径里。src/mcp/client/session.py:110-130 的 ClientSession 构造函数同时接收 elicitation callback 与 list_roots callback;src/mcp/client/session.py:154-165 只有当这些 callback 不是默认实现时,初始化能力里才声明 elicitation 或 roots。随后 src/mcp/client/session.py:436-450 对 ElicitRequest 调用 _elicitation_callback,对 ListRootsRequest 调用 _list_roots_callback,并统一用 ClientResponse 做类型校验后 respond。

schema 则补上了协议边界。modelcontextprotocol/schema/2025-11-25/schema.ts:2107-2109 的 ListRootsResult 定义 roots/list 返回 Root 数组,modelcontextprotocol/schema/2025-11-25/schema.ts:2116-2136 的 Root 要求 URI 当前必须以 file:// 开头(注释原文:"This must start with file:// for now"),可附带展示名;modelcontextprotocol/schema/2025-11-25/schema.ts:2145-2148 定义 roots 变化通知。Elicitation 侧,modelcontextprotocol/schema/2025-11-25/schema.ts:2155-2178 的 ElicitRequestFormParams 只允许顶层 primitive schema(mode 可省略,省略即 form),modelcontextprotocol/schema/2025-11-25/schema.ts:2185-2208 的 ElicitRequestURLParams 用 opaque elicitationId 与 url 表达外部交互,modelcontextprotocol/schema/2025-11-25/schema.ts:2215-2226 再把它们统一成 ElicitRequestParams / elicitation/create request。两者共同构成一条原则:server 可以向 client 请求用户输入或根目录视图,但不能绕过 client 的 UI、权限和类型校验。

18.10 本章小结

本章覆盖的六个特性看似零散,实则构成了 MCP 协议中最贴近用户体验的一层。如果说 Tools/Resources/Prompts 定义了 Server 能做什么,这些特性就定义了 Server 如何与人协作:

  • Elicitation 让 Server 在需要时向用户提问,Form Mode 用于常规数据,URL Mode 用于敏感信息
  • Roots 让 Server 感知 Client 的文件系统边界,做出更精准的决策
  • Completion 通过自动补全提升用户输入参数的体验
  • Progress 让长操作的进展透明可见
  • Cancellation 赋予用户对运行中操作的控制权
  • Logging 提供结构化的实时反馈通道

所有这些特性都通过能力协商(Capabilities)控制可用性,反向请求通常嵌套在某次正向请求之中(规范称为 nested),让用户看得懂它从何而来。它们的存在让 MCP 从一个简单的"工具调用协议"进化为一个支持复杂人机协作流程的完整框架。

一句话口诀方便记忆:敏感走 URL,常规用 Form;变更靠通知,拉取保幂等;进度要单调,取消会竞态;日志脱敏严,能力先协商。

在下一章,我们将看到这些特性在 Claude Code 的 MCP 客户端实现里如何落地——那里的每一个 API 调用都是本章概念的生动注脚。