MCP 协议设计与实现

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

作者 杨艺韬 · 6,784 字

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

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

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

4.1 连接生命周期全景

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

stateDiagram-v2
    [*] --> 初始化阶段: 传输层连接建立
    初始化阶段 --> 正常运行阶段: initialized 通知发送
    正常运行阶段 --> 关闭阶段: 一方发起关闭
    关闭阶段 --> [*]: 连接释放

    state 初始化阶段 {
        [*] --> 等待初始化
        等待初始化 --> 协商中: 客户端发送 initialize 请求
        协商中 --> 已初始化: 服务端响应 + 客户端发送 initialized
    }

    state 正常运行阶段 {
        [*] --> 双向通信
        双向通信 --> 双向通信: 请求/响应/通知
    }

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

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

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

这三个阶段的划分不是建议性的,而是强制性的。规范明确要求:初始化阶段必须是客户端与服务端之间的第一次交互。在初始化完成之前,客户端不应该发送除 ping 以外的请求;服务端不应该发送除 pinglogging 以外的请求。

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

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

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 的版本定义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/coreconstants.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 把最新版本放在末尾。但这不影响协商逻辑,因为关键在于”是否包含”,而非顺序。

三个被忽视的不对称(实测)

并排实测两个 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——MCP 最早期的预览版本——Python SDK 从未支持过

2. TS 有 DEFAULT_NEGOTIATED_PROTOCOL_VERSION 常量,Python 没有——

// TS constants.ts:2
export const DEFAULT_NEGOTIATED_PROTOCOL_VERSION = '2025-03-26';

这是 TS SDK 在 HTTP 传输里当客户端没显式声明版本时的回退默认值——锁定到 2025-03-26 而不是 LATEST——为了防止不知情的旧客户端撞上新协议特性。Python SDK 没有等价物——传输层直接拒绝缺失版本的请求。

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

文件
packages/core/src/types/constants.ts:1'2025-11-25'(生产用)
packages/core/src/types/spec.types.ts:37'DRAFT-2026-v1'(spec 类型生成用)

spec.types.ts 是从 MCP 协议官方 JSON Schema 自动生成的类型文件——里面的版本号反映spec 仓库当前 draftconstants.ts 是 SDK 实际发布时冻结的稳定版本。两个值不一致是因为 TS SDK 用”spec 跑在最前、SDK 落地稍后”的双轨策略——这种隔离让 spec 仓库改动 draft 不会立即破坏 SDK 编译。Python SDK 没有自动生成层、LATEST_PROTOCOL_VERSION 单一来源——简化但牺牲了对 spec 演进的提前观察能力。

TypeScript SDK 的客户端初始化代码(Client.connect):

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

// 检查服务端回复的版本是否在支持列表中
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;

服务端的对应逻辑(Server._oninitialize):

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

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

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

Python SDK 服务端的逻辑完全一致(ServerSession._received_request):

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 的逻辑可以概括为同一个模式:服务端优先尊重客户端的版本偏好,只有在不支持时才降级到自己的最新版本。这种”客户端优先”的策略确保了向后兼容性——较新的服务端可以与较旧的客户端配合工作。

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.4 能力声明机制

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

4.4.1 为什么需要能力声明

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

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

4.4.2 服务端能力

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

能力说明子能力
tools提供可调用的工具listChanged:工具列表变更通知
resources提供可读取的资源listChanged:资源列表变更通知;subscribe:支持订阅单个资源的变更
prompts提供提示词模板listChanged:模板列表变更通知
logging发送结构化日志无子能力
completions支持参数自动补全无子能力
experimental实验性特性自定义

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

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

4.4.3 客户端能力

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

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

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

roots 能力让服务端知道客户端管理着哪些文件系统根目录。子能力 listChanged 表示客户端会在根目录列表变化时通知服务端。

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 能力机制的核心设计理念:每个能力都精确对应一组方法。声明了某个能力,就等于承诺支持该能力下的所有方法。

4.5 enforceStrictCapabilities:严格模式

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

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

enforceStrictCapabilities 设为 true 时,每次发出请求前都会调用 assertCapabilityForMethod,如果对端没有声明相应能力,请求会被立即拒绝,抛出 SdkError

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

客户端的能力检查逻辑清晰地映射了每个方法到对应的能力:

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 版本。但 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 的服务端使用状态机来追踪初始化进度,拒绝在初始化完成前处理业务请求:

case types.InitializeRequest(params=params):
    self._initialization_state = InitializationState.Initializing
    # 处理初始化...
    self._initialization_state = InitializationState.Initialized

case _:
    if self._initialization_state != InitializationState.Initialized:
        raise RuntimeError(
            "Received request before initialization was complete"
        )

这个状态机保证了一个不可绕过的约束:任何非初始化、非 ping 的请求,在初始化完成之前都会被拒绝。

4.7 优雅关闭

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

4.7.1 stdio 传输的关闭

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

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

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

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

# mcp/client/stdio.py:186
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 在 line 48 硬编码为 2.0 秒——服务端只有 2 秒优雅退出窗口,超出就进暴力流程。这个值没有 CLI 参数暴露、改需要 fork SDK,设计意图是”2 秒对大多数正常清理够用、再长用户会认为 client hang”。

关键细节:_terminate_process_tree 不是杀单个进程、是杀进程树**。** 打开 POSIX 实现 src/mcp/os/posix/utilities.py:13

pid = getattr(process, "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 常通过 uvxnpxpython -m 这类启动器运行——它们会再 fork/exec 出实际的 MCP server 进程。如果只杀直接子进程,孙子进程会成为孤儿、继续吃 stdin/stdout 文件描述符,最终累积成 zombie fleet。用 killpg(pgid, SIGTERM) 把整棵树一次性处理掉。

探测循环用 killpg(pgid, 0)——signal 0 是 POSIX 的”不发信号、只检查目标是否存在”调用约定。比轮询 /proc/$pid 或调 waitpid(WNOHANG) 更便携、也更快(系统调用开销最小)。

Windows 完全走另一套机制src/mcp/os/win32/utilities.py:278)——Windows 没有 process group 概念,SDK 改用 Job Object

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

启动进程时(同文件 line 268)调 AssignProcessToJobObject 把子进程绑定到一个 Job Object 上,孙子进程自动继承。关闭时调 TerminateJobObject 一次性杀整个 Job——等价于 POSIX 的 killpg,但 API 完全不同。这就是为什么 SDK 要拆 os/posix/os/win32/ 两个包:同一个”杀进程树”需求、两套操作系统模型只能各写一份。

所以规范里”第 3-4 步发 SIGTERM/SIGKILL”在 POSIX 上是 killpg SIGTERM → 轮询 2 秒 → killpg SIGKILL 的三段式、Windows 上则是单次 TerminateJobObject——同一个规范意图被两种 OS 用不同原语各自落实。理解这层实现差异对部署多平台 MCP server 运维至关重要:在 Windows 上你看不到 SIGTERM、在 Linux 上你在 ps 里看到的可能是整个进程组状态而不是单个进程。

4.7.2 HTTP 传输的关闭

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

4.7.3 SDK 中的关闭实现

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

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

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

private _onclose(): void {
    const responseHandlers = this._responseHandlers;
    this._responseHandlers = new Map();
    this._progressHandlers.clear();
    this._transport = undefined;

    // 通知所有等待响应的请求:连接已关闭
    const error = new SdkError(
        SdkErrorCode.ConnectionClosed, 'Connection closed'
    );
    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;          // 最大总超时
    // ...
};

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 注释里。mcp-specification/schema/2025-11-25/schema.ts:257-273initialize 定义为普通 request,params 里必须携带客户端支持的最新协议版本、客户端能力和 clientInfo;schema.ts:281-294 规定服务端响应的 protocolVersion 可以不同于客户端请求值,客户端如果不支持就必须断开;schema.ts:302-304 再用 notifications/initialized 表示客户端已经接受协商结果。三步合起来不是礼貌性问候,而是从“未协商连接”切换到“已协商会话”的状态提交。

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

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

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

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

这也是初始化日志必须分阶段记录的原因:已发送 initialize、已收到 InitializeResult、已发送 initialized、已开放业务请求,四个状态应能在日志中区分。

4.9 设计洞察

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

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

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

渐进协商原则。版本协商不是”全有或全无”的——当双方版本不完全一致时,通过协商找到一个双方都支持的版本。这保证了不同版本的实现可以互操作。

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

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

物理事实补充:TS 支持 5 版本(含 2024-10-07)vs Python 4 版本;TS 有 DEFAULT_NEGOTIATED_PROTOCOL_VERSION = ‘2025-03-26’ 作 HTTP 回退默认 vs Python 拒绝缺失版本;TS LATEST_PROTOCOL_VERSION 有两个值(constants.ts 的 ‘2025-11-25’ 生产用 vs spec.types.ts 的 ‘DRAFT-2026-v1’ spec 自动生成用)反映”spec 跑在前、SDK 落地稍后”双轨策略——是 §3.8.3/§16.9.1 揭示的两 SDK 不对称又一例。