MCP 协议设计与实现

第18章 Elicitation、Roots 与配置管理

作者 杨艺韬 · 7,298 字

第18章 Elicitation、Roots 与配置管理

“A good protocol doesn’t just let machines talk to machines — it lets machines ask people the right questions at the right time.”

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

本章要点

  • 理解 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 个字段:

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 方向的消息流,也就是所谓的”反向通道”。本章覆盖的六个协议特性,共同构成了 MCP 的操作生命周期层,我称之为”第二层协议”:

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:#ec4899,color:#fff,stroke:none
    style R fill:#8b5cf6,color:#fff,stroke:none
    style S fill:#6366f1,color:#fff,stroke:none
    style C fill:#3b82f6,color:#fff,stroke:none
    style P fill:#10b981,color:#fff,stroke:none
    style CA fill:#f59e0b,color:#fff,stroke:none
    style L fill:#64748b,color:#fff,stroke:none
    style TL fill:#0ea5e9,color:#fff,stroke:none
    style RC fill:#0ea5e9,color:#fff,stroke:none
    style PR fill:#0ea5e9,color:#fff,stroke:none

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

18.2 Elicitation:Server 向用户提问

18.2.1 一个根本性的安全追问

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

候选方案:

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

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

  • Form Mode(方案 B)适合低敏感度数据——用户名、偏好、枚举选项。这些信息即使被恶意 Client 记录也无大碍。
  • URL Mode(方案 C)适合高敏感度数据——密码、OAuth Token、支付凭证。这些绝不能经过 MCP Client 和 LLM 上下文

把这条铁律刻在脑子里:凡是不能进 LLM 上下文的数据,都必须走 URL Mode

18.2.2 两种模式的全景对比

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

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;  // 注意:空对象也算
}

绝对禁止的反模式:Server 不管 Client 能力,直接发 URL Mode 请求。Client 收到不支持的模式会报错,用户看到的是神秘的”操作失败”。正确做法:能力不支持时提供降级路径(如提示用户手动配置)。

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 妥协

每个 Client 都需要把 Schema 转成可交互表单。如果允许任意嵌套,Client 的表单引擎会迅速变成一个迷你 JSON Schema Form 框架,复杂度爆炸。限制扁平结构让 Client 实现可以简单到”for 循环 + 字段映射”。

支持的类型一览:

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

format 字段的特殊含义emailuridatedate-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 的安全炼金术

当涉及 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
    C->>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 必须用安全浏览器(iOS 上用 SFSafariViewController,Android 用 Custom Tabs,桌面用系统默认浏览器)——绝不能用可被宿主 App 嗅探的 WebView
  5. Server 必须验证打开 URL 的用户与发起 Elicitation 的用户同一人——否则攻击者可以伪造 elicitationId 诱骗受害者授权

第 5 条尤其微妙:常见实现是给 URL 附加一次性的 short-lived token,且在 Server 端用 session cookie 绑定。很多实现疏忽了这一点,导致”离线钓鱼”攻击——攻击者复制 elicitation URL 给受害者,受害者授权后 token 却绑定到攻击者账户。

18.2.7 URLElicitationRequiredError 错误码

有时 Server 在收到 tools/call 时才发现需要外部授权。协议为此定义了错误码 -32042,响应中可附带所需的 Elicitation 列表:

{
  "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 --> [*]

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:
    result = await ctx.session.elicit(
        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 "注册取消"

elicitInput / elicit 都是 async 阻塞调用——它会挂起当前工具执行,直到用户响应。在 Server 内部,这意味着底层必须维护请求状态机,配合 asyncio.Event 或 Promise 的 resolve 回调来恢复执行。

18.2.9 请求关联约束

一个至关重要的协议约束:所有 Server → Client 的请求(Elicitation、Roots、Sampling)必须关联到一个正在处理的 Client → Server 请求

换言之,Server 不能”无缘无故”向用户提问——它只能在处理 tools/callresources/readprompts/get 等请求的过程中发起反向请求。

这不仅是语义合理性要求,更是传输层向前兼容的考量

  • STDIO 传输是全双工的,Server 可以随时发消息
  • Streamable HTTP 传输是请求-响应模式,Server 只能在 POST 响应流中发消息
  • 未来可能出现的其他传输(如 WebRTC DataChannel)也应保持这个约束

如果 Server 可以独立发起反向请求,HTTP 传输就只能通过 SSE 等机制额外开通道——协议会变复杂。把反向请求都绑在正向请求的”附属线程”里,所有传输都能保持简洁

18.2.10 Elicitation 反模式

反模式问题正确做法
密集弹窗:每次工具调用都弹用户弹窗疲劳,Decline 率飙升缓存用户历史输入,只在真正缺失时弹
Form Mode 收集密码密码暴露在 Client 和潜在日志必须用 URL Mode 走带外流程
超大 Schema:一次要 20 个字段用户填到一半放弃拆成多步 Elicitation,或用默认值减少必填项
忽略 Decline:反复弹同一个表单骚扰用户Decline 后立即返回错误,在相同会话内不再询问
URL 里塞参数?secret=xxx日志泄露用随机 ID 引用 Server 端状态
没有 timeout:Server 永远等 Client 响应Server 资源泄漏设合理超时(推荐 5 分钟),超时后释放资源

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 权限、沙箱规则(第15章)
Server 应该访问哪里?Roots

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

  • 用户在 VSCode 里开了 myapp-frontendmyapp-backend 两个 workspace
  • Server 有个 search_code 工具
  • 没有 Roots:Server 只能搜 $HOME,结果一片红色
  • 有 Roots:Server 知道只需搜这两个目录,响应 <1s

18.3.2 信息性引导 vs 访问控制

协议明确定位 Roots 为”informational guidance”——Server 并非不能访问 Roots 外的路径。但一个行为良好的 Server 应当:

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

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

18.3.3 动态变更通知

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

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

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

变更通知拉取方法
notifications/tools/list_changedtools/list
notifications/resources/list_changedresources/list
notifications/prompts/list_changedprompts/list
notifications/roots/list_changedroots/list

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

18.3.4 能力声明

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

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

18.3.5 Roots 的安全考量

Client 侧:

  • 用户同意:暴露 Roots 前必须征得同意(通常隐式于 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 侧应做防抖(常见 150-300ms),Server 侧可限流
  • 低延迟:补全应 <200ms 返回,否则用户体验劣化
  • 缓存:对枚举类补全可 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 未知,也不能倒退
progresstotal 可为浮点支持”已处理字节数/总字节数”这类精细进度
message 人类可读UI 直接显示,比如”正在下载 foo.tar.gz”
请求完成后停止进度通知Server 不能在 response 后继续发 progress

18.5.3 百分比陷阱

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

  • progress + total一个分数,Client 自己算百分比
  • total 可以省略(流式任务不知道总量)
  • 多阶段任务应复位:不要让 progress 从 50 跳到 60(因为前 50 是阶段 1,后 10 是阶段 2),而是用 message 标注阶段

优雅实现:

// ❌ 错误:阶段间 progress 跳变
await report(50, 100, '阶段1完成');
await report(60, 100, '阶段2进行中');  // 用户困惑:为什么跳 10?

// ✅ 正确:每阶段独立 0-100,用 message 表达阶段
await report(0, 100, '阶段2:开始测试');
await report(50, 100, '阶段2:测试 50%');
await report(100, 100, '阶段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 的一方不再发对应响应——但允许已发出的响应到达(Client 必须忽略)

实现上的技巧: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 只发送不低于设定级别的日志。

{
  "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 安全约束

日志是数据泄露的重灾区。绝对不能包含:

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

实现建议:

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

// ✅ 安全
log('error', {
  message: 'Auth failed',
  tokenPrefix: userToken.slice(0, 4) + '****',
  bodySize: requestBody.length,
  bodyHash: hash(requestBody),
});

18.7.3 速率限制

日志可以成为 DoS 向量——Server 恶意或 bug 触发海量日志会打爆 Client。协议约定 Server 应实现速率限制(典型 100 条/秒或 1MB/秒)。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: ["/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: 同意
        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 能否做到多轮对话? 目前 Elicitation 是单次问答。如果 Server 要和用户做多轮交互(比如询问→确认→再询问),只能通过多次 Elicitation 实现。未来可能出现”Elicitation Session”概念。

Q2:URL Mode 能否支持 Server 主动通知结果? 目前 Client 在用户点”同意”后就返回 accept,Server 是否真正完成授权是另一码事。notifications/elicitation/complete 是一种补救,但协议层还可以更正式。

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

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

这些问题都不是理论问题——MCP 社区的 GitHub 里能找到相关讨论。协议的演进是一件令人兴奋的事,读者可以关注 spec 仓库的 RFC 流。

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

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

schema 则补上了协议边界。mcp-specification/schema/2025-11-25/schema.ts:2101-2115 定义 roots/list 返回 Root 数组,schema.ts:2122-2136 要求 Root URI 当前必须以 file:// 开头,可附带展示名;schema.ts:2151-2153 定义 roots 变化通知。Elicitation 侧,schema.ts:2161-2184 的 form mode 只允许顶层 primitive schema,schema.ts:2191-2214 的 URL mode 用 opaque elicitationId 与 URL 表达外部交互,schema.ts:2230-2232 再把它们统一成 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)控制可用性,通过请求关联约束(Request Association)确保语义合理性。它们的存在让 MCP 从一个简单的”工具调用协议”进化为一个支持复杂人机协作流程的完整框架

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

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