周五快下班的时候,组里的后端佬抛出了一个问题,问我 streamable http 是否支持双向通信。我当时就很纳闷,你都这个水平了,http 是单向的,加个前缀就不是单向的了?俗话说的好啊,越无知越笃定。另一个后端佬甩出了 csdn 一篇帖子,里面赫然写着:“streamable http 是支持双向通信的”。我当时就很震惊,“吹牛逼”,我当时就很疑惑,这是怎么回事?
streamable http 有什么用
旧 mcp 协议中的 http + sse
mcp,全称是 model context protocol,其是构建复杂任务 agent 的基础,但是这并不是本文的重点内容,在这里提到是为了引出本文的主角 streamable http。在过去,mcp 主要采用传统 http + sse 的形式,但是这种形式有一些缺点,比如:每次都需要维护两个独立的端点(http 方法 + http 请求 url), 例如: 端点 1: POST /messages,用于发送请求获取 mcp 可以调用的工具列表
POST /messages
Content-Type: application/json
{
"jsonrpc": "2.0",
"method": "tools/list",
"id": 1
}
GET /mcp
Accept: text/event-stream
Cache-Control: no-cache
# 服务器响应:
data: {"jsonrpc":"2.0","method":"notifications/progress","params":{...}}
data: {"jsonrpc":"2.0","method":"notifications/message","params":{...}}
sse 则是需要客户端和服务端之间保持一个持久的链接,单向(服务端向客户端)流式传输,此时服务器的负担就会很重。 双端架构(简化)如下:
客户端 服务器
│ │
├─ POST /messages ──────→│ (请求端点)
│ │
├─ GET /mcp ←──────────── │ (SSE推送端点)
│ (保持连接) │
从上面的结构图,所以从广义上来说,这种模式也是一种双向通信(伪双向)。但同时我们也可以推断出双端结构的一些弊端:
- 需要管理两个不同的链接
- 服务器需要实现两套不同的逻辑
- 客户端需要协调两个端点的状态
- 为部署带来复杂挑战
- 负载均衡更复杂
streamable http 带来的变革
最大的变革,即统一的 post 端点
POST /mcp
Content-Type: application/json
Accept: application/json, text/event-stream
{
"jsonrpc": "2.0",
"method": "tools/list",
"id": 1
}
在 Accept 字段中我们可以看到,服务器可以自由选择两种不同的响应格式,一种是 json,一种是 event-stream。 讲到这里,我想应该不少朋友还是处于懵懵的状态,这里附上一个简单的 demo,帮助大家理解。
场景为客户端请求工具列表,服务器需要发送进度通知。老式的双端架构代码大致如下:
// 客户端代码
class OldMCPClient {
constructor(baseUrl) {
this.requestUrl = `${baseUrl}/messages`;
this.sseUrl = `${baseUrl}/mcp`;
}
async initialize() {
// 1. 建立SSE连接用于接收推送
this.eventSource = new EventSource(this.sseUrl);
this.eventSource.onmessage = (event) => {
const notification = JSON.parse(event.data);
this.handleNotification(notification);
};
// 2. 发送初始化请求
const response = await fetch(this.requestUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'initialize',
id: 1,
}),
});
}
async listTools() {
// 只能通过POST端点发送请求
return fetch(this.requestUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'tools/list',
id: 2,
}),
});
}
}
而新式的单一端点结构如下:
// 客户端代码
class NewMCPClient {
constructor(baseUrl) {
this.endpointUrl = `${baseUrl}/mcp`; // 只需要一个端点
}
async makeRequest(method, params = {}) {
const response = await fetch(this.endpointUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream', // 支持两种响应
},
body: JSON.stringify({
jsonrpc: '2.0',
method,
params,
id: Date.now(),
}),
});
// 根据响应类型处理
if (response.headers.get('content-type')?.includes('text/event-stream')) {
return this.handleStreamResponse(response);
} else {
return response.json();
}
}
async handleStreamResponse(response) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = JSON.parse(line.slice(6));
this.handleMessage(data);
}
}
}
}
}
从上面的代码可以看出,新的架构下,客户端只需要维护一个端点,而服务器只需要实现一个端点即可。 简化的服务端(nodejs)代码:
// 老式:需要两个路由处理器
app.post('/messages', handleRequest);
app.get('/mcp', handleSSE);
// 新式:只需要一个路由处理器
app.post('/mcp', (req, res) => {
const request = req.body;
if (needsStreaming(request)) {
// 返回流式响应
res.setHeader('Content-Type', 'text/event-stream');
streamResponse(res, request);
} else {
// 返回标准JSON响应
res.json(processRequest(request));
}
});
新的单一端点架构同时带来了更简单的负载均衡(以 nginx file 为例)
# 老式:需要配置两个不同的路径
location /messages {
proxy_pass http://backend;
}
location /mcp {
proxy_pass http://backend;
proxy_set_header Connection '';
proxy_http_version 1.1;
proxy_buffering off;
}
# 新式:只需要一个配置
location /mcp {
proxy_pass http://backend;
}
所以让我们总结一下 streamable http 带来的提升:
- 一个 URL 路径 : POST /mcp
- 一套处理逻辑 :根据请求内容和需求决定响应方式
- 灵活的响应 :可以是 JSON 也可以是流式数据
- 简化的架构 :减少了系统复杂性和维护成本
streamable http 和真正的全双工通信 websocket 的区别
上面我们使用了大量的例子来具体说明 streamable http 到底是什么(没办法,协议是相对抽象的,我们只能以有形的 demo 去攻略无形的知识点),但还是没能彻底解决我的困惑,即 streamable http 和真正的全双工通信 websocket 的区别(主要是判断 streamable http 是否是全双工)。 先抛出结论: streamable 的双向通信是伪双向,体现在“请求时双向,请求间单向”。 来,先展示下“伪双向”的时序图
客户端 服务器
│ │
├─ POST /mcp (代码执行请求) ────→│
│ ├─ 开始执行
│ │
│←── data: 进度 10% ─────────────┤
│←── data: 进度 30% ─────────────┤
│←── data: 输出 "Hello" ─────────┤
│←── data: 进度 60% ─────────────┤
│←── data: 输出 "World" ─────────┤
│←── data: 完成 ─────────────────┤
│ │
├─ POST /mcp (新请求) ──────────→│ // 必须新建连接
│ │
我们再来看看 websocket 这个真双向的时序图
客户端 服务器
│ │
├─ 建立 WebSocket 连接 ─────────→│
│←─────────────── 连接确认 ──────┤
│ │
├─ 代码执行请求 ────────────────→│
│←── 进度 10% ──────────────────┤
├─ 取消请求 ───────────────────→│ // 可以随时发送
│←── 取消确认 ──────────────────┤
│←── 执行停止 ──────────────────┤
│ │
├─ 新的执行请求 ────────────────→│ // 同一连接
│←── 进度 20% ──────────────────┤
│←── 输出 "Hello" ──────────────┤
├─ 状态查询 ───────────────────→│ // 可以并发发送
│←── 状态响应 ──────────────────┤
│←── 进度 50% ──────────────────┤
用一个不太恰当的例子来解释就是,真正的全双工通信是一条“双向多车道”, 而“伪双向”通信是两条“单向多车道”。 我们再来看看两者在客户端和服务端实现上分别有什么不同。 首先是“伪双向”的客户端实现:
class StreamableHTTPClient {
constructor(serverUrl) {
this.serverUrl = serverUrl;
}
// 上行通道:发送请求
async executeCode(code) {
const response = await fetch(`${this.serverUrl}/mcp`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream', // 期望流式响应
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'code/execute',
params: { code },
id: Date.now(),
}),
});
// 下行通道:接收流式响应
return this.handleStreamResponse(response);
}
async handleStreamResponse(response) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = JSON.parse(line.slice(6));
this.handleMessage(data);
}
}
}
}
handleMessage(message) {
switch (message.method) {
case 'execution/progress':
console.log(`进度: ${message.params.percentage}%`);
break;
case 'execution/output':
console.log(`输出: ${message.params.output}`);
break;
case 'execution/complete':
console.log('执行完成');
break;
}
}
// 如果需要发送更多请求,必须创建新的连接
async sendAnotherRequest(data) {
// 这是一个完全独立的请求
return fetch(`${this.serverUrl}/mcp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
}
}
服务端实现:
app.post('/mcp', (req, res) => {
const request = req.body;
if (request.method === 'code/execute') {
// 设置流式响应
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// 模拟代码执行过程
executeCodeWithProgress(
request.params.code,
(progress) => {
// 发送进度更新
res.write(
`data: ${JSON.stringify({
jsonrpc: '2.0',
method: 'execution/progress',
params: { percentage: progress },
})}\n\n`
);
},
(output) => {
// 发送输出
res.write(
`data: ${JSON.stringify({
jsonrpc: '2.0',
method: 'execution/output',
params: { output },
})}\n\n`
);
},
() => {
// 执行完成
res.write(
`data: ${JSON.stringify({
jsonrpc: '2.0',
method: 'execution/complete',
params: {},
})}\n\n`
);
res.end();
}
);
}
});
“伪双向”的特点之一就是当我们需要发送更多请求,必须创建新的链接,并且,实际上我们没法通过请求 b 去干涉请求 a 的决策和执行。
然后我们看看“真双向”的客户端实现
class WebSocketClient {
constructor(serverUrl) {
this.ws = new WebSocket(serverUrl);
this.setupEventHandlers();
}
setupEventHandlers() {
this.ws.onopen = () => {
console.log('连接建立');
};
this.ws.onmessage = (event) => {
const message = JSON.parse(event.data);
this.handleMessage(message);
};
}
// 可以随时发送请求
executeCode(code) {
this.ws.send(
JSON.stringify({
jsonrpc: '2.0',
method: 'code/execute',
params: { code },
id: Date.now(),
})
);
}
// 可以随时发送其他请求,无需新建连接
cancelExecution() {
this.ws.send(
JSON.stringify({
jsonrpc: '2.0',
method: 'execution/cancel',
id: Date.now(),
})
);
}
// 可以同时发送多个请求
sendMultipleRequests() {
this.executeCode('console.log("task1")');
this.executeCode('console.log("task2")');
this.cancelExecution();
// 所有请求都通过同一个连接发送
}
}
这里我们可以清晰地看到,建立请求和事件处理的逻辑是解耦的,当建立连接的操作完成后,我们可以随时发送请求,而不需要等待响应;并且只需要建立一次连接。
服务端实现
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', (ws) => {
ws.on('message', (data) => {
const request = JSON.parse(data);
if (request.method === 'code/execute') {
executeCodeWithProgress(
request.params.code,
(progress) => {
// 随时发送进度
ws.send(
JSON.stringify({
jsonrpc: '2.0',
method: 'execution/progress',
params: { percentage: progress },
})
);
},
(output) => {
// 随时发送输出
ws.send(
JSON.stringify({
jsonrpc: '2.0',
method: 'execution/output',
params: { output },
})
);
}
);
} else if (request.method === 'execution/cancel') {
// 可以立即处理取消请求
cancelCurrentExecution();
ws.send(
JSON.stringify({
jsonrpc: '2.0',
result: 'cancelled',
id: request.id,
})
);
}
});
});
真正的双向通信就是我可以通过请求 b 去干预请求 a 的执行,比如取消请求 a 的执行。 朋友们不用觉得 demo 的代码看上去很多,但其实结论还是比较简洁的,真伪双向的本质就是看请求间的隔离性,伪双向是完全隔离,无法互相影响的,而真双向是统一绘话,可以互相影响。
设计哲学上的差异
Streamable HTTP:任务导向
-
设计理念 :每个请求是一个独立的任务
-
适用场景 :相对独立的操作,如文件上传、数据查询、报告生成
-
优势 :简单、可靠、易于缓存和负载均衡
-
限制 :无法实现复杂的交互控制
WebSocket:会话导向
-
设计理念 :整个连接是一个持续的会话
-
适用场景 :需要复杂交互的应用,如游戏、协作工具、实时监控
-
优势 :灵活的交互控制,真正的实时通信
-
限制 :连接管理复杂,难以缓存
所以看到这里,大家或许也就能理解 mcp 为什么采用 streamable http 而不是 websocket 了——因为 MCP 主要处理相对独立的工具调用和资源访问,而不需要复杂的跨操作控制。
总结
本篇文章主要还是就 streamable http 的设计进行了介绍,并解释了为什么可以说它是一种“双向”的通信协议,以及对比了它和 websocket 之间的差异,希望能帮助大家对“单双向”以及 streamable http 有一个更清晰的理解。
注:本文代码皆由claude-4-sonnet生成