MCP 协议设计与实现
第4章 生命周期与能力协商
在前面的章节中,我们了解了 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 协商算法
版本协商的规则如下:
- 客户端在
initialize请求中发送它支持的协议版本,应该是客户端支持的最新版本。 - 如果服务端支持该版本,必须回复相同的版本号。
- 如果服务端不支持该版本,必须回复它自己支持的另一个版本号,应该是服务端支持的最新版本。
- 客户端收到响应后,检查服务端回复的版本是否在自己的支持列表中。如果不在,应该断开连接。
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 传输,规范定义了一个分级关闭流程:
- 客户端关闭子进程的输入流(即服务端的 stdin)
- 等待服务端自行退出
- 如果服务端在合理时间内没有退出,发送
SIGTERM - 如果
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 场景的连接管理框架。