MCP 协议设计与实现
第14章 SSE 与 WebSocket
前面几章我们分析了 STDIO 和 Streamable HTTP —— MCP 传输层规范承认的两种机制。本章聚焦于另外两种在 MCP 生态里真实出现过的传输:SSE(Server-Sent Events) 和 WebSocket。
这两种传输有一个共同点:它们都已经退出了 MCP 的主线。SSE 曾是 MCP 在网络环境下的主力方案,已被 Streamable HTTP 取代并标记为废弃;WebSocket 则从未进入过协议规范,两个官方 SDK 先后把它删掉了。
先说结论:WebSocket 正在退出两个官方 SDK
- 协议规范从未定义过 WebSocket 传输。
docs/specification/2025-11-25/全文没有一处提到 websocket,此前各版本同样如此。它只是 SDK 层面的一个实现选项。 - TypeScript SDK 在 2026-03-30 的
feat!: remove WebSocketClientTransport(commit045c62a1,PR #1783)中删除了客户端 WebSocket 传输。这个 commit 早于本专栏基准的b8886e7(2026-04-16),所以基准版 TS SDK 里已经没有 WebSocket 了。 - Python SDK 的移除发生在 2026-06-08 的
Remove the unsupported WebSocket transport(commitb478bff,PR #2785)——commit 标题里的 unsupported 是官方定性。但这晚于本专栏基准的3d7b311(2026-04-15):基准版 Python SDK 里src/mcp/client/websocket.py(85 行)与src/mcp/server/websocket.py(52 行)仍然存在。所以第 11 章「WebSocket:Python ✅ / TS ❌」的对照表说的是基准版的事实,与这里并不矛盾。
所以:新项目不要再选 WebSocket 传输,远程场景一律用 Streamable HTTP。
那本章为什么还留着它?因为这段代码是 MCP 传输层抽象设计得最干净的一个样本——
一百行不到就把全双工链路收敛成 (read_stream, write_stream) 元组。
14.4 节按历史实现来读它,目的是理解传输层抽象本身,不是教你去用。
本章要点
- SSE 的双通道设计(GET 长连接 + POST 独立请求)及其必然的复杂性
- SSE 被废弃的三个核心原因:双通道脆弱性、会话状态绑死在一条常驻 GET 流上、连接恢复困难
- WebSocket 传输的兴衰全过程:为何从未入规范、为何两个 SDK 先后移除
- 从已删除的
websocket_client里提炼传输层抽象的可迁移设计 - 传输方式的选型决策树(现存只剩 STDIO 与 Streamable HTTP 两个正确答案)
- 向后兼容策略——优先 Streamable HTTP,失败降级到 SSE
- 迁移路线图——服务端先行,客户端渐进,监控再下线
14.1 SSE 传输的架构设计
SSE 是一种基于 HTTP 的单向流式传输技术。浏览器(或客户端)通过一个长连接的 GET 请求接收服务端推送的事件流,而客户端向服务端发送消息则需要通过独立的 POST 请求完成。这种非对称的通信模型决定了 SSE 传输在 MCP 中的架构形态。
14.1.1 为什么早期 MCP 选 SSE
回到 2024 年 11 月 MCP 发布时的技术选项:
| 候选方案 | 优势 | 劣势 |
|---|---|---|
| WebSocket | 全双工,低开销 | 防火墙/代理兼容性差,浏览器限制 |
| SSE | 标准 HTTP,通过所有代理 | 单向,需要辅助 POST 通道 |
| 长轮询 | 最简单 | 开销大,延迟不稳定 |
| HTTP/2 Server Push | 高效 | 浏览器支持撤回,复杂 |
| gRPC-Web | 高效,类型安全 | 需要 proxy,学习曲线 |
MCP 选择 SSE 的核心理由:
- 原生 HTTP 兼容——通过任何 HTTP 反向代理、CDN、企业防火墙
- 浏览器原生支持——
EventSourceAPI,零依赖 - 文本协议可调试——wireshark/curl 可直接看
- 实现成熟——SSE 由 WHATWG HTML Living Standard 的 Server-sent events 一节定义(前身是 W3C 的 Server-Sent Events 推荐标准),浏览器与服务端库的实现都已经跑了十几年
14.1.2 双通道通信模型
SSE 传输的核心设计是将一条逻辑上的双向通信链路拆分成两条物理通道:
sequenceDiagram
participant Client as MCP 客户端
participant Server as MCP 服务端
Note over Client,Server: 阶段一:建立 SSE 连接
Client->>Server: GET /sse (建立 SSE 长连接)
Server-->>Client: event: endpoint<br/>data: /messages?sessionId=xxx
Note over Client,Server: 阶段二:双向通信
Client->>Server: POST /messages?sessionId=xxx<br/>{"jsonrpc":"2.0","method":"initialize",...}
Server-->>Client: event: message<br/>data: {"jsonrpc":"2.0","result":{...}}
Client->>Server: POST /messages?sessionId=xxx<br/>{"jsonrpc":"2.0","method":"tools/list",...}
Server-->>Client: event: message<br/>data: {"jsonrpc":"2.0","result":{...}}
Note over Client,Server: 阶段三:关闭连接
Client->>Server: 关闭 EventSource
这个设计有几个值得注意的要点:
- 连接建立阶段:客户端首先发起 GET 请求建立 SSE 长连接,服务端通过
endpoint事件告知客户端后续 POST 请求应发送到哪个 URL - endpoint 事件的安全校验:两个 SDK 的客户端都会验证 endpoint URL 的 origin 与初始连接的 origin 一致,防止消息被引到别的服务器(2024-11-05 规范本身没写这条,它要求的是服务端 MUST 校验
Origin请求头以防 DNS rebinding) - 会话绑定:endpoint URL 中通常带一个会话 ID 参数,将 SSE 连接与后续的 POST 请求关联到同一个会话。规范没有规定路径和参数名:图里的
/sse、/messages?sessionId=是 TS SDK v1 服务端的写法,基准版 Python 服务端实抓到的是data: /messages/?session_id=3772932aeab54b4eac482064f4d7c93b(32 位 hex)
14.1.3 endpoint 事件的安全考量
endpoint 事件的 origin 校验是 SDK 层一个精细的安全设计。考虑这样的攻击场景:
endpoint 被引向第三方 origin:
1. 客户端连上一个被篡改或配置错误的 SSE 服务端
2. 它在 endpoint 事件里返回指向 attacker.com 的 URL
3. 客户端若不校验,会把后续所有 JSON-RPC 消息 POST 到 attacker.com
(SDK 给 POST 附带的 Authorization 头也一起送过去)
4. 凭证与敏感数据泄露
对策:SDK 强制校验 endpoint URL 的 origin 必须与初始连接一致。这是一个简单但有效的同源策略:
if (this._endpoint.origin !== this._url.origin) {
throw new Error(`Endpoint origin does not match connection origin`);
}
这种校验必须在 SDK 层实现,不能依赖 server 端诚实——因为这个校验就是在防止服务端作恶。
14.2 TypeScript SDK 的 SSE 客户端实现
TypeScript SDK 中的 SSEClientTransport 类实现了 Transport 接口,完整展示了 SSE 传输的工作机制。
连接建立
连接建立的核心逻辑在 _startOrAuth 方法中。该方法创建 EventSource 实例并监听两类事件:
// packages/client/src/client/sse.ts:180-210
// 监听 endpoint 事件,获取 POST 请求的目标 URL
this._eventSource.addEventListener('endpoint', (event: Event) => {
const messageEvent = event as MessageEvent;
try {
this._endpoint = new URL(messageEvent.data, this._url);
// 安全校验:endpoint URL 的 origin 必须与连接 URL 一致
if (this._endpoint.origin !== this._url.origin) {
throw new Error(`Endpoint origin does not match connection origin: ${this._endpoint.origin}`);
}
} catch (error) {
// 注意 throw 不是终点:这里会 reject 掉 start() 的 Promise、
// 通知 onerror,并把连接整个关掉
reject(error);
this.onerror?.(error as Error);
void this.close();
return;
}
resolve();
});
// 监听 message 事件,接收服务端推送的 JSON-RPC 消息
this._eventSource.onmessage = (event: Event) => {
const messageEvent = event as MessageEvent;
let message: JSONRPCMessage;
try {
message = JSONRPCMessageSchema.parse(JSON.parse(messageEvent.data));
} catch (error) {
// 单条消息解析失败只报错、不断连
this.onerror?.(error as Error);
return;
}
this.onmessage?.(message);
};
消息发送
发送消息时,send 方法通过 POST 请求将 JSON-RPC 消息发送到之前接收到的 endpoint URL:
// packages/client/src/client/sse.ts:252-306 节选(源码带 private)
// 省略:外层 try/catch(任何异常先 onerror 再抛出)、各分支里排空 response body、
// init 里展开的 ...this._requestInit
async _send(message: JSONRPCMessage, isAuthRetry: boolean): Promise<void> {
if (!this._endpoint) {
throw new SdkError(SdkErrorCode.NotConnected, 'Not connected');
}
const headers = await this._commonHeaders();
headers.set('content-type', 'application/json');
const response = await (this._fetch ?? fetch)(this._endpoint, {
method: 'POST',
headers,
body: JSON.stringify(message),
signal: this._abortController?.signal
});
if (!response.ok) {
if (response.status === 401 && this._authProvider) {
if (response.headers.has('www-authenticate')) {
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
this._resourceMetadataUrl = resourceMetadataUrl;
this._scope = scope;
}
if (this._authProvider.onUnauthorized && !isAuthRetry) {
await this._authProvider.onUnauthorized({ response, serverUrl: this._url, fetchFn: this._fetchWithInit });
return this._send(message, true); // 递归重试,isAuthRetry=true 防止无限循环
}
if (isAuthRetry) {
throw new SdkError(SdkErrorCode.ClientHttpAuthentication,
'Server returned 401 after re-authentication', { status: 401 });
}
throw new UnauthorizedError();
}
const text = await response.text?.().catch(() => null);
throw new Error(`Error POSTing to endpoint (HTTP ${response.status}): ${text}`);
}
}
这里有一个精妙的设计:认证失败(401)时,_send 方法会调用 authProvider.onUnauthorized(传入一个包含 response / serverUrl / fetchFn 的对象)尝试刷新凭证,然后以 isAuthRetry = true 递归调用自身进行重试,但只重试一次——第二次仍然 401 就抛 SdkError。这与第 13 章 §13.6 里 Streamable HTTP 的 401 处理是同一套模式。同样的认证重试逻辑也存在于 SSE 连接建立阶段的 onerror 处理中。
断线重连的挑战
SSE 的一个痛点是断线重连。EventSource 自动重连,但重连后 session 已失效:
1. SSE 连接断开(网络抖动)
2. EventSource 自动重连 GET /sse
3. 服务端把它当新连接:分配新的 sessionId,旧会话随旧流一起被清掉
4. 新的 endpoint 事件到达,客户端换用新 endpoint URL
5. 但新会话从没收到过 initialize;旧流上还没送达的响应也回不来了
第 4 步 TS SDK 是做了的:endpoint 监听器一直挂着,每来一次 endpoint 事件就覆盖 _endpoint(packages/client/src/client/sse.ts:180-197)。补不回来的是第 5 步——基准版 Python 服务端对未完成初始化的会话,收到请求直接抛 RuntimeError(src/mcp/server/session.py:194-195,报错信息是请求早于初始化完成),而 2024-11-05 规范既没有事件 ID 也没有续传机制,已发出的请求可能就此丢失——这是 SSE 架构的固有缺陷。
14.3 Python SDK 的 SSE 客户端实现
Python SDK 的 sse_client 采用了异步上下文管理器模式,使用 httpx 和 httpx_sse 库实现 SSE 连接,并通过 anyio 的内存流实现消息的异步传递。
双协程架构
其核心架构由两个并发协程构成:
sse_reader:从 SSE 事件流中读取消息,解析后写入read_streampost_writer:从write_stream中读取待发送的消息,通过 POST 请求发送到服务端
# 简化自 src/mcp/client/sse.py:65-143:省略日志、on_session_created、空 data 跳过、
# 异常分支,以及 post_writer 借 last_context 在发送方上下文里并发 POST 的包装
async def sse_reader(task_status):
async for sse in event_source.aiter_sse():
match sse.event:
case "endpoint":
endpoint_url = urljoin(url, sse.data)
# 安全校验:scheme 与 netloc 都必须一致
url_parsed, endpoint_parsed = urlparse(url), urlparse(endpoint_url)
if (url_parsed.netloc != endpoint_parsed.netloc
or url_parsed.scheme != endpoint_parsed.scheme):
raise ValueError(
f"Endpoint origin does not match connection origin: {endpoint_url}")
task_status.started(endpoint_url)
case "message":
message = types.jsonrpc_message_adapter.validate_json(sse.data, by_name=False)
await read_stream_writer.send(SessionMessage(message))
async def post_writer(endpoint_url: str):
async for session_message in write_stream_reader:
response = await client.post(
endpoint_url,
json=session_message.message.model_dump(by_alias=True, mode="json", exclude_unset=True)
)
response.raise_for_status()
task_status 机制
值得注意的是,sse_reader 使用了 anyio 的 task_status 机制:只有当 endpoint 事件到达后,post_writer 才会启动。这确保了在发送任何消息之前,endpoint URL 已经就绪。
# 简化自 src/mcp/client/sse.py:148-160
async with anyio.create_task_group() as tg:
# sse_reader 先启动,等待 endpoint 事件
endpoint_url = await tg.start(sse_reader)
# endpoint 事件到达后,post_writer 才启动
tg.start_soon(post_writer, endpoint_url)
这避免了一个常见的竞态:如果 post_writer 和 sse_reader 并行启动,post_writer 可能在 endpoint_url 就绪前就想发送消息。
session_id 回调
Python SDK 还提供了 on_session_created 回调,允许调用者在会话建立时获取 sessionId,便于记录日志或把会话与外部状态关联:
# src/mcp/client/sse.py:25-27
def _extract_session_id_from_endpoint(endpoint_url: str) -> str | None:
query_params = parse_qs(urlparse(endpoint_url).query)
return query_params.get("sessionId", [None])[0] or query_params.get("session_id", [None])[0]
# sse_reader 的 endpoint 分支里(src/mcp/client/sse.py:86-89)
if on_session_created:
session_id = _extract_session_id_from_endpoint(endpoint_url)
if session_id:
on_session_created(session_id)
注意它同时认 sessionId 和 session_id 两种拼写——这是为了兼容不同实现的服务端各自的 query 参数命名:TS SDK v1 的 SSEServerTransport 写的是 sessionId,Python 自家的 SseServerTransport 写的是 session_id(src/mcp/server/sse.py:153)。
14.4 WebSocket 传输
WebSocket 提供了真正的全双工通信能力,客户端和服务端可以在同一条连接上同时发送和接收消息。它在 MCP 里存在过,但从未被写进协议规范,最终两个官方 SDK 都把它删了。
本节读的是已经或即将退场的代码。TypeScript 侧在本专栏基准版里已经删干净,Python 侧在基准版
3d7b311里还留着;要对照更早或更晚的状态,用下面的命令:git clone https://github.com/modelcontextprotocol/python-sdk.git cd python-sdk git show 3d7b311:src/mcp/client/websocket.py # 本专栏基准版,文件仍在 git show b478bff^:src/mcp/client/websocket.py # 移除提交 #2785 的父提交TypeScript 侧则要回到移除之前:
git show 045c62a1^:packages/client/src/client/websocket.ts。
14.4.1 WebSocket 为什么没能留下来
这条路线的终点是两个 breaking change:TypeScript SDK 的 #1783(2026-03-30)删掉
WebSocketClientTransport,Python SDK 的 #2785(2026-06-08)删掉收发两端。原因可以归为四条:
- 规范从未采纳——传输层规范只定义了 STDIO 和 Streamable HTTP。SDK 里的 WebSocket 始终是编外实现,没有互操作性保证:A 家的 WebSocket 客户端连不连得上 B 家的服务端,协议说了不算。
- 浏览器环境的硬限制——浏览器的
WebSocket构造器不支持自定义 header,OAuth 的Authorization头没地方放。只能退而求其次塞进 URL query 或子协议字段:前者容易落进访问日志,后者是在挪用本该用来协商协议名的字段。 - Streamable HTTP 已经补齐了短板——SSE 双通道的脆弱、会话绑死在常驻 GET 流上、断线无法续传,新传输都解决了。再维护一条全双工链路,收益不足以覆盖成本。
- 代理与防火墙的现实——
Upgrade: websocket在相当多的企业出口、CDN 和 API 网关上会被拦掉;而 MCP 的远程场景恰恰大量发生在这类网络里。
第 1 条是根因,其余三条决定了没人有动力去补第 1 条。
14.4.2 全双工模型
与 SSE 的双通道设计不同,WebSocket 的架构更加简洁:
sequenceDiagram
participant Client as MCP 客户端
participant Server as MCP 服务端
Note over Client,Server: WebSocket 握手
Client->>Server: GET /ws (Upgrade: websocket)<br/>Sec-WebSocket-Protocol: mcp
Server-->>Client: 101 Switching Protocols<br/>Sec-WebSocket-Protocol: mcp
Note over Client,Server: 全双工通信(同一连接)
Client->>Server: {"jsonrpc":"2.0","method":"initialize",...}
Server-->>Client: {"jsonrpc":"2.0","result":{...}}
par 并行消息
Server-->>Client: {"jsonrpc":"2.0","method":"notifications/progress",...}
and
Client->>Server: {"jsonrpc":"2.0","method":"tools/call",...}
end
Server-->>Client: {"jsonrpc":"2.0","result":{...}}
Note over Client,Server: 关闭连接
Client->>Server: WebSocket Close Frame
Server-->>Client: WebSocket Close Ack
WebSocket 方案有两个显著优势:
- 无需 endpoint 协商——连接建立后即可直接通信
- 真正的全双工——双方可以在任意时刻发送消息,无需等待对方响应
14.4.3 Python SDK 的 WebSocket 实现(基准版仍在,2026-06-08 后移除)
src/mcp/client/websocket.py 采用异步上下文管理器模式,代码简洁而完整(依赖 websockets 库,
需装可选依赖 mcp[ws],见 pyproject.toml:49)。用基准版的 websocket_client 连同版的
websocket_server(Starlette + uvicorn)实跑,握手响应里服务端回的正是 Sec-WebSocket-Protocol: mcp,
initialize 与 tools/call 都能走通。下面是抹掉类型标注和异常分支后的骨架,完整原文见本节开头的
git show 命令:
@asynccontextmanager
async def websocket_client(url: str):
async with ws_connect(url, subprotocols=[Subprotocol("mcp")]) as ws:
# 创建内存流用于消息传递
read_stream_writer, read_stream = anyio.create_memory_object_stream(0)
write_stream, write_stream_reader = anyio.create_memory_object_stream(0)
async def ws_reader():
async for raw_text in ws:
message = types.jsonrpc_message_adapter.validate_json(raw_text)
await read_stream_writer.send(SessionMessage(message))
async def ws_writer():
async for session_message in write_stream_reader:
msg_dict = session_message.message.model_dump(
by_alias=True, mode="json", exclude_unset=True
)
await ws.send(json.dumps(msg_dict))
async with anyio.create_task_group() as tg:
tg.start_soon(ws_reader)
tg.start_soon(ws_writer)
yield (read_stream, write_stream)
tg.cancel_scope.cancel()
四个关键设计决策
这段代码展现了几个关键设计决策:
1. mcp 子协议
通过 subprotocols=[Subprotocol("mcp")] 声明使用 MCP 子协议,服务端据此识别这是 MCP 通信而非普通 WebSocket 连接。这是 RFC 6455 规定的标准机制,让一个 WebSocket 端点可以承载多种协议。
2. 零缓冲内存流
anyio.create_memory_object_stream(0) 创建的是无缓冲流,这意味着发送方会阻塞直到接收方准备好。这种背压机制防止了消息在内存中无限堆积——如果下游处理慢,上游自然就慢下来,而不是 OOM。
3. 统一的流抽象
无论是 SSE 还是 WebSocket,Python SDK 都将传输层抽象为 (read_stream, write_stream) 元组,上层的 Session 和 Client 类无需关心底层使用的是哪种传输协议。这是一个漂亮的 Transport Layer Abstraction。
4. 优雅关闭
当调用者退出 async with 块时,tg.cancel_scope.cancel() 会取消所有子任务,WebSocket 连接随之关闭。不需要手动清理资源。
与 SSE 实现对比,WebSocket 版本的代码量明显更少,不需要 endpoint 协商、不需要维护两个独立的 HTTP 通道、也不需要处理 SSE 事件类型的分发逻辑。
14.5 四种传输方式横向对比
MCP 历史上出现过四种传输方式,但现存可选的只有两种——STDIO 与 Streamable HTTP。 另外两种列在这里是为了让你在读到老代码、老文章时知道它们是什么、为什么不该再用。
14.5.1 对比表
| 维度 | STDIO | Streamable HTTP | SSE(已废弃) | WebSocket(已移除) |
|---|---|---|---|---|
| 通信模型 | 进程管道 | HTTP 请求/响应 + 可选 SSE 流 | GET 流 + POST 请求 | 全双工 |
| 部署模型 | 本地进程 | 远程 HTTP 服务 | 远程 HTTP 服务 | 远程 WebSocket 服务 |
| 是否在协议规范中 | ✅ 是 | ✅ 是 | ⚠️ 只在 2024-11-05 版定义;2025-03-26 起被取代,2025-11-25 版仅在向后兼容一节称其 deprecated | ❌ 从未进入规范 |
| SDK 支持(基准版) | TS + Python | TS + Python | TS 只剩客户端 SSEClientTransport(标 @deprecated),无服务端;Python 客户端 client/sse.py + 服务端 server/sse.py 都还在 |
TS 已删除;Python 基准版 3d7b311 仍保留(2026-06 才移除) |
| 是否需要服务端 | 否(子进程) | 是 | 是 | 是 |
| 认证支持 | 无(依赖 OS) | OAuth 2.0 | SDK 客户端可接 OAuth(2024-11-05 规范本身没有授权章节) | 无内建支持 |
| 连接开销 | 派生子进程 | 复用 HTTP 连接,无额外握手 | 需先建 SSE 长连接再等 endpoint 事件 | 一次 HTTP Upgrade 握手 |
| 吞吐量 | 极高(IPC) | 高 | 中 | 高 |
| 延迟 | 最低 | 低 | 中 | 低 |
| 状态保持 | 无状态 | 可选 session | 强状态(endpoint) | 强状态(连接) |
| 防火墙友好 | N/A | ✅ 非常友好 | ✅ 友好 | ⚠️ 一般 |
| 代理兼容 | N/A | ✅ 完美 | ⚠️ 需要长连接支持 | ⚠️ 需要 Upgrade 支持 |
| 浏览器可用 | ❌ | ✅ | ✅ | ⚠️ 可连但无法传 header |
| 调试难度 | 低(文本 pipe) | 低(HTTP 工具) | 中 | 中(需专用工具) |
| 协议状态 | 稳定 | 当前推荐 | 已废弃 | 已移除 |
| 推荐场景 | 桌面 CLI/IDE | 云端服务 | 仅存量兼容 | 无——不要再选 |
表中"连接开销 / 吞吐量 / 延迟"是定性的量级排序,用于横向对照,不是基准测试数据。 真实数字取决于网络、代理层和消息大小,选型前请在自己的部署环境里实测。
14.5.2 选型决策树
graph TD
Start{部署场景?}
Start -->|本地集成| Local
Start -->|远程服务| Remote
Local[STDIO<br/>简单可靠]
Remote{需要对接存量 SSE 服务端?}
Remote -->|否| Streamable[Streamable HTTP<br/>唯一推荐]
Remote -->|是,过渡期| Compat[Streamable HTTP<br/>+ SSE 降级]
Compat -.->|存量端点下线后<br/>移除降级逻辑| Streamable
style Local fill:#dcfce7,stroke:#22c55e
style Streamable fill:#dcfce7,stroke:#22c55e,stroke-width:2px
style Compat fill:#fef3c7,stroke:#f59e0b
决策树只剩两层,是因为语言不再是选型变量。两个 SDK 都完整提供 STDIO 与 Streamable HTTP 的客户端和服务端,外加过渡期的 SSE 客户端(Python 基准版还多一个 SSE 服务端);WebSocket 分支随 #1783 / #2785 先后消失(Python 侧发生在基准版之后)。
14.5.3 详细选型指南
场景 1:IDE 插件、CLI 工具
- 推荐:STDIO
- 理由:最简单、最可靠,无需网络配置,启动延迟低
- 注意:进程管理需要处理——子进程崩溃要能感知并重启
场景 2:云端 MCP 服务
- 推荐:Streamable HTTP
- 理由:当前协议推荐的网络传输方案,兼容 HTTP 基础设施
- 注意:注意 session affinity(分布式部署时同一 session 要路由到同一节点)
场景 3:需要兼容现存 SSE 服务端
- 推荐:Streamable HTTP + SSE 降级
- 理由:新协议优先,失败时降级到旧协议
- 注意:只在过渡期使用,等 SSE 端点下线后移除降级逻辑
场景 4:需要 Server→Client 高频推送
- 推荐:Streamable HTTP(用其 SSE 响应流承载推送)
- 理由:这曾是 WebSocket 唯一站得住脚的场景,但 Streamable HTTP 的响应流已能覆盖:
服务端可以在一次请求的响应里持续推送 notification,也能反向发起
sampling/createMessage这类 Server→Client 请求 - 注意:推送要克制。真正需要毫秒级双向高频的负载,本就不该走 MCP—— MCP 是 Agent 的工具调用协议,不是实时消息总线
场景 5:企业内网,严格防火墙
- 推荐:Streamable HTTP
- 理由:最标准的 HTTP 形态,通过所有企业代理
- 注意:这类网络对
Upgrade: websocket的拦截,正是 WebSocket 传输没能在 MCP 里立住的 现实原因之一
14.6 SSE 的废弃与迁移
14.6.1 为什么 SSE 被废弃
TypeScript SDK 中的 SSEClientTransport 类已被标记为 @deprecated,官方建议迁移到 StreamableHTTPClientTransport。
规范层面,2025-03-26 版起 Streamable HTTP 取代 HTTP+SSE,本专栏锚定的 2025-11-25 版在向后兼容一节仍称之为 deprecated;2026-07-28 版又按新的特性生命周期政策把它正式归为 Deprecated(SEP-2596,见该版 changelog.mdx),迁移方向仍是 Streamable HTTP。
废弃的核心原因在于 SSE 传输的三个架构缺陷:
缺陷 1:双通道脆弱性
SSE 需要维护 SSE 连接(GET)和 POST 通道两条链路,任何一条中断都会导致通信失败。而且两条通道的失败模式不一样——SSE 失败会自动重连,POST 失败会立即返回错误。这种不对称让错误处理变得复杂。
缺陷 2:会话状态绑死在一条常驻 GET 流上
注意,这里的问题不是服务端发不了请求。2024-11-05 规范写的是 "Server messages are sent as SSE message events"——响应、通知、Server→Client 请求一律走这条 GET 流,客户端再把对请求的响应 POST 回来。sampling/createMessage 从第一版规范起就这样工作。用基准版 Python 服务端(transport="sse",工具里调 ctx.session.create_message)实抓:
<- SSE event: endpoint data: /messages/?session_id=8d72b530...
(initialize / notifications/initialized 往返略)
-> POST {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"ask","arguments":{}}} => 202 Accepted
<- SSE event: message data: {"jsonrpc":"2.0","id":0,"method":"sampling/createMessage","params":{"messages":[...],"maxTokens":5,"_meta":{}}}
-> POST {"jsonrpc":"2.0","id":0,"result":{"role":"assistant","content":{"type":"text","text":"pong"},"model":"fake"}} => 202 Accepted
<- SSE event: message data: {"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"client said: pong"}],...}}
真正的代价在于:POST 只回 202 Accepted,所有服务端消息——包括每一个请求的响应——都只能从那条 GET 流回来。于是这条流必须从连接建立一直开到会话结束,服务端必须把「会话 ID → 这条流」的映射放在进程内存里(Python 是 SseServerTransport._read_stream_writers 字典,src/mcp/server/sse.py:75;找不到就回 404 Could not find session),POST 被负载均衡打到别的实例就找不到会话。服务端没法做成无状态,也没法「一个请求一个响应、回完就关」。
缺陷 3:连接恢复困难
SSE 连接断开后,需要重新建立连接并获取新的 endpoint URL,旧 endpoint 上的未完成请求可能丢失。无法无缝恢复——这对长对话型的 MCP 会话是致命的。
14.6.2 Streamable HTTP 如何解决这些问题
Streamable HTTP 通过以下方式解决了上述所有问题:
核心设计:
- 所有请求都走 POST /mcp(统一入口),服务端主动推送则走同一路径的 GET
- 响应可以是同步 JSON 或流式 SSE,由服务端按 Content-Type 决定
- session 通过 `MCP-Session-Id` 请求/响应头传递(规范只定义了这个头,没有 cookie 方案;
以上均为本专栏锚定的 2025-11-25 版,2026-07-28 版已去掉协议级会话与这个头)
- Server→Client 请求通过响应流或 GET 流传递
- 断线重连 = GET + `Last-Event-ID`,由 EventStore 重放断线期间的事件
具体改进:
| SSE 缺陷 | Streamable HTTP 的解法 |
|---|---|
| 双通道脆弱 | 单一 HTTP 入口 |
| 会话绑死在常驻 GET 流上 | POST 的响应可以直接是 JSON 或一次性 SSE 流,GET 流变成可选 |
| 连接恢复困难 | Last-Event-ID + EventStore 精确重放(见第 13 章 §13.5) |
14.6.3 向后兼容策略
尽管 SSE 已被废弃,但由于大量现存服务端仍在使用该传输方式,客户端需要在过渡期间支持两种协议。2025-11-25 规范的 Backwards Compatibility 一节给了客户端的做法:把用户给的 URL 当作 Streamable HTTP 端点,先 POST 一个 InitializeRequest;若失败且状态码是 400、404 或 405,就对同一个 URL 发 GET,收到的第一个事件若是 endpoint,便认定对端是旧 HTTP+SSE 服务端。
两个 SDK 都没有把这套探测做进库里:TS 只在 Client.connect 的文档示例(packages/client/src/client/client.examples.ts:60-78)和 examples/client/src/streamableHttpWithSseFallbackClient.ts 里演示,而且两处都是遇到任何错误就降级;Python 侧没有自动探测,示例 examples/clients/simple-auth-client/mcp_simple_auth_client/main.py:219 靠参数显式二选一,python -m mcp.client <url> 更是对 HTTP(S) URL 一律走 sse_client(src/mcp/client/__main__.py:55-56)。下面按规范把降级条件收窄到这三个状态码:
import type { Transport } from '@modelcontextprotocol/client';
import { Client, SdkError, SSEClientTransport, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
async function connectWithBackwardsCompatibility(
url: string
): Promise<{ client: Client; transport: Transport; type: 'streamable-http' | 'sse' }> {
const baseUrl = new URL(url);
try {
// 优先尝试 Streamable HTTP:向用户给的 URL POST initialize
const transport = new StreamableHTTPClientTransport(baseUrl);
const client = new Client({ name: 'compat-client', version: '1.0.0' });
await client.connect(transport);
return { client, transport, type: 'streamable-http' };
} catch (error) {
// 判断是否是"协议不支持"的错误
if (!isProtocolMismatch(error)) {
throw error; // 其他错误直接抛出
}
// 降级到 SSE:对同一个 URL 发 GET,等 endpoint 事件
console.warn('Falling back to legacy SSE transport');
const sseTransport = new SSEClientTransport(baseUrl);
const sseClient = new Client({ name: 'compat-client', version: '1.0.0' });
await sseClient.connect(sseTransport);
return { client: sseClient, transport: sseTransport, type: 'sse' };
}
}
function isProtocolMismatch(error: unknown): boolean {
// 状态码不在 message 里(形如 "Error POSTing to endpoint: Method Not Allowed"),
// 而在 SdkError 的 data.status;规范列出的回退条件是 400 / 404 / 405
if (!(error instanceof SdkError)) return false;
const status = (error.data as { status?: number } | undefined)?.status;
return status === 400 || status === 404 || status === 405;
}
这个模式的关键在于:先尝试现代协议,只有服务端明确表示不认这个端点(400 / 404 / 405)时才回退到旧协议,鉴权失败、网络错误等照常抛出。判断要看 SdkError.data.status 而不是 message:用基准版 TS 客户端连基准版 Python SSE 服务端实跑,POST 到 /sse 得到的错误是 SdkError,code 为 CLIENT_HTTP_NOT_IMPLEMENTED,message 为 "Error POSTing to endpoint: Method Not Allowed",状态码只在 data: {"status":405,...} 里——拿 message.includes('405') 去判断会漏掉降级。按上面的写法实跑,会打出 Falling back to legacy SSE transport 并通过 SSE 正常列出工具。
14.6.4 迁移路线图
对于正在维护 MCP 服务端的开发者,建议按以下步骤完成迁移:
gantt
title SSE → Streamable HTTP 迁移时间线(日期为示意)
dateFormat YYYY-MM-DD
section 服务端
在 SSE 基础上增加 Streamable HTTP 端点: 2026-01-01, 30d
两个端点并行运行: 2026-02-01, 90d
section 客户端
采用降级策略更新客户端: 2026-01-15, 60d
部署新版本到主流用户: 2026-03-15, 60d
section 监控
观察客户端流量分布: 2026-03-01, 120d
确认 SSE 流量降到阈值以下: crit, 2026-06-30, 30d
section 完全迁移
标记 SSE 端点为 deprecated: 2026-07-01, 30d
移除 SSE 端点: 2026-08-01, 30d
四步迁移法:
- 服务端先行:在现有 SSE 端点的基础上,增加 Streamable HTTP 端点支持。两个端点可以并行运行
- 客户端适配:采用上述降级策略更新客户端代码,确保优先使用新协议
- 监控与切换:观察客户端连接方式的统计数据,当绝大多数活跃客户端已切换到 Streamable HTTP,并且旧端点错误率、客服反馈和业务关键路径都稳定后,再考虑下线 SSE 端点
- 完全迁移:移除 SSE 相关代码,简化服务端架构
关键监控指标:
- 新旧协议的连接数比例(阈值由业务兼容窗口和客户分布决定)
- 各客户端版本的协议分布
- 降级发生的频率(高说明客户端版本旧)
14.7 实战:为你的 MCP Server 增加 Streamable HTTP 支持
以下是一段示意性参考实现,展示如何让一个现有的 SSE-only 服务端同时支持 Streamable HTTP。
关于这段代码的版本口径
它写的是 v1 时代 @modelcontextprotocol/sdk 单包布局下的形态(import 路径按 v1.29.0 的 src/server/ 布局)。本专栏基准的 TS SDK(b8886e7)已经拆成 pnpm monorepo:packages/core(private,不单独发布)、client、server,以及 packages/middleware/ 下的 express / fastify / hono / node 四个适配包;而且 server 包里没有 SSEServerTransport——它已随 2026-01-16 的 f495077e(#1326)删除,SSE 服务端在新版 TS SDK 里已经没有对应实现。所以这段代码是用来说明"双端点并行"这个迁移形态的,不要直接拷进基准版项目编译。Python 侧倒是可以照做:mcp.server.sse.SseServerTransport 在基准版 3d7b311 里仍然存在。
import express from 'express';
import { Server as MCPServer } from '@modelcontextprotocol/sdk/server/index.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
const app = express();
// ========== 现有 SSE 端点(保留)==========
app.get('/sse', async (req, res) => {
const transport = new SSEServerTransport('/messages', res);
const server = new MCPServer({ /* ... */ });
await server.connect(transport);
});
app.post('/messages', express.json(), async (req, res) => {
// SSE session 查找 + 消息投递
});
// ========== 新增 Streamable HTTP 端点 ==========
app.post('/mcp', express.json(), async (req, res) => {
// 按 mcp-session-id 头找已有 transport;只有 initialize 请求才新建:
// const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
// await new MCPServer({ /* ... */ }).connect(transport);
// 然后交给它处理本次请求:
// await transport.handleRequest(req, res, req.body);
});
app.listen(3000);
如果你希望客户端在连接前就知道服务端支持哪些传输,可以自定义一个能力广播端点。注意这不是规范内容——2025-11-25 规范里 MCP 只定义了三个 .well-known 路径,全部属于 OAuth 发现(oauth-protected-resource、oauth-authorization-server、openid-configuration),没有任何一个用于传输方式发现。下面的写法属于私有约定,只有你同时控制客户端和服务端时才可用:
// 私有扩展端点,非 MCP 规范
app.get('/.well-known/my-mcp-transports', (req, res) => {
res.json({
transports: {
'streamable-http': { url: '/mcp' },
'sse': { url: '/sse', deprecated: true },
},
});
});
对应的客户端可以读它来选传输——但通用客户端只能靠 §14.6.3 的"先试 Streamable HTTP,失败再降级 SSE"来探测,因为它不能假设对端实现了你的私有端点:
// 示意:createTransport 需自行实现(按 type 返回对应的 Transport)
async function autoConnect(baseUrl: string): Promise<Client> {
const wellKnown = await fetch(`${baseUrl}/.well-known/my-mcp-transports`).then(r => r.json());
// 优先选择非 deprecated 的 transport
const transports = Object.entries(wellKnown.transports)
.sort(([, a], [, b]) => (a.deprecated ? 1 : 0) - (b.deprecated ? 1 : 0));
for (const [type, config] of transports) {
try {
const transport = createTransport(type, baseUrl + config.url);
const client = new Client({ name: 'my-client', version: '1.0' });
await client.connect(transport);
return client;
} catch (e) {
continue;
}
}
throw new Error('No compatible transport');
}
14.8 三个反模式
反模式一:把 SSE 当成实时推送通道
现象:开发者把 MCP 的 SSE 通道当成一般 WebSocket 用,往里面塞大量 notification。
问题:SSE 是 MCP 的响应通道,不是通用推送通道。高频 notification 会让客户端消化不过来。
对策:Server→Client 推送应该克制。确实需要高频推送时,用 Streamable HTTP 的响应流承载, 而不是把 SSE 通道当消息总线——MCP 是工具调用协议,不是实时推送中间件。
反模式二:跨 session 共用 endpoint
现象:服务端生成一个 endpoint URL,所有客户端共用。
问题:消息会串——客户端 A 的请求响应可能发给客户端 B。
对策:每个 SSE 连接分配独立的 endpoint URL,包含唯一 sessionId。
反模式三:照着老文章上 WebSocket
现象:开发者搜到 2025 年的教程或旧版 SDK 文档,照着用 WebSocketClientTransport
或 websocket_client。
问题:这两个 API 都已经删掉了(TS 基准版里就没有;Python 在基准版之后的 #2785 删除),用新版 SDK 代码根本 import 不进来。就算自己
补一个实现,也要面对浏览器无法传 header、企业防火墙拦 Upgrade、以及最根本的
——协议规范不承认它,互操作性没有任何保证。
对策:远程一律 Streamable HTTP。判断一篇 MCP 教程是否过期,看它推不推 WebSocket 就是个很好的试纸。
14.9 SDK 源码实况:两个 SDK 的清理进度并不同步
§14.6 讲的是规范层面的 SSE 废弃。但把两个 SDK 拉下来数文件会发现,源码层面的清理速度并不一致——而且两边差了两个多月。
实测口径:本专栏基准版本——TypeScript SDK b8886e7(2026-04-16),Python SDK 3d7b311(2026-04-15)。行数为 wc -l 实测值。
| 文件 | 状态 |
|---|---|
mcp-ts: packages/client/src/client/sse.ts |
存在,311 行——SSEClientTransport 标了 @deprecated,客户端仍能连接存量 SSE 服务端 |
mcp-ts: packages/server/src/server/sse.ts |
不存在——已随 f495077e(#1326,2026-01-16)删除;server 包只有 stdio.ts + streamableHttp.ts 两种 transport |
mcp-ts: **/websocket.ts |
不存在——已随 045c62a1(#1783,2026-03-30)删除,该 commit 早于基准版 |
mcp-py: src/mcp/client/sse.py |
存在,160 行 |
mcp-py: src/mcp/server/sse.py |
存在,241 行——Python 侧 SSE 服务端仍在主包里,未加 deprecation 标记 |
mcp-py: src/mcp/client/websocket.py / src/mcp/server/websocket.py |
存在,85 / 52 行——移除发生在 b478bff(#2785,2026-06-08),晚于基准版 |
也就是说:在本专栏的基准版本上,TS SDK 已经没有 WebSocket、也没有 SSE 服务端;Python SDK 两者都还在。这是第 11 章「WebSocket:Python ✅ / TS ❌」那一行的来源。如果你读到本章时用的是更新的 Python SDK,websocket.py 应该已经消失了。
WebSocket 移除的 changeset(.changeset/remove-websocket-transport.md,在基准版 TS 仓库里仍能读到)原文:
Remove
WebSocketClientTransport. WebSocket is not a spec-defined transport; use stdio or Streamable HTTP. TheTransportinterface remains exported for custom implementations. See #142.
三个值得记住的事实:
- 官方定性是"非规范传输"——传输层规范只承认 stdio + Streamable HTTP。WebSocket 一直是 SDK 的自选动作,所以两边先后删掉它时,都不算破坏协议兼容性。
- Python SDK 比 TS SDK 晚了约七十天——TS 侧 2026-03-30 删除客户端实现,Python 侧 2026-06-08 删除收发两端,commit 标题直接写
Remove the unsupported WebSocket transport。两边的终点一样,只是节奏不同——这也正是本专栏基准版上两个 SDK 表现不一致的原因。 Transport接口仍然导出——changeset 最后一句是留给自定义实现的口子。如果你确实有非用 WebSocket 不可的理由,正确做法是自己实现Transport接口,而不是期待 SDK 把它加回来。
本章选型铁律的最终形态:
- 本地 → STDIO
- 云端 → Streamable HTTP(两个 SDK 的服务端 transport 主文件规模几乎相同,见第 13 章 §13.9.1,语言不再影响选型)
- 对接存量 SSE 服务端 → Streamable HTTP + SSE 降级,并给降级逻辑设定下线时间
- 想用 WebSocket → 先重新审视设计;确有必要就自行实现
Transport,别等 SDK
14.9.1 sse 实现的尺寸对比与根因
| 文件 | 行 | 备注 |
|---|---|---|
TS packages/client/src/client/sse.ts |
311 | 大头是 OAuth 401 重试与 EventSource 的配置/事件接线 |
Python src/mcp/client/sse.py |
160 | 用 httpx_sse 的 aconnect_sse、不用自己管 SSE 连接与帧解析 |
Python src/mcp/server/sse.py |
241 | 服务端比客户端复杂(要管多 session、endpoint event 等) |
Python src/mcp/server/websocket.py |
52 | 用 Starlette 的 WebSocket、协议细节完全外包 |
Python src/mcp/client/websocket.py |
85 | 用 websockets 库,同上 |
TS 客户端 SSE 差不多是 Python 的两倍——但根因不是"Node 没有 EventSource 所以要手写 parser":packages/client/src/client/sse.ts:3-4 明确 import { EventSource } from 'eventsource',帧解析同样是外包给第三方库的。真正撑起这 311 行的是认证——_startOrAuth / _send 里那套 401 刷新重试、WWW-Authenticate 解析(见 §14.2),以及 finishAuth、自定义 fetch、requestInit 等可配置项。Python 侧把 auth 交给了 httpx.Auth(sse_client(auth=...) 一个参数),所以传输文件本身就薄。
WebSocket 实现都很短(85 / 52 行)——因为客户端的 websockets 库、服务端的 Starlette WebSocket 已经处理了所有协议细节(握手、frame、ping/pong、close)。SDK 层只需要 wrap 成 MCP 的 (read_stream, write_stream) 抽象——薄薄一层。
这条规律——传输文件的大小主要由「SDK 自己还得管什么」决定,而不是由协议本身的复杂度决定。SSE 和 WebSocket 的帧解析在两个 SDK 里都外包给了成熟库;真正让文件变厚的是认证、会话配对、重连这些只能由 MCP 层来做的事。
14.9.2 旧 SSE 与 WebSocket 的真实边界:Python 客户端最清楚
旧 SSE 的客户端实现直接暴露了它的“双通道”本质。src/mcp/client/sse.py:55-60 先用 GET 连接 SSE endpoint;src/mcp/client/sse.py:65-91 的 reader 等待服务端发来 endpoint 事件,并检查 endpoint 的 scheme 与 host 是否和原始 SSE URL 一致;只有通过同源检查后,writer 才能把客户端消息 POST 到这个 endpoint。也就是说,旧 SSE 并不是一条全双工连接,而是“GET 接收 + POST 发送 + endpoint 事件负责配对”的组合。
这个组合的安全边界很脆。src/mcp/client/sse.py:74-84 的同源检查不是装饰,它防止恶意或错误的 SSE 服务端把客户端引导到另一个 origin 发送 JSON-RPC 请求。对浏览器外的 CLI 客户端来说,这类问题更容易被忽略,因为没有浏览器同源策略自动兜底;SDK 必须自己检查。另一方面,src/mcp/client/sse.py:93-108 只把 message event 解析成 JSON-RPC,空消息会跳过,未知 event 只是 warning。这使旧 SSE 能兼容心跳和少量扩展,但也说明它的协议语义高度依赖 event 名称。
WebSocket 的边界完全不同。src/mcp/client/websocket.py:15-31 把 WebSocket 客户端定义成与服务端对称的 MCP transport;src/mcp/client/websocket.py:42 连接时请求 mcp subprotocol;src/mcp/client/websocket.py:46-68 用两个协程(ws_reader / ws_writer)分别读写 JSON-RPC 文本消息。这里没有 endpoint 事件、没有 POST 发送路径、也没有 SSE event id;好处是模型简单,坏处是所有恢复、认证、代理兼容和负载均衡问题都要落在 WebSocket 连接层处理。
这解释了为什么本章不应把 WebSocket 描述成“更先进的 SSE”。它们解决的是不同约束:旧 SSE 借 HTTP GET/POST 穿过普通基础设施,代价是双连接关联复杂;WebSocket 提供真正全双工,代价是基础设施和恢复语义更重;Streamable HTTP 则试图保留 HTTP 生态,把流式响应、session id、protocol version、Last-Event-ID 放到同一个传输模型里。迁移时最重要的不是追求某个传输“最新”,而是确认你的服务是否需要断线恢复、是否部署在受限代理后、是否允许长连接、是否能承受重复执行风险。
从实现取舍看,基准版 Python SDK 还同时保留 SSE client 与 WebSocket client(后者已在 #2785 删除);TS SDK 的主线则更集中在 stdio 与 Streamable HTTP。对生产团队而言,旧 SSE 最适合被当作兼容层维护,WebSocket 只剩双方都可控、且愿意自行实现 Transport 的专用通道这一点余地,跨网络的默认选项应优先评估 Streamable HTTP。这个结论不是因为某个传输“更潮”,而是因为状态恢复、认证集成、HTTP 运维工具链和协议版本协商最终都要在生产里付账。
迁移时可以把三种传输放进同一张运维视角的表里:
| 维度 | 旧 SSE | WebSocket | Streamable HTTP |
|---|---|---|---|
| 客户端发送 | POST 到 endpoint | 同一连接发送文本帧 | POST JSON-RPC |
| 服务端推送 | SSE message event | 同一连接发送文本帧 | POST 响应流或 GET 流 |
| 恢复语义 | 依赖自定义重连 | 通常需要应用自建 | Last-Event-ID + EventStore |
| 代理兼容 | HTTP 友好但双通道复杂 | 取决于代理升级支持 | 复用 HTTP 语义 |
| 适合角色 | 兼容旧客户端 | 双方可控的专用链路 | 远程默认候选 |
这张表的重点不是给某个传输打分,而是提醒实现者:传输选择会把复杂度推到不同地方。旧 SSE 把复杂度推给 endpoint 配对;WebSocket 把复杂度推给连接存活与恢复;Streamable HTTP 把复杂度推给 content-type、session header 和事件存储。一个团队如果没有能力长期维护这些状态,就应选择复杂度最符合自己运维工具链的方案。
还有一个经常被忽略的迁移风险:客户端库的“自动降级”必须可观测。若连接 Streamable HTTP 失败后静默退回旧 SSE,短期会让用户觉得稳定,长期会让团队误判迁移进度。更好的做法是在客户端指标中记录首选传输、实际传输、失败原因和 server 版本;服务端也记录每条连接的 transport 类型与协议版本。这样,当旧 SSE 流量迟迟不降时,团队能区分是老客户端没升级、代理挡住了 Streamable HTTP,还是新端点存在兼容问题。
WebSocket 也有类似陷阱。它在本地或内网 demo 里常显得最简单,因为一条连接双向收发;但一到企业代理、移动网络、长时间空闲连接和滚动发布,连接保持与重连策略就会成为主要复杂度。MCP 的消息层本身已经能表达 request、notification、response;传输层不需要再追求“看起来最实时”,而要选择最容易被基础设施正确承载的形态。
因此,本章的迁移建议可以压缩成一句工程原则:不要按协议形态选传输,要按故障恢复责任选传输。如果你愿意维护 endpoint 配对,旧 SSE 可以继续兼容;如果你愿意维护连接状态,WebSocket 可以服务专用场景;如果你希望把认证、代理、可观测和恢复尽量放回 HTTP 体系,Streamable HTTP 才是更自然的长期方向。
14.10 本章小结
本章深入分析了 MCP 协议中 SSE 和 WebSocket 两种传输方式的设计与实现。
核心要点回顾
- SSE 是 MCP 早期的主力网络传输方案——双通道设计(GET 长连接 + POST 请求)
- SSE 已被废弃——双通道脆弱、会话绑死在常驻 GET 流上、断线无法续传;Server→Client 请求它其实能承载,问题不在这里
- WebSocket 提供全双工——但从未进入规范;TS SDK 已删除,Python SDK 在本专栏基准版之后也删除了
- Streamable HTTP 是当前推荐——统一 HTTP 模型解决了 SSE 的所有缺陷
- 迁移需要循序渐进——服务端先行,客户端降级,监控再下线
选型铁律
- 本地 → STDIO
- 云端 → Streamable HTTP
- 旧系统兼容 → Streamable HTTP + SSE 降级,并给降级逻辑设定下线时间
- 想用 WebSocket → 别用;确有必要就自行实现
Transport接口