11.2 迁移到 2026-07-28
🎯 学习目标:把已有的 MCP 服务器/客户端从
initialize握手模型迁移到 2026-07-28 的无状态模型
⏱️ 预计时间:35分钟
📊 难度等级:⭐⭐⭐⭐
📌 适用对象:你已经有一个能跑的 MCP 实现(基于 2024-11-05 ~ 2025-11-25 中任一版本),现在想对齐
2026-07-28。
🗺️ 迁移全景
这次迁移和以往"加个字段"的版本升级完全不同。它改的是协议的基本形态:
好消息:因为新模型是无状态的,服务器侧的改造其实变简单了——不需要会话表、不需要连接级状态、不需要处理时序。难点主要在客户端侧的兼容处理。
📋 迁移清单
按优先级排列。前四项是必须改的,不改就跑不起来。
| # | 改动 | 优先级 | 影响范围 |
|---|---|---|---|
| 1 | 删除 initialize 握手 | 🔴 必须 | 客户端 + 服务器 |
| 2 | 每请求注入 _meta | 🔴 必须 | 客户端(服务器需校验) |
| 3 | 所有结果带 resultType | 🔴 必须 | 服务器 |
| 4 | 实现 server/discover | 🔴 必须 | 服务器 |
| 5 | 列表/读取结果带 ttlMs + cacheScope | 🟠 强烈建议 | 服务器 |
| 6 | 移除会话状态,改显式句柄 | 🟠 强烈建议 | 服务器 |
| 7 | resources/subscribe → subscriptions/listen | 🟠 强烈建议 | 双方 |
| 8 | 服务器主动请求 → MRTR | 🟠 强烈建议 | 双方 |
| 9 | logging/setLevel → 请求级 logLevel | 🟡 建议 | 双方 |
| 10 | 删除 ping、roots.list_changed | 🟡 建议 | 双方 |
| 11 | 错误码对齐(-32002 → -32602) | 🟡 建议 | 服务器 |
| 12 | 停止使用 roots / sampling | 🟢 长期 | 双方 |
🔴 1. 删除 initialize 握手
❌ 旧写法
// 客户端:连接后必须先握手
const handshake = await connection.request({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: { roots: { listChanged: true }, sampling: {} },
clientInfo: { name: 'my-client', version: '1.0.0' }
}
});
// 再补一个初始化完成通知
await connection.notify({ jsonrpc: '2.0', method: 'notifications/initialized' });
// 之后才能发业务请求
const tools = await connection.request({ jsonrpc: '2.0', id: 2, method: 'tools/list' });// 服务器:注册 initialize 处理器
this.requestHandlers.set('initialize', async (params) => ({
protocolVersion: '2024-11-05',
capabilities: { tools: { listChanged: true } },
serverInfo: { name: 'my-server', version: '1.0.0' }
}));✅ 新写法
// 客户端:没有握手了,直接发业务请求
// 把三个 _meta 字段做成辅助方法,每个请求都注入
buildMeta(extra = {}) {
return {
'io.modelcontextprotocol/protocolVersion': '2026-07-28',
'io.modelcontextprotocol/clientInfo': { name: 'my-client', version: '1.0.0' },
'io.modelcontextprotocol/clientCapabilities': { elicitation: {} },
...extra
};
}
const tools = await connection.request({
jsonrpc: '2.0',
id: 2,
method: 'tools/list',
params: { _meta: this.buildMeta() }
});// 服务器:删掉 initialize 处理器,改为 server/discover
this.requestHandlers.set('server/discover', async () => ({
resultType: 'complete',
supportedVersions: ['2026-07-28'],
capabilities: {
tools: { listChanged: true },
resources: { listChanged: true }
},
_meta: {
'io.modelcontextprotocol/serverInfo': { name: 'my-server', version: '1.0.0' }
},
instructions: '提供文件读写与代码分析工具。',
ttlMs: 3600000,
cacheScope: 'public'
}));⚠️ 注意
clientCapabilities是必需字段。请求缺少它会返回-32602,HTTP 上是400 Bad Request。即使你的客户端什么都不支持,也要传一个空对象{}。
🔴 2. 每请求注入 _meta
这是无状态模型的核心。服务器不得依赖任何连接状态,所以客户端必须把上下文放在每个请求里。
必需字段:
| 字段 | 必需 | 说明 |
|---|---|---|
io.modelcontextprotocol/protocolVersion | 是 | 本请求用的协议版本 |
io.modelcontextprotocol/clientCapabilities | 是 | 可以为 {} |
io.modelcontextprotocol/clientInfo | 否 | 但应该每次带 |
服务器侧的校验:
function validateRequestMeta(params) {
const meta = params?._meta;
if (!meta) {
throw new MCPError('缺少 _meta', -32602);
}
if (typeof meta['io.modelcontextprotocol/protocolVersion'] !== 'string') {
throw new MCPError('缺少 io.modelcontextprotocol/protocolVersion', -32602);
}
if (meta['io.modelcontextprotocol/clientCapabilities'] === undefined) {
throw new MCPError('缺少 io.modelcontextprotocol/clientCapabilities', -32602);
}
const requested = meta['io.modelcontextprotocol/protocolVersion'];
if (!SUPPORTED_VERSIONS.includes(requested)) {
throw new MCPError('Unsupported protocol version', -32022, {
supported: SUPPORTED_VERSIONS,
requested
});
}
return meta;
}📌 服务器不应把校验结果缓存到连接上——下一个请求可能来自不同的客户端。
🔴 3. 所有结果带 resultType
// ❌ 旧写法
return { tools: [...] };
return { content: [{ type: 'text', text: '完成' }] };
// ✅ 新写法
return { resultType: 'complete', tools: [...] };
return { resultType: 'complete', content: [{ type: 'text', text: '完成' }] };客户端侧要做向后兼容——这是规范强制要求的:
// 客户端:缺失 resultType 时按 complete 处理(兼容旧服务器)
const resultType = result?.resultType ?? 'complete';
switch (resultType) {
case 'complete':
return result;
case 'input_required':
return handleInputRequired(result); // 见第 8 项 MRTR
default:
throw new MCPError(`无法识别的 resultType: ${resultType}`, -32603);
}💡 这几行兼容代码价值很高:它让你的客户端能同时对付新旧两代服务器。
🔴 4. 实现 server/discover
服务器必须实现,这是新版的兼容入口。它的返回结构:
{
"jsonrpc": "2.0",
"id": "discover-1",
"result": {
"resultType": "complete",
"supportedVersions": ["2026-07-28", "2025-11-25"],
"capabilities": {
"tools": { "listChanged": true },
"resources": { "listChanged": true },
"prompts": { "listChanged": true }
},
"_meta": {
"io.modelcontextprotocol/serverInfo": { "name": "my-server", "version": "1.0.0" }
},
"instructions": "面向 LLM 的使用说明:本服务器提供…",
"ttlMs": 3600000,
"cacheScope": "public"
}
}双代服务器的兼容策略
如果你的服务器要同时服务新旧两代客户端(推荐做法):
// 按客户端怎么开场来决定走哪条路
async function handleRequest(request) {
// 带现代 _meta 的请求 → 走新模型,无状态处理
if (request.params?._meta?.['io.modelcontextprotocol/protocolVersion']) {
return handleModern(request);
}
// 收到 initialize → 走旧版语义(需要你保留旧实现)
if (request.method === 'initialize') {
return handleLegacyInitialize(request);
}
// 什么都不带的请求 → 拒绝,并在错误信息里说明支持的版本
throw new MCPError(
`Unsupported protocol version. This server supports: ${SUPPORTED_VERSIONS.join(', ')}`,
-32022
);
}💡 官方建议:只支持现代版本的服务器,应该在给
initialize的错误信息里点名自己支持的版本。因为旧客户端没有向前回退机制,这条错误消息可能是它能拿到的唯一诊断信息。
🟠 5. 缓存提示:ttlMs + cacheScope
下列结果的 resultType: "complete" 响应必须带缓存提示:
server/discover、tools/list、prompts/list、resources/list、resources/templates/list、resources/read
// 工具列表:变化不频繁,可以缓存久一点、允许共享缓存
return {
resultType: 'complete',
tools: [...],
ttlMs: 300000, // 5 分钟
cacheScope: 'public'
};
// 资源读取:可能是用户私有数据,只允许私有缓存
return {
resultType: 'complete',
contents: [...],
ttlMs: 60000, // 1 分钟
cacheScope: 'private'
};怎么选参数:
| 场景 | ttlMs | cacheScope |
|---|---|---|
| 工具/提示词列表(全局不变) | 大(如 300000+) | public |
| 资源列表(随用户变化) | 中 | private |
| 读取用户私有数据 | 小 | private |
| 高频变化的数据 | 小或省略缓存 | private |
⚠️ 两种结果不得缓存:
resultType: "input_required"的中间结果,以及带inputResponses/requestState的重试请求结果。
🟠 6. 会话状态 → 显式句柄
旧版靠 Mcp-Session-Id 或连接身份隐式维持状态。新版要求把状态显式化——作为普通工具参数传递。
❌ 旧写法:依赖会话
// 服务器在会话里存状态
session.set('current_dataset', dataset);
// 后续调用直接读取隐式状态
async function analyze() {
const dataset = session.get('current_dataset'); // 依赖会话,无法水平扩展
...
}✅ 新写法:显式句柄
// 第一个工具返回一个不透明句柄
this.tools.set('open_dataset', async ({ path }) => {
const handle = mintHandle(path); // 服务器自己生成,如签名的不透明 ID
await handleStore.put(handle, { path, openedAt: Date.now() });
return {
resultType: 'complete',
content: [{ type: 'text', text: `已打开数据集,句柄:${handle}` }],
isError: false
};
});
// 后续工具显式接收句柄
this.tools.set('analyze', async ({ dataset_handle, query }) => {
const state = await handleStore.get(dataset_handle);
if (!state) {
return {
resultType: 'complete',
content: [{ type: 'text', text: `数据集句柄已失效:${dataset_handle}` }],
isError: true
};
}
...
});这样做的收益:
- ✅ 服务器可以水平扩展——任何实例都能处理任何请求
- ✅ 句柄可以加密签名、可以设过期时间、可以审计
- ✅ 客户端可以并发处理多个数据集,互不干扰
📌 句柄应该有有效期,并且过期后返回明确的错误,让 LLM 知道要重新打开。
🟠 7. 资源订阅 → subscriptions/listen
❌ 旧写法
await connection.request({ method: 'resources/subscribe', params: { uri: 'file:///a.json' } });
// 然后在某个 GET SSE 流上收 notifications/resources/updated✅ 新写法
// 开一条订阅流,用过滤器声明想收哪些类型的通知
{
"jsonrpc": "2.0",
"id": 1,
"method": "subscriptions/listen",
"params": {
"notifications": {
"resourcesListChanged": true,
"resourceSubscriptions": ["file:///a.json"]
},
"_meta": { /* 必需的 _meta 字段 */ }
}
}
// 服务器必须先回确认(流的第一条消息)
{ "jsonrpc": "2.0", "method": "notifications/subscriptions/acknowledged",
"params": { "_meta": { "io.modelcontextprotocol/subscriptionId": 1 },
"notifications": { "resourcesListChanged": true, "resourceSubscriptions": ["file:///a.json"] } } }
// 之后的每条通知都带 subscriptionId
{ "jsonrpc": "2.0", "method": "notifications/resources/updated",
"params": { "_meta": { "io.modelcontextprotocol/subscriptionId": 1 }, "uri": "file:///a.json" } }迁移要点:
- 删掉
resources/subscribe/resources/unsubscribe两个方法的处理器 - 删掉 HTTP GET 端点
- 服务器在确认消息之前,不得发任何通知
- stdio 重连后客户端必须重发
subscriptions/listen(服务器不保存订阅状态)
🟠 8. 服务器主动请求 → MRTR
这是改动最大的一项。旧版"服务器反过来问客户端"的三种方式(roots/list、sampling/createMessage、elicitation/create)全部改为MRTR。
❌ 旧写法:服务器主动发请求
// 服务器在处理 tools/call 的过程中,反过来调客户端
const answer = await ctx.client.request({
method: 'elicitation/create',
params: { message: '请选择环境', requestedSchema: {...} }
});
// 然后继续处理✅ 新写法:返回 input_required,客户端重试
// 第一步:服务器返回中间结果
this.tools.set('deploy_service', async ({ service }, ctx) => {
const environment = ctx.inputResponses?.['ask-1']?.content?.environment;
if (!environment) {
// 还缺信息 → 返回 input_required
return {
resultType: 'input_required',
inputRequests: {
'ask-1': {
method: 'elicitation/create',
params: {
message: '请选择要部署的环境',
requestedSchema: {
type: 'object',
properties: {
environment: { type: 'string', enum: ['staging', 'production'] }
},
required: ['environment']
}
}
}
}
};
}
// 拿到输入 → 正常执行
return { resultType: 'complete', content: [{ type: 'text', text: `已部署到 ${environment}` }] };
});// 第二步:客户端收集输入后,用「新的请求 ID」重试原请求
const retry = await connection.request({
jsonrpc: '2.0',
id: nextId(), // 必须是新的 ID
method: 'tools/call',
params: {
name: 'deploy_service',
arguments: { service: 'api' },
inputResponses: {
'ask-1': { action: 'accept', content: { environment: 'staging' } }
},
_meta: this.buildMeta()
}
});关键点:
- 服务器不得发起 JSON-RPC 请求,客户端不得发送 JSON-RPC 响应(规范原文)
- 重试是全新的请求,用新的
id - 服务器要跨重试关联状态,把自定义标识放进中间结果的
requestState,客户端原样带回 - 用到的客户端能力必须已在
clientCapabilities里声明,否则返回-32021
💡 这个改动其实很划算:MRTR 让长任务天然可以跨越多次 HTTP 请求,服务器不需要维护待决请求表,也不需要长连接。
🟡 9~12. 其余清理项
logging/setLevel → 请求级 logLevel
// ❌ 旧:客户端调一次,改动整个会话的日志级别
await connection.notify({ method: 'logging/setLevel', params: { level: 'debug' } });
// ✅ 新:放在单个请求的 _meta 里
params._meta['io.modelcontextprotocol/logLevel'] = 'debug';❗ 服务器不得为没有携带
logLevel的请求发送notifications/message。
删除 ping 与 notifications/roots/list_changed
ping:无状态协议不需要保活。要检测健康状态,用自己的 HTTP 健康检查或直接发server/discovernotifications/roots/list_changed:roots 已废弃,目录改由工具参数或资源 URI 传递
错误码对齐
// ❌ 旧:资源不存在用 -32002
throw new MCPError('Resource not found', -32002);
// ✅ 新:用 -32602
throw new MCPError('Resource not found', -32602);同时确认没有误用规范保留区间(-32020 ~ -32099)。自定义错误码应该分配到 JSON-RPC 保留范围(-32768 ~ -32000)之外。
停止使用 roots / sampling
按官方建议:
| 旧用法 | 新做法 |
|---|---|
| 用 roots 告诉服务器可访问哪些目录 | 通过工具参数传路径,或用资源 URI,或写进服务器配置 |
| 用 sampling 让客户端帮忙调 LLM | 服务器直接对接 LLM 提供方 API |
🔄 兼容矩阵:改造后能不能互通
| 你的客户端 | 对方服务器 | 结果 |
|---|---|---|
| 现代(2026-07-28) | 现代 | ✅ 正常 |
| 现代 | 旧版 | ❌ 失败。stdio 上应该先发 server/discover 以便确定性失败,然后把可读的错误抛给用户 |
| 双代 | 现代 | ✅ 正常,走现代路径 |
| 双代 | 旧版 | ✅ 回退到 initialize |
| 旧版 | 现代 | ❌ 失败。旧客户端没有向前回退机制 |
| 旧版 | 双代 | ✅ 正常 |
客户端怎么探测服务器是哪一代
| 传输 | 探测方式 |
|---|---|
| stdio | 先发 server/discover;收到可识别的现代错误(如 -32022)→ 现代服务器;其他任何错误 → 旧版服务器,回退到 initialize |
| Streamable HTTP | 直接发一个现代请求;如果返回 400 且响应体里是可识别的现代错误 → 现代服务器;如果是其他 4xx 且没有现代错误体 → 旧版服务器,回退 |
探测结果应该缓存:stdio 上按进程生命周期缓存,HTTP 上按 origin 缓存;可以跨重启持久化,但缓存假设失效时要重新探测。
✅ 迁移自检表
改完后逐项对照:
服务器侧
- [ ] 删除了
initialize/notifications/initialized处理器 - [ ] 实现了
server/discover,返回supportedVersions/capabilities/_meta.serverInfo - [ ] 每个请求都校验
_meta里的必需字段,缺失返回-32602 - [ ] 版本不支持时返回
-32022,data.supported列出支持的版本 - [ ] 每个结果都带
resultType - [ ] 6 类结果都带
ttlMs+cacheScope - [ ] 删除了
Mcp-Session-Id相关逻辑,跨调用状态改用显式句柄 - [ ] 删除了
ping/logging/setLevel/notifications/roots/list_changed处理器 - [ ]
resources/subscribe改为subscriptions/listen,且确认消息先于任何通知 - [ ] 不再主动发 JSON-RPC 请求;原有的
roots/list、sampling/createMessage、elicitation/create改为 MRTR - [ ] 资源不存在的错误码改为
-32602 - [ ]
tools/list返回确定顺序
客户端侧
- [ ] 删除了握手流程,改为每请求注入
_meta - [ ]
clientCapabilities始终存在(可以为{}) - [ ] 缺失
resultType时按"complete"处理 - [ ] 处理
resultType: "input_required",带inputResponses用新 ID 重试 - [ ] 不再依赖
Mcp-Session-Id - [ ] 订阅改为
subscriptions/listen,会核对确认结果 - [ ] stdio 重连后重发
subscriptions/listen - [ ] 断流后用新请求 ID 重发,非幂等操作先查状态
- [ ] 不再尝试用
Last-Event-ID续传
⚠️ 迁移中最容易踩的坑
| 坑 | 症状 | 修法 |
|---|---|---|
忘了带 clientCapabilities | 所有请求被 -32602 拒绝 | _meta 里至少放一个 {} |
结果漏了 resultType | 新版客户端拒绝解析 | 每个结果都补上 |
列表结果漏了 ttlMs / cacheScope | 严格的客户端判定不合规 | 6 类结果补齐 |
| 复用同一个请求 ID 重试 MRTR | 客户端无法区分响应 | 每次重试用新 ID |
| 订阅确认前就发通知 | 客户端判定协议违规 | 确认必须是流的第一条消息 |
| 服务器把校验结果缓存到连接上 | 混用客户端时行为错乱 | 每请求独立校验 |
| stdio 上写日志到 stdout | 分帧被破坏,解析全崩 | 日志一律写 stderr |
| 断流后想"续传" | 实现里找不到 Last-Event-ID | 用新 ID 重发 |
🔗 官方资料
- 🔄 官方变更日志 · Key Changes
- 🧭 Versioning and Compatibility(含官方兼容矩阵)
- 🔍 Discovery(
server/discover) - 🔁 Multi Round-Trip Requests
- 📡 Subscriptions
- 🗄️ Caching
迁移完成后,建议回到 1.5 协议规范 通读一遍,确认没有遗漏的旧模型痕迹。
📚 迁移完想继续推进:12.1 OAuth 2.1 授权体系(如果你的服务器对外走 HTTP)、12.3 官方扩展体系(Tasks 已变为扩展)、12.5 MCP Inspector 工具链(用 protocol era 对比新旧行为)。