MCP 协议设计与实现
第18章 Elicitation、Roots 与配置管理
第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 只知道”登录失败”这四个字。它可以:
- 拒绝并返回错误:“缺少 project_key 参数” → 用户要重新措辞
- LLM 猜测填充:模型基于上下文胡乱填一个 → 创建到错误项目,灾难
- 返回占位结果:创建草稿再让用户修改 → 破坏了工具的原子语义
这三条路都不优雅。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 → 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)适合低敏感度数据——用户名、偏好、枚举选项。这些信息即使被恶意 Client 记录也无大碍。
- URL Mode(方案 C)适合高敏感度数据——密码、OAuth Token、支付凭证。这些绝不能经过 MCP Client 和 LLM 上下文。
把这条铁律刻在脑子里:凡是不能进 LLM 上下文的数据,都必须走 URL Mode。
18.2.2 两种模式的全景对比
| 维度 | Form Mode | URL 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 渲染建议 |
|---|---|---|
| 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 | 多选 Checkbox |
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 的安全炼金术
当涉及 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 的五条安全铁律:
- 不在 URL 中携带敏感信息——URL 会出现在浏览器历史、HTTP Referer、Server 访问日志中
- 不提供预认证 URL——防止 URL 泄露后被任何持有者滥用
- Client 必须显示完整 URL 并获用户明确同意——防 Server 把用户导向钓鱼网站
- Client 必须用安全浏览器(iOS 上用 SFSafariViewController,Android 用 Custom Tabs,桌面用系统默认浏览器)——绝不能用可被宿主 App 嗅探的 WebView
- 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/call、resources/read、prompts/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-frontend和myapp-backend两个 workspace - Server 有个
search_code工具 - 没有 Roots:Server 只能搜
$HOME,结果一片红色 - 有 Roots:Server 知道只需搜这两个目录,响应 <1s
18.3.2 信息性引导 vs 访问控制
协议明确定位 Roots 为”informational guidance”——Server 并非不能访问 Roots 外的路径。但一个行为良好的 Server 应当:
- 默认限定:搜索、分析、操作默认限定在 Roots 内
- 越界提示:要操作 Roots 外的路径时,向用户确认
- 缓存友好: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_changed | tools/list |
notifications/resources/list_changed | resources/list |
notifications/prompts/list_changed | prompts/list |
notifications/roots/list_changed | roots/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 未知,也不能倒退 |
progress 和 total 可为浮点 | 支持”已处理字节数/总字节数”这类精细进度 |
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 两条”铁律”
- 不能取消
initialize请求——协议握手是原子的 - 收到 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-130 的 ClientSession 构造函数同时接收 elicitation callback 与 list_roots callback;session.py:154-165 只有当这些 callback 不是默认实现时,初始化能力里才声明 elicitation 或 roots。随后 session.py:436-448 对 ElicitRequest 调用 _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 调用都是本章概念的生动注脚。