Skip to content

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移除会话状态,改显式句柄🟠 强烈建议服务器
7resources/subscribesubscriptions/listen🟠 强烈建议双方
8服务器主动请求 → MRTR🟠 强烈建议双方
9logging/setLevel → 请求级 logLevel🟡 建议双方
10删除 pingroots.list_changed🟡 建议双方
11错误码对齐(-32002-32602🟡 建议服务器
12停止使用 roots / sampling🟢 长期双方

🔴 1. 删除 initialize 握手

❌ 旧写法

javascript
// 客户端:连接后必须先握手
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' });
javascript
// 服务器:注册 initialize 处理器
this.requestHandlers.set('initialize', async (params) => ({
    protocolVersion: '2024-11-05',
    capabilities: { tools: { listChanged: true } },
    serverInfo: { name: 'my-server', version: '1.0.0' }
}));

✅ 新写法

javascript
// 客户端:没有握手了,直接发业务请求
// 把三个 _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() }
});
javascript
// 服务器:删掉 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应该每次带

服务器侧的校验:

javascript
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

javascript
// ❌ 旧写法
return { tools: [...] };
return { content: [{ type: 'text', text: '完成' }] };

// ✅ 新写法
return { resultType: 'complete', tools: [...] };
return { resultType: 'complete', content: [{ type: 'text', text: '完成' }] };

客户端侧要做向后兼容——这是规范强制要求的:

javascript
// 客户端:缺失 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

服务器必须实现,这是新版的兼容入口。它的返回结构:

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

双代服务器的兼容策略

如果你的服务器要同时服务新旧两代客户端(推荐做法):

javascript
// 按客户端怎么开场来决定走哪条路
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/discovertools/listprompts/listresources/listresources/templates/listresources/read

javascript
// 工具列表:变化不频繁,可以缓存久一点、允许共享缓存
return {
    resultType: 'complete',
    tools: [...],
    ttlMs: 300000,          // 5 分钟
    cacheScope: 'public'
};

// 资源读取:可能是用户私有数据,只允许私有缓存
return {
    resultType: 'complete',
    contents: [...],
    ttlMs: 60000,           // 1 分钟
    cacheScope: 'private'
};

怎么选参数:

场景ttlMscacheScope
工具/提示词列表(全局不变)大(如 300000+)public
资源列表(随用户变化)private
读取用户私有数据private
高频变化的数据小或省略缓存private

⚠️ 两种结果不得缓存resultType: "input_required" 的中间结果,以及带 inputResponses / requestState 的重试请求结果。

🟠 6. 会话状态 → 显式句柄

旧版靠 Mcp-Session-Id 或连接身份隐式维持状态。新版要求把状态显式化——作为普通工具参数传递。

❌ 旧写法:依赖会话

javascript
// 服务器在会话里存状态
session.set('current_dataset', dataset);

// 后续调用直接读取隐式状态
async function analyze() {
    const dataset = session.get('current_dataset');   // 依赖会话,无法水平扩展
    ...
}

✅ 新写法:显式句柄

javascript
// 第一个工具返回一个不透明句柄
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

❌ 旧写法

javascript
await connection.request({ method: 'resources/subscribe', params: { uri: 'file:///a.json' } });
// 然后在某个 GET SSE 流上收 notifications/resources/updated

✅ 新写法

javascript
// 开一条订阅流,用过滤器声明想收哪些类型的通知
{
    "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/listsampling/createMessageelicitation/create)全部改为MRTR

❌ 旧写法:服务器主动发请求

javascript
// 服务器在处理 tools/call 的过程中,反过来调客户端
const answer = await ctx.client.request({
    method: 'elicitation/create',
    params: { message: '请选择环境', requestedSchema: {...} }
});
// 然后继续处理

✅ 新写法:返回 input_required,客户端重试

javascript
// 第一步:服务器返回中间结果
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}` }] };
});
javascript
// 第二步:客户端收集输入后,用「新的请求 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

javascript
// ❌ 旧:客户端调一次,改动整个会话的日志级别
await connection.notify({ method: 'logging/setLevel', params: { level: 'debug' } });

// ✅ 新:放在单个请求的 _meta 里
params._meta['io.modelcontextprotocol/logLevel'] = 'debug';

❗ 服务器不得没有携带 logLevel 的请求发送 notifications/message

删除 pingnotifications/roots/list_changed

  • ping:无状态协议不需要保活。要检测健康状态,用自己的 HTTP 健康检查或直接发 server/discover
  • notifications/roots/list_changed:roots 已废弃,目录改由工具参数或资源 URI 传递

错误码对齐

javascript
// ❌ 旧:资源不存在用 -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
  • [ ] 版本不支持时返回 -32022data.supported 列出支持的版本
  • [ ] 每个结果都带 resultType
  • [ ] 6 类结果都带 ttlMs + cacheScope
  • [ ] 删除了 Mcp-Session-Id 相关逻辑,跨调用状态改用显式句柄
  • [ ] 删除了 ping / logging/setLevel / notifications/roots/list_changed 处理器
  • [ ] resources/subscribe 改为 subscriptions/listen,且确认消息先于任何通知
  • [ ] 不再主动发 JSON-RPC 请求;原有的 roots/listsampling/createMessageelicitation/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 重发

🔗 官方资料


迁移完成后,建议回到 1.5 协议规范 通读一遍,确认没有遗漏的旧模型痕迹。

📚 迁移完想继续推进12.1 OAuth 2.1 授权体系(如果你的服务器对外走 HTTP)、12.3 官方扩展体系(Tasks 已变为扩展)、12.5 MCP Inspector 工具链(用 protocol era 对比新旧行为)。

内容依据 MCP 官方规范整理 · 规范以 官方文档 为准