MCP 协议设计与实现

第4章 生命周期与能力协商

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

在前面的章节中,我们了解了 MCP 的整体架构和 JSON-RPC 传输层。但一个根本性的问题还没有回答:当客户端第一次遇到服务端时,它们如何从"陌生人"变成"合作者"?这正是生命周期管理要解决的核心问题。

MCP 协议定义了一套严格的连接生命周期,确保客户端与服务端在开始正式通信之前,先就协议版本和功能边界达成共识。这不是形式上的握手礼仪,而是协议安全运行的基石——如果两端对"能做什么"没有共识,任何后续交互都可能导致不可预期的错误。

本章讲的握手以本专栏锚定的 2025-11-25 版规范为准(两个 SDK 的 pin 版本最新支持的也是这一版)。需要提前说明:2026-07-28 版规范已经去掉了 initialize / notifications/initialized 握手(SEP-2575),改为每个请求在 _meta 里携带协议版本与客户端能力,版本不符时服务端返回 UnsupportedProtocolVersionError;ping 也在这一版被移除。下文凡说「规范」,除特别注明外均指 2025-11-25。

4.1 连接生命周期全景

一个 MCP 连接从建立到关闭,会经历三个明确的阶段:

stateDiagram-v2
    [*] --> 初始化阶段: 传输层连接建立
    初始化阶段 --> 正常运行阶段: initialized 通知发送
    正常运行阶段 --> 关闭阶段: 一方发起关闭
    关闭阶段 --> [*]: 连接释放
    state 初始化阶段 {
        [*] --> 等待初始化
        等待初始化 --> 协商中: 客户端发送 initialize 请求
        协商中 --> 已初始化: 服务端响应 + 客户端发送 initialized
    }
    state 正常运行阶段 {
        [*] --> 双向通信
        双向通信 --> 双向通信: 请求/响应/通知
    }

第一阶段:初始化(Initialization)。客户端发送 initialize 请求,携带自己支持的协议版本和能力声明;服务端回复自己的能力和选定的协议版本;客户端确认无误后发送 notifications/initialized 通知。这个阶段完成后,双方就建立了一份"合作契约"。

第二阶段:正常运行(Operation)。双方按照协商好的能力进行通信。客户端只能调用服务端声明支持的方法,服务端只能向客户端发起客户端声明支持的请求。

第三阶段:关闭(Shutdown)。通过传输层机制终止连接。对于 stdio 传输,客户端关闭输入流;对于 HTTP 传输,关闭相应的 HTTP 连接。

这三个阶段的划分不是建议性的,而是强制性的。规范明确要求:初始化阶段必须是客户端与服务端之间的第一次交互。两侧的禁令边界并不在同一个时刻上——客户端不应该在服务端回复 initialize 之前发送除 ping 以外的请求;服务端不应该在收到 initialized 通知之前发送除 ping 和 logging 以外的请求(docs/specification/2025-11-25/basic/lifecycle.mdx 的这两条 SHOULD NOT 分别锚在「响应 initialize」和「收到 initialized」上)。

4.2 初始化握手详解

初始化握手是整个生命周期中最关键的环节。让我们逐步拆解这个过程。

4.2.1 握手时序

sequenceDiagram
    participant C as 客户端 (Client)
    participant S as 服务端 (Server)
    Note over C,S: 阶段一:初始化
    C->>+S: initialize 请求
    Note right of C: 携带 protocolVersion、capabilities、clientInfo
    S-->>C: initialize 响应
    Note left of S: 携带 protocolVersion、capabilities、serverInfo
    C--)S: notifications/initialized
    Note over C,S: 阶段二:正常运行
    rect rgb(200, 220, 250)
        C->>S: tools/list、resources/read 等
        S-->>C: 响应结果
        S->>C: sampling/createMessage 等
        C-->>S: 采样结果
    end
    Note over C,S: 阶段三:关闭
    C--)S: 关闭传输层连接

整个过程遵循"三步走"的模式:请求 -> 响应 -> 通知。这个设计非常精妙——initialize 请求和响应完成了信息交换,而 notifications/initialized 通知则起到"确认信号"的作用,告诉服务端"我已经检查了你的响应,一切就绪,可以开始了"。

4.2.2 客户端发出的 initialize 请求

客户端发出的 initialize 请求包含三个核心字段:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2025-11-25",
    "capabilities": {
      "roots": { "listChanged": true },
      "sampling": {},
      "elicitation": {}
    },
    "clientInfo": {
      "name": "ExampleClient",
      "version": "1.0.0"
    }
  }
}
  • protocolVersion:客户端期望使用的协议版本,通常是客户端支持的最新版本。
  • capabilities:客户端声明自己支持的能力,如采样(sampling)、用户交互(elicitation)、文件系统根目录(roots)等。
  • clientInfo:客户端的身份信息,包含名称、版本号,还可选地包含标题、描述、图标等展示信息。

4.2.3 服务端返回的 initialize 响应

服务端收到请求后,进行版本协商和能力声明:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2025-11-25",
    "capabilities": {
      "logging": {},
      "prompts": { "listChanged": true },
      "resources": { "subscribe": true, "listChanged": true },
      "tools": { "listChanged": true }
    },
    "serverInfo": {
      "name": "ExampleServer",
      "version": "1.0.0"
    },
    "instructions": "本服务器提供文件管理和代码分析工具"
  }
}

响应中有一个值得特别注意的字段:instructions。这是服务端给客户端(以及背后的 LLM)的自然语言指令,可以引导模型如何使用该服务端提供的工具和资源。

4.2.4 initialized 通知

客户端验证服务端响应后,发送一个简单的通知:

{
  "jsonrpc": "2.0",
  "method": "notifications/initialized"
}

这个通知虽然简单,但意义重大——它标志着初始化阶段的结束和正常运行阶段的开始。服务端在收到此通知之前,不应该向客户端发送除 ping 和日志以外的请求。

4.3 协议版本协商

版本协商是初始化握手中最精密的子流程。MCP 使用日期格式的版本号(如 2025-11-25),这种设计使版本的新旧关系一目了然。

4.3.1 协商算法

版本协商的规则如下:

  1. 客户端在 initialize 请求中发送它支持的协议版本,应该是客户端支持的最新版本。
  2. 如果服务端支持该版本,必须回复相同的版本号。
  3. 如果服务端不支持该版本,必须回复它自己支持的另一个版本号,应该是服务端支持的最新版本。
  4. 客户端收到响应后,检查服务端回复的版本是否在自己的支持列表中。如果不在,应该断开连接。
flowchart TD
    A[客户端发送 protocolVersion] --> B{服务端是否支持该版本?}
    B -->|是| C[服务端回复相同版本]
    B -->|否| D[服务端回复自己支持的最新版本]
    C --> E[协商成功,使用该版本]
    D --> F{客户端是否支持服务端的版本?}
    F -->|是| G[协商成功,使用服务端的版本]
    F -->|否| H[协商失败,客户端断开连接]

4.3.2 SDK 中的真实实现

让我们看看官方 SDK 中版本协商的真实代码。

Python SDK 的版本定义(src/mcp/shared/version.py):

from mcp.types import LATEST_PROTOCOL_VERSION

SUPPORTED_PROTOCOL_VERSIONS: list[str] = [
    "2024-11-05",
    "2025-03-26",
    "2025-06-18",
    LATEST_PROTOCOL_VERSION  # "2025-11-25"
]

TypeScript SDK 的版本定义(@modelcontextprotocol/core 的 constants.ts):

export const LATEST_PROTOCOL_VERSION = '2025-11-25';

export const SUPPORTED_PROTOCOL_VERSIONS = [
  LATEST_PROTOCOL_VERSION,
  '2025-06-18',
  '2025-03-26',
  '2024-11-05',
  '2024-10-07'
];

注意两个 SDK 的版本列表顺序不同——TypeScript SDK 把最新版本放在首位,Python SDK 把最新版本放在末尾。判断对端版本是否可接受时只看"是否包含";但在 TypeScript SDK 里顺序并非无关:列表第一项就是客户端在 initialize 里发出的版本、也是服务端不支持客户端版本时回退的版本(ProtocolOptions.supportedProtocolVersions 的注释写明 "First version is preferred")。Python SDK 这两处直接用 LATEST_PROTOCOL_VERSION 常量,与列表顺序无关。

三个容易忽视的细节

并排对照两个 SDK 的协议版本相关文件,还有三处值得注意——

1. 支持版本数量不同:TS 5 个 vs Python 4 个——

SDK 支持版本列表
TS ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07'] —— 5 个
Python ['2024-11-05', '2025-03-26', '2025-06-18', '2025-11-25'] —— 4 个
差异 TS 多支持 2024-10-07——规范仓库里没有这一版的 schema(最早的是 2024-11-05),TS SDK 为它保留了 CompatibilityCallToolResultSchema(兼容旧版的 toolResult 字段,packages/core/src/types/schemas.ts:1402);Python SDK 的 src/ 在整个 git 历史里都没出现过这个版本号

2. 「缺失版本头就回退到 2025-03-26」两边都有,只是名字不同——

// TS:packages/core/src/types/constants.ts:2
export const DEFAULT_NEGOTIATED_PROTOCOL_VERSION = '2025-03-26';
# Python:src/mcp/types/_types.py:18
DEFAULT_NEGOTIATED_VERSION = "2025-03-26"

这是 HTTP 传输里客户端没带 MCP-Protocol-Version 头时的回退默认值——锁定到 2025-03-26(MCP-Protocol-Version 头正是 2025-06-18 才加的,所以不带头就只能假定它之前的那一版)而不是 LATEST,为了防止不知情的旧客户端撞上新协议特性。这正是规范 basic/transports.mdx 的要求:服务端没收到该头、又无从得知版本时,应该假定 2025-03-26。两个 SDK 都是假定默认值而不是拒绝请求:TS 在 packages/server/src/server/streamableHttp.ts:723 写 req.headers.get('mcp-protocol-version') ?? DEFAULT_NEGOTIATED_PROTOCOL_VERSION(这里用来决定是否发送 priming 事件),Python 在 src/mcp/server/streamable_http.py:854-855 写 if protocol_version is None: protocol_version = DEFAULT_NEGOTIATED_VERSION;只有当版本头带了、但不在支持列表里时,两边才回 400(TS 见同文件 validateProtocolVersion,:889-897;Python 见 src/mcp/server/streamable_http.py:858-866)。值得记住的是两边常量名不一样(TS 多一个 PROTOCOL),跨语言排查时按名字 grep 会漏。

3. TS SDK 里 LATEST_PROTOCOL_VERSION 有两个不一致的定义——

文件 值
packages/core/src/types/constants.ts:1 '2025-11-25'(SDK 运行时使用)
packages/core/src/types/spec.types.ts:37 'DRAFT-2026-v1'(自动拉取的规范类型)

spec.types.ts 由 pnpm run fetch:spec-types(scripts/fetch-spec-types.ts)从规范仓库 main 分支的 schema/draft/schema.ts 原样拉取,文件头注明了来源 commit,里面的版本号反映的是规范仓库当时的 draft;它只被测试 packages/core/test/spec.types.test.ts 引用,用来核对 SDK 类型与规范类型是否一致。SDK 在握手里实际使用的是 constants.ts 里的 '2025-11-25'。Python SDK 没有这样的自动拉取层,LATEST_PROTOCOL_VERSION 只在 src/mcp/types/_types.py:12 定义一处。

TypeScript SDK 的客户端初始化代码(Client.connect,节选自 packages/client/src/client/client.ts:504-537):

// 发送 initialize 请求,使用支持列表中的第一个版本
const result = await this._requestWithSchema({
    method: 'initialize',
    params: {
        protocolVersion:
            this._supportedProtocolVersions[0] ?? LATEST_PROTOCOL_VERSION,
        capabilities: this._capabilities,
        clientInfo: this._clientInfo
    }
}, InitializeResultSchema, options);

// (省略:result 为 undefined 时报错)
// 检查服务端回复的版本是否在支持列表中
if (!this._supportedProtocolVersions.includes(result.protocolVersion)) {
    throw new Error(
        `Server's protocol version is not supported: ${result.protocolVersion}`
    );
}

// 保存协商结果
this._serverCapabilities = result.capabilities;
this._negotiatedProtocolVersion = result.protocolVersion;
// ...(省略:保存 serverInfo、给 HTTP 传输设置协议版本头、保存 instructions)

await this.notification({
    method: 'notifications/initialized'
});

服务端的对应逻辑(Server._oninitialize,packages/server/src/server/server.ts:426-444):

private async _oninitialize(request: InitializeRequest): Promise<InitializeResult> {
    const requestedVersion = request.params.protocolVersion;

    this._clientCapabilities = request.params.capabilities;
    this._clientVersion = request.params.clientInfo;

    // 如果支持客户端请求的版本,就用它;否则用自己的首选版本
    const protocolVersion = this._supportedProtocolVersions.includes(requestedVersion)
        ? requestedVersion
        : (this._supportedProtocolVersions[0] ?? LATEST_PROTOCOL_VERSION);

    this.transport?.setProtocolVersion?.(protocolVersion);

    return {
        protocolVersion,
        capabilities: this.getCapabilities(),
        serverInfo: this._serverInfo,
        ...(this._instructions && { instructions: this._instructions })
    };
}

Python SDK 服务端的逻辑一致(ServerSession._received_request,节选,省略了初始化状态切换和 server_info 的 title、icons 等字段):

# src/mcp/server/session.py:167
case types.InitializeRequest(params=params):
    requested_version = params.protocol_version
    await responder.respond(
        types.InitializeResult(
            protocol_version=requested_version
            if requested_version in SUPPORTED_PROTOCOL_VERSIONS
            else types.LATEST_PROTOCOL_VERSION,
            capabilities=self._init_options.capabilities,
            server_info=types.Implementation(
                name=self._init_options.server_name,
                version=self._init_options.server_version,
            ),
        )
    )

两个 SDK 的逻辑可以概括为同一个模式:服务端优先尊重客户端的版本偏好,只有在不支持时才改回自己的首选版本(TS 取支持列表第一项,Python 取 LATEST_PROTOCOL_VERSION,默认都是 2025-11-25)。这种"客户端优先"的策略确保了向后兼容性——较新的服务端可以与较旧的客户端配合工作。

4.3.3 版本协商失败

规范在「错误处理」一节给出了这样一个初始化错误的示例:

{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32602,
    "message": "Unsupported protocol version",
    "data": {
      "supported": ["2024-11-05"],
      "requested": "1.0.0"
    }
  }
}

错误码 -32602 是 JSON-RPC 标准的"无效参数"错误码。data 字段中包含了服务端支持的版本列表和客户端请求的版本,便于诊断和调试。

不过要注意,按 §4.3.1 的协商规则,服务端遇到不支持的版本时必须回一个自己支持的版本,而不是报错;两个 SDK 的服务端都照此实现,从不返回这个错误。用 Python SDK 的 STDIO 服务端实测,客户端发 "protocolVersion": "1.0.0" 或 "2024-10-07",得到的都是正常结果 "protocolVersion": "2025-11-25",是否断开由客户端判断。

4.4 能力声明机制

版本协商解决了"说同一种语言"的问题,而能力协商则解决了"能做哪些事"的问题。MCP 的能力声明机制是一种精巧的"特性开关"系统——双方在初始化时通过 capabilities 字段声明自己支持的功能,后续通信必须严格遵守这些声明。

4.4.1 为什么需要能力声明

考虑这样一个场景:客户端调用了 tools/list 方法,但服务端根本没有实现任何工具。如果没有能力声明机制,这个请求会导致不可预期的错误——可能返回一个空列表,可能抛出一个运行时异常,可能超时。而有了能力声明,客户端在初始化阶段就知道服务端不支持工具,因此根本不会发出这个请求。

能力声明的本质是一种契约前置——把运行时可能出现的不兼容问题,提前到连接建立阶段暴露出来。

4.4.2 服务端能力

服务端通过 capabilities 字段声明自己提供哪些功能:

能力 说明 子能力
tools 提供可调用的工具 listChanged:工具列表变更通知
resources 提供可读取的资源 listChanged:资源列表变更通知;subscribe:支持订阅单个资源的变更
prompts 提供提示词模板 listChanged:模板列表变更通知
logging 发送结构化日志 无子能力
completions 支持参数自动补全 无子能力
tasks 支持 task 增强的请求(2025-11-25 新增,实验性) 见规范 tasks 一节
experimental 实验性特性 自定义

listChanged 子能力值得特别关注。当服务端声明 tools: { listChanged: true } 时,意味着它会在工具列表发生变化时主动通知客户端(发送 notifications/tools/list_changed)。客户端收到通知后可以重新获取工具列表,实现动态更新。这对于工具可能在运行时增减的服务端至关重要。

subscribe 子能力是资源(resources)特有的。声明 resources: { subscribe: true } 的服务端,允许客户端订阅特定资源的变化。当资源内容更新时,服务端会通知已订阅的客户端。

4.4.3 客户端能力

客户端的能力声明告诉服务端:你可以向我发起哪些请求。

能力 说明
sampling 支持 LLM 采样请求——服务端可以请求客户端调用 LLM 生成内容
roots 提供文件系统根目录——服务端可以了解客户端的工作空间结构
elicitation 支持用户交互请求——服务端可以请求客户端向用户展示表单或链接
tasks 支持 task 增强的请求(2025-11-25 新增,实验性)
experimental 实验性特性

客户端能力中最重要的是 sampling。这个能力赋予服务端一种独特的权力:向客户端请求 LLM 推理。这意味着一个不直接接入 LLM 的 MCP 服务端,可以通过客户端间接使用 AI 能力。但客户端如果没有声明 sampling 能力,服务端就不能发起 sampling/createMessage 请求。

roots 能力让服务端知道客户端管理着哪些文件系统根目录。子能力 listChanged 表示客户端会在根目录列表变化时通知服务端。(2026-07-28 版把 Sampling、Roots 与服务端的 Logging 三项特性标为 Deprecated,弃用窗口内仍可用,见该版 changelog.mdx,SEP-2577。)

elicitation 能力允许服务端通过客户端与最终用户交互。服务端可以请求客户端展示表单或 URL,收集用户的输入后返回。

4.4.4 能力与方法的映射关系

graph LR
    subgraph 服务端能力
        ST[tools] --> TL[tools/list]
        ST --> TC[tools/call]
        SR[resources] --> RL[resources/list]
        SR --> RR[resources/read]
        SR --> RS[resources/subscribe]
        SP[prompts] --> PL[prompts/list]
        SP --> PG[prompts/get]
        SL[logging] --> LS[logging/setLevel]
        SC[completions] --> CC[completion/complete]
    end
    subgraph 客户端能力
        CS[sampling] --> SM[sampling/createMessage]
        CR[roots] --> LR2[roots/list]
        CE[elicitation] --> EL[elicitation/create]
    end

这幅映射关系图揭示了 MCP 能力机制的核心设计理念:每个能力都精确对应一组方法。声明了某个能力,就等于承诺支持该能力下的方法;带子能力的方法还要看子能力,例如 resources/subscribe 只有在声明了 resources.subscribe 时才可用。

4.5 enforceStrictCapabilities:严格模式

TypeScript SDK 提供了一个重要的选项:enforceStrictCapabilities。它控制在发出请求时,是否检查对端是否声明了支持相应的能力。

export type ProtocolOptions = {
    enforceStrictCapabilities?: boolean;
    // ...
};

当 enforceStrictCapabilities 设为 true 时,每次发出请求前都会调用 assertCapabilityForMethod,如果对端没有声明相应能力,请求会被立即拒绝,抛出 SdkError(packages/core/src/shared/protocol.ts:816-823):

// Protocol._requestWithSchema 中的检查逻辑
if (this._options?.enforceStrictCapabilities === true) {
    try {
        this.assertCapabilityForMethod(request.method as RequestMethod);
    } catch (error) {
        earlyReject(error);
        return;
    }
}

客户端的能力检查逻辑清晰地映射了每个方法到对应的能力(节选自 packages/client/src/client/client.ts:581-641,省略了 logging、tools、completions 等分支):

protected assertCapabilityForMethod(method: RequestMethod): void {
    switch (method as ClientRequest['method']) {
        case 'prompts/get':
        case 'prompts/list':
            if (!this._serverCapabilities?.prompts) {
                throw new SdkError(
                    SdkErrorCode.CapabilityNotSupported,
                    `Server does not support prompts (required for ${method})`
                );
            }
            break;

        case 'resources/subscribe':
            if (!this._serverCapabilities?.resources) {
                throw new SdkError(/* ... */);
            }
            // 订阅还需要检查子能力
            if (!this._serverCapabilities.resources.subscribe) {
                throw new SdkError(
                    SdkErrorCode.CapabilityNotSupported,
                    `Server does not support resource subscriptions`
                );
            }
            break;

        case 'initialize':
        case 'ping':
            // 这两个方法不需要任何能力
            break;
    }
}

注意一个关键的设计决策:enforceStrictCapabilities 只检查远端的能力,不影响本地能力的检查。本地能力的错误声明被视为逻辑错误,始终会被检查。SDK 的注释明确解释了这一点:

Note that this DOES NOT affect checking of local side capabilities, as it is considered a logic error to mis-specify those.

当前 enforceStrictCapabilities 默认为 false,这是为了向后兼容早期没有正确声明能力的 SDK 版本。关闭时本地不拦截:listTools()、listPrompts()、listResources() 这类列表方法在对端没声明对应能力时直接返回空列表(如 listTools(),packages/client/src/client/client.ts:990-995),其余请求原样发出、由对端回 JSON-RPC 错误。但 SDK 的文档已经提示:未来版本将默认设为 true。所以建议新项目从一开始就启用严格模式。

4.6 初始化失败与错误恢复

初始化不总是一帆风顺的。TypeScript SDK 的客户端在初始化失败时会自动断开连接:

override async connect(transport: Transport, options?: RequestOptions) {
    await super.connect(transport);
    try {
        const result = await this._requestWithSchema(
            { method: 'initialize', params: { /* ... */ } },
            InitializeResultSchema, options
        );
        // 版本检查、保存能力、发送 initialized...
    } catch (error) {
        // 初始化失败,关闭连接
        void this.close();
        throw error;
    }
}

Python SDK 的客户端同样会在版本不匹配时抛出异常:

result = await self.send_request(
    types.InitializeRequest(params=types.InitializeRequestParams(
        protocol_version=types.LATEST_PROTOCOL_VERSION,
        capabilities=types.ClientCapabilities(sampling=sampling, ...),
        client_info=self._client_info,
    )),
    types.InitializeResult,
)

if result.protocol_version not in SUPPORTED_PROTOCOL_VERSIONS:
    raise RuntimeError(
        f"Unsupported protocol version from the server: "
        f"{result.protocol_version}"
    )

Python SDK 的服务端使用状态机来追踪初始化进度,拒绝在初始化完成前处理业务请求:

# src/mcp/server/session.py:167-195(节选,在 ServerSession._received_request 的 match 里)
case types.InitializeRequest(params=params):
    self._initialization_state = InitializationState.Initializing
    # 回复 InitializeResult...
    self._initialization_state = InitializationState.Initialized
case types.PingRequest():
    # Ping requests are allowed at any time
    pass
case _:
    if self._initialization_state != InitializationState.Initialized:
        raise RuntimeError(
            "Received request before initialization was complete"
        )

这个状态机保证了:任何非初始化、非 ping 的请求,在初始化完成之前都会被拒绝。有两个细节要看清。第一,状态在回复完 initialize 之后就切到 Initialized,并不等 notifications/initialized(收到这条通知时只是再设一次,src/mcp/server/session.py:201-202),这与规范「客户端在服务端回复 initialize 之后才可以发普通请求」的边界一致。第二,这里抛出的 RuntimeError 会被 BaseSession 的接收循环兜住(src/mcp/shared/session.py:377-388),对端收到的是一条笼统的 -32602 Invalid request parameters,看不到真实原因。用 STDIO 实测(Python SDK pin 版本的服务端,手写 JSON-RPC 逐行写入 stdin):

>> {"jsonrpc": "2.0", "id": 0, "method": "ping"}
>> {"jsonrpc": "2.0", "id": 9, "method": "tools/list"}
>> {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": {"name": "raw", "version": "0.1"}}}
>> {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}
<< {"jsonrpc":"2.0","id":0,"result":{}}
<< {"jsonrpc":"2.0","id":9,"error":{"code":-32602,"message":"Invalid request parameters","data":""}}
<< {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{...},"serverInfo":{...},"instructions":"demo server"}}
<< {"jsonrpc":"2.0","id":2,"result":{"tools":[...]}}

初始化前的 ping 正常应答,tools/list 被拒;initialize 之后即便没发 notifications/initialized,tools/list 也照常返回。另外,以无状态模式(stateless=True)创建的 ServerSession 一开始就处于 Initialized(src/mcp/server/session.py:90-92)。

4.7 优雅关闭

MCP 协议本身没有定义专门的关闭消息,而是复用传输层的关闭机制。这是一个务实的设计选择——不同传输方式有各自的关闭语义。

4.7.1 stdio 传输的关闭

对于 stdio 传输,规范定义了一个分级关闭流程:

  1. 客户端关闭子进程的输入流(即服务端的 stdin)
  2. 等待服务端自行退出
  3. 如果服务端在合理时间内没有退出,发送 SIGTERM
  4. 如果 SIGTERM 后仍未退出,发送 SIGKILL

反方向也允许:服务端可以关闭自己到客户端的输出流(stdout)并退出,由此发起关闭。

这种"优雅降级"的策略给了服务端完成清理工作的机会,同时保证了最终一定能关闭。

但规范只是流程骨架,实际实现还有几个工程细节值得看清楚。Python SDK 在 src/mcp/client/stdio.py:180-211 把关闭逻辑放在 stdio_client 上下文管理器的 finally 分支里:

# src/mcp/client/stdio.py:185
finally:
    # MCP spec: stdio shutdown sequence
    if process.stdin:
        try:
            await process.stdin.aclose()          # 步骤 1
        except Exception:
            pass
    try:
        with anyio.fail_after(PROCESS_TERMINATION_TIMEOUT):
            await process.wait()                  # 步骤 2:等 2 秒
    except TimeoutError:
        await _terminate_process_tree(process)    # 步骤 3-4:平台相关
    ...

PROCESS_TERMINATION_TIMEOUT 在 src/mcp/client/stdio.py:48 硬编码为 2.0 秒——关掉 stdin 之后,服务端只有 2 秒自行退出的窗口,超时就交给 _terminate_process_tree() 做 SIGTERM→SIGKILL 升级(那里还有一个各自独立的 2 秒,见第 11 章 §11.9.4)。这个值没有通过 stdio_client 的参数暴露,要改只能覆盖模块常量或 fork SDK。

源码给这个常量的注释只有一句 "Timeout for process termination before falling back to force kill"——没有说明为什么是 2 秒,规范正文也没有对这个数字提出要求。所以下面这段是笔者的推测而非官方口径:2 秒大致是"正常进程清理够用、又不至于让用户觉得 client 卡住"的折中。真要为你的场景调它,别依赖这个推测,直接量一下你的 server 关停耗时分布。

关键细节:_terminate_process_tree 不是杀单个进程,是杀进程树。 打开 POSIX 实现 src/mcp/os/posix/utilities.py:13(节选,省略了取不到 pid 时直接返回、killpg 失败时退回 terminate()/kill() 的分支):

pid = getattr(process, "pid", None) or getattr(getattr(process, "popen", None), "pid", None)
pgid = os.getpgid(pid)
os.killpg(pgid, signal.SIGTERM)                   # 向整个进程组发 SIGTERM
with anyio.move_on_after(timeout_seconds):
    while True:
        try:
            os.killpg(pgid, 0)                    # signal 0:探测进程组是否还活着
            await anyio.sleep(0.1)
        except ProcessLookupError:
            return
try:
    os.killpg(pgid, signal.SIGKILL)               # 再等 2 秒没退出就整组 SIGKILL
except ProcessLookupError:
    pass

这里 killpg 给整个进程组发信号,不是 kill(pid) 只对单个进程。原因:MCP server 常通过 uvx、npx、python -m 这类启动器运行——它们会再 fork/exec 出实际的 MCP server 进程。如果只杀直接子进程,孙子进程会成为孤儿、继续占着 stdin/stdout 等文件描述符,越积越多。用 killpg(pgid, SIGTERM) 把整个进程组一次性处理掉。这能成立的前提是子进程自成一组:POSIX 下 _create_platform_compatible_process 用 start_new_session=True 启动服务端(src/mcp/client/stdio.py:249),否则 killpg 会连客户端自己所在的进程组一起波及。

探测循环用 killpg(pgid, 0)——signal 0 是 POSIX 的"不发信号、只检查目标是否存在"调用约定。waitpid 只能等自己的直接子进程,要确认整个进程组是否都已退出,用 signal 0 探测最直接。

Windows 完全走另一套机制(src/mcp/os/win32/utilities.py:291-294)——Windows 没有 POSIX 那种可以整组发信号的进程组,SDK 改用 Job Object(节选):

job = getattr(process, "_job_object", None)
if job and win32job:
    win32job.TerminateJobObject(job, 1)   # 整个 Job Object 一起终结

启动进程时(同文件 line 268)调 AssignProcessToJobObject 把子进程绑定到一个 Job Object 上,它再创建的子进程默认也归入同一个 Job。关闭时调 TerminateJobObject 一次性杀整个 Job,随后再对进程本身调一次 terminate()——效果上对应 POSIX 的 killpg,但 API 完全不同。这就是为什么 SDK 要拆 os/posix/ 和 os/win32/ 两个包:同一个"杀进程树"需求、两套操作系统模型只能各写一份。

所以规范里"第 3-4 步发 SIGTERM/SIGKILL"在 POSIX 上是 killpg SIGTERM → 轮询 2 秒 → killpg SIGKILL 的三段式、Windows 上则是直接 TerminateJobObject、没有先温和后强制的两步——同一个规范意图被两种 OS 用不同原语各自落实。也就是说,在 Windows 上服务端收不到可以捕获后做清理的 SIGTERM,关掉 stdin 后的那 2 秒就是它唯一的清理窗口。

TypeScript SDK 的 StdioClientTransport.close()(packages/client/src/client/stdio.ts:205-244)走的是同样的时序:stdin.end() 后最多等 2 秒,进程未退出就 kill('SIGTERM'),再等 2 秒仍未退出就 kill('SIGKILL')。不同的是它只对直接子进程发信号,不处理进程组。实测中,Python SDK 的 STDIO 服务端在 stdin 关闭后约 0.1 秒内就自行退出,根本走不到发信号那一步。

4.7.2 HTTP 传输的关闭

对于 HTTP 传输,关闭更加简单——关闭相应的 HTTP 连接即可。

4.7.3 SDK 中的关闭实现

TypeScript SDK 的 Protocol.close() 方法委托给传输层:

async close(): Promise<void> {
    await this._transport?.close();
}

当连接关闭时(无论是主动关闭还是意外断开),_onclose 方法会执行全面的清理工作:

// packages/core/src/shared/protocol.ts:492-522
private _onclose(): void {
    const responseHandlers = this._responseHandlers;
    this._responseHandlers = new Map();
    this._progressHandlers.clear();
    this._taskManager.onClose();
    this._pendingDebouncedNotifications.clear();

    // 清掉所有超时定时器
    for (const info of this._timeoutInfo.values()) {
        clearTimeout(info.timeoutId);
    }
    this._timeoutInfo.clear();

    const requestHandlerAbortControllers = this._requestHandlerAbortControllers;
    this._requestHandlerAbortControllers = new Map();

    const error = new SdkError(
        SdkErrorCode.ConnectionClosed, 'Connection closed'
    );
    this._transport = undefined;

    // 先放用户的 onclose,再无论如何都把等待中的请求收尾掉
    try {
        this.onclose?.();
    } finally {
        // 通知所有等待响应的请求:连接已关闭
        for (const handler of responseHandlers.values()) {
            handler(error);
        }

        // 取消所有正在处理的请求
        for (const controller of requestHandlerAbortControllers.values()) {
            controller.abort(error);
        }
    }
}

这段代码展示了一个负责任的关闭流程:不仅释放资源,还主动通知所有等待中的请求处理器,让它们能够妥善处理关闭事件,而不是悬在那里等待永远不会到来的响应。

4.8 超时与重连

4.8.1 请求超时

MCP 规范建议为所有请求设置超时。TypeScript SDK 默认的请求超时为 60 秒:

export const DEFAULT_REQUEST_TIMEOUT_MSEC = 60_000;

超时机制支持按请求自定义,还有一个精妙的设计——resetTimeoutOnProgress。当收到进度通知时,可以重置超时计时器。这对长时间运行的操作特别有用:只要服务端在持续汇报进度,客户端就知道它还在工作,不应该超时。同时,maxTotalTimeout 提供了一个绝对上限,防止因为持续的进度通知而无限等待。

export type RequestOptions = {
    timeout?: number;                   // 单次超时
    resetTimeoutOnProgress?: boolean;  // 收到进度通知时是否重置超时
    maxTotalTimeout?: number;          // 最大总超时
    // ...
};

规范还要求:请求超时后,发送方应该给对端发一条 notifications/cancelled 并停止等待。TypeScript SDK 的超时与 AbortSignal 取消走同一个 cancel 分支(packages/core/src/shared/protocol.ts 的 _requestWithSchema),会先发取消通知再以 SdkError(RequestTimeout) 拒绝。给一个要跑 3 秒的 tools/call 设 timeout: 500 实测,线上报文是:

C->S {"method":"tools/call","params":{"name":"x","arguments":{}},"jsonrpc":"2.0","id":1}
C->S {"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1,"reason":"SdkError: Request timed out"}}

Python SDK 的 BaseSession.send_request() 默认不设超时(read_timeout_seconds 为 None);设了超时的请求超时时只在本地抛 MCPError(REQUEST_TIMEOUT)(src/mcp/shared/session.py:290-296),不会给对端发取消通知。另外,规范明确规定客户端不得取消自己的 initialize 请求(见 §4.9)。

4.8.2 重连机制

TypeScript SDK 的客户端支持重连。当传输层已经有 sessionId 时,connect 方法会跳过初始化握手,直接恢复之前协商好的协议版本:

override async connect(transport: Transport, options?: RequestOptions) {
    await super.connect(transport);
    // 如果已有 sessionId,说明是重连
    if (transport.sessionId !== undefined) {
        if (this._negotiatedProtocolVersion !== undefined
            && transport.setProtocolVersion) {
            transport.setProtocolVersion(this._negotiatedProtocolVersion);
        }
        return; // 跳过初始化
    }
    // 否则执行正常的初始化握手...
}

这个设计使得 HTTP 传输在网络波动时能够快速恢复,而不需要重新走一遍完整的初始化流程。

4.9 初始化为什么不能被取消:schema 里的状态纪律

生命周期章节最容易被写成“先 initialize、再 initialized、最后 close”的流程图,但真正的边界藏在 schema 注释里。modelcontextprotocol/schema/2025-11-25/schema.ts:250-272 把 initialize 定义为普通 request,params 里必须携带客户端支持的最新协议版本、客户端能力和 clientInfo;modelcontextprotocol/schema/2025-11-25/schema.ts:274-293 规定服务端响应的 protocolVersion 可以不同于客户端请求值,客户端如果不支持就必须断开(schema 注释用的是 MUST,lifecycle.mdx 正文用的是 SHOULD);modelcontextprotocol/schema/2025-11-25/schema.ts:295-303 再用 notifications/initialized 表示客户端已经接受协商结果。三步合起来不是礼貌性问候,而是从“未协商连接”切换到“已协商会话”的状态提交。

这也是为什么 modelcontextprotocol/schema/2025-11-25/schema.ts:231-243 在取消通知里专门写出一条限制:客户端不能尝试取消自己的 initialize 请求。规范没有写明理由;一种合理的理解是:这不是因为 initialize 特殊到不能失败,而是它失败时的恢复动作已经很明确:断开连接、重新建立、重新协商。如果允许 cancel initialize,双方就会多出一个尴尬中间态:服务端可能已经按某个版本初始化了 handler,客户端却说不需要这个结果;接下来任何 notification、progress 或 error response 都不知道应该按哪个版本解释。协议选择禁止这条路,相当于把状态机剪短。

Python 客户端的实现也印证了这点。src/mcp/client/session.py:148-183 在 initialize() 中先根据回调是否存在生成 sampling、elicitation、roots、tasks 等能力,再发送 initialize 请求;同文件 src/mcp/client/session.py:127-130 把默认回调与用户回调区分开,所以“能力声明”不是配置表里的静态文本,而是由实际可处理的回调决定。也就是说,一个客户端如果没有传 sampling callback,就不会在初始化时声明 sampling;服务端之后再发 sampling/createMessage,就属于能力协商违约。

这给生产实现带来三个实用约束。第一,初始化失败不要半恢复:应关闭 transport,清理 pending request,重新创建会话对象。第二,能力变更不能偷偷发生:能力只在初始化时交换一次,list_changed 类通知只能表达已声明能力范围内的列表变化;运行中要启用一个新能力,应该重新连接,而不是直接接受新 method。第三,版本兼容逻辑要集中在初始化阶段;业务 handler 不应该在每次工具调用里散落“如果协议版本是 X 就怎样”的分支,否则生命周期就失去“先协商、后执行”的价值。

还有一个容易漏掉的细节:initialized 是 notification,不会有 response,也就不能作为“服务端确认已就绪”的等待点。客户端发出它之后,应把本地状态切成可用;服务端收到它之后,才可以向客户端发起 ping 与日志以外的请求。如果这条 notification 因连接中断丢失,正确恢复方式仍然是重连重协商,而不是补发一个半初始化会话里的业务请求。生命周期状态一旦靠 notification 推进,就必须接受它的单向语义。

所以初始化日志最好分阶段记录:已发送 initialize、已收到 InitializeResult、已发送 initialized、已开放业务请求,四个状态应能在日志中区分。

4.10 设计洞察

回顾整个生命周期机制,我们可以提炼出几个核心设计原则。

契约前置原则。MCP 把所有兼容性问题都集中在初始化阶段暴露,而不是分散在后续的每次交互中。这大大简化了运行时的错误处理——一旦初始化成功,后续交互就可以在一个确定性更强的环境中进行。

能力驱动原则。MCP 的方法不是"所有人都可以调用"的,而是需要相应能力背书的。这种设计使得协议具有良好的可扩展性——新增功能时只需要新增能力声明,不需要修改已有方法的行为。

渐进协商原则。版本协商不是"全有或全无"的——当双方最新版本不一致时,服务端回一个自己支持的版本,只要客户端也支持就能继续。这让不同版本的实现在有共同版本时可以互操作;不过协商只有这一个来回,服务端给出的版本客户端不支持就只能断开。

优雅降级原则。从初始化失败时的自动断开,到关闭时的分级信号(关闭流 -> SIGTERM -> SIGKILL),整个生命周期的异常处理都遵循"先尝试优雅,再强制执行"的策略。

这些设计原则不是 MCP 独创的——它们在 TLS 握手、HTTP 内容协商、WebSocket 建连等协议中都有体现。MCP 的贡献在于,它把这些经过验证的模式有机地组合在一起,构建了一个适合 AI Agent 场景的连接管理框架。