深入理解 MCP Transport 层:从抽象接口到 SSE 双向通信
一、背景:为什么需要 Transport 抽象?
在 MCP(Model Context Protocol)、LSP(Language Server Protocol)等 AI/工具链协议中,服务端需要面对多种不同的通信环境:
| 使用场景 | 通信方式 |
|---|---|
| 浏览器 Web 客户端 | SSE + HTTP POST |
| IDE 插件 | WebSocket |
| CLI 工具 / 子进程 | stdin / stdout |
| 单元测试 | Mock 内存队列 |
如果业务逻辑直接依赖具体的通信实现(比如直接写 res.write("data: ...") ),换一种传输方式就要大改代码,几乎无法维护。
Transport 抽象的核心思想是:
把"通信"变成两个操作------
Read()和Write(),业务层只关心消息内容,不关心消息是怎么通过网络传输的。
用一个经典的类比来说:这就像物流系统,你只需要说"我要寄一个包裹",至于用卡车、飞机还是船,是物流层的事。
二、ITransport:抽象接口设计
源文件:ITransport.h
cpp
namespace vx {
// 抽象传输接口,用于统一不同通信方式
class ITransport {
public:
virtual bool Start() = 0; // 启动传输服务
virtual void Stop() = 0; // 停止传输服务
virtual bool IsRunning() = 0; // 判断是否运行中
// 同步读写
virtual std::pair<size_t, std::string> Read() = 0;
virtual void Write(const std::string& json_data) = 0;
// 异步读写(返回 std::future)
virtual std::future<std::pair<size_t, std::string>> ReadAsync() = 0;
virtual std::future<void> WriteAsync(const std::string& json_data) = 0;
// 元信息
virtual std::string GetName() = 0;
virtual std::string GetVersion() = 0;
virtual int GetPort() = 0;
};
}
设计要点解析
1. 纯虚函数(= 0)
所有方法都是纯虚函数,意味着这是一个纯接口,不能直接实例化,必须由子类实现。等价于 Java 的 interface。
2. std::pair<size_t, std::string> 返回值
读取操作返回一个 pair:
first(size_t):实际读取的字节数,用于判断读取是否有效second(string):读到的内容,在 MCP 场景下通常是 JSON 字符串
3. 同步 + 异步双接口
Read()/Write():调用后阻塞,直到操作完成,适合简单场景ReadAsync()/WriteAsync():返回std::future,调用方可以在之后用.get()获取结果,适合高并发场景
4. 典型使用方式
cpp
std::unique_ptr<ITransport> transport = std::make_unique<SseTransport>();
transport->Start();
transport->Write("{\"msg\":\"hello\"}");
auto [len, data] = transport->Read(); // C++17 结构化绑定
上层代码对 SSE、Stdio、WebSocket 完全透明。
三、StdioTransport:最简实现
源文件:StdioTransport.h / StdioTransport.cpp
3.1 是什么
Stdio 是用 标准输入输出(stdin/stdout) 做通信通道的传输实现。
进程A --stdout--> 进程B(stdin)
进程A <--stdin-- 进程B(stdout)
典型用于:MCP 服务、LSP 服务、CLI 插件系统、子进程管道通信。
3.2 生命周期:空实现
cpp
bool Start() override { return true; } // stdin 无需初始化
void Stop() override {} // 无资源可释放
bool IsRunning() override { return true; } // 永远"在线"
int GetPort() override { return 0; } // 没有端口概念
Stdio 不像 SSE 那样需要绑定端口、启动线程。stdin/stdout 由操作系统管理,进程存在就可以直接用,所以这些方法都是空实现或直接返回固定值。
3.3 Read():逐字符读一行
cpp
std::pair<size_t, std::string> Stdio::Read() {
std::string json_data;
int c;
while ((c = std::getc(stdin)) != EOF && c != '\n') {
json_data += static_cast<char>(c);
}
return {json_data.length(), json_data};
}
逻辑很简单:从 stdin 逐字符读取,遇到换行符(\n)或 EOF 停止,返回读到的这一行。
等价于 std::getline(std::cin, json_data),但这里用 getc 更底层,对字节流控制更精确。
3.4 Write():写出并立即刷新
cpp
void Stdio::Write(const std::string& json_data) {
std::cout << json_data << std::endl << std::flush;
}
三个动作:
- 输出 JSON 字符串
std::endl:追加换行(协议要求的消息分隔符)std::flush:强制刷新缓冲区(关键!)
如果不刷新,数据可能卡在 C++ 的 IO 缓冲区里,对面进程迟迟收不到,造成"假死"。
3.5 异步实现
cpp
std::future<std::pair<size_t, std::string>> Stdio::ReadAsync() {
return std::async(std::launch::async, []() {
// 在独立线程中执行同步 Read 逻辑
std::string json_data;
int c;
while ((c = std::getc(stdin)) != EOF && c != '\n') {
json_data += static_cast<char>(c);
}
return std::make_pair(json_data.length(), json_data);
});
}
用 std::async(std::launch::async, ...) 在独立线程中执行 IO,不阻塞调用方线程,适合并发处理多个请求。
四、SseTransport:复杂的双向通信实现
源文件:SseTransport.h / SseTransport.cpp
4.1 核心设计:两条通道 + 两个队列
SSE 本质上是 HTTP,而 HTTP 是单向请求-响应模型。为了实现类双向通信,这里用了一个巧妙的拆分:
客户端 服务端
| |
|--- GET /sse ---------------→ | 建立 SSE 长连接(服务端推送通道)
| |
|←-- data: {...} ------------- | 服务端持续推送消息
| |
|--- POST /messages ----------→| 客户端发送消息
|←-- {"status":"received"} --- |
内部数据流:
POST /messages → incoming_messages_(入站队列)→ Read() → 业务逻辑
业务逻辑 → Write() → outgoing_messages_(出站队列)→ SSE 推送线程
关键洞察: Write() 并不直接写 socket,而是把消息投递到出站队列,由 SSE 连接的 content_provider 回调异步消费并发出去。这样彻底解耦了业务线程和网络线程。
4.2 状态机:三个原子变量
cpp
std::atomic<bool> server_running_ {false}; // HTTP 服务是否运行
std::atomic<bool> client_connected_ {false}; // 是否有 SSE 客户端已连接
std::atomic<bool> sse_active_ {false}; // SSE 长连接是否处于活跃状态
使用 std::atomic 而不是普通 bool + mutex,是因为这三个变量会被多个线程(HTTP 线程、SSE 线程、业务线程)并发读写,atomic 保证每次读写是原子操作,避免数据竞争。
4.3 禁止拷贝和移动
cpp
SSE(const SSE&) = delete;
SSE& operator=(const SSE&) = delete;
SSE(SSE&&) = delete;
SSE& operator=(SSE&&) = delete;
SSE 内部持有线程、互斥锁、条件变量、队列等非可复制资源。如果允许拷贝,这些资源会被错误地复制,导致未定义行为。显式 = delete 是正确且必要的做法。
4.4 Start():在独立线程中启动 HTTP 服务
cpp
bool SSE::Start() {
if (server_running_.load()) return false;
server_running_.store(true);
server_thread_ = std::thread([this]() {
if (!server_->listen(host_.c_str(), port_)) {
LOG(ERROR) << "Failed to start SSE server" << std::endl;
server_running_.store(false);
}
});
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 等待服务就绪
return server_running_.load();
}
server_->listen() 是一个阻塞调用,会一直等待并处理客户端连接,因此必须放在独立线程中运行。sleep_for(100ms) 是一个简单的"等待就绪"手段,避免 Start() 返回时服务还没真正启动。
4.5 HandleSSEConnection():SSE 长连接的核心
当客户端 GET /sse 时,进入这个处理函数。
Step 1:设置 SSE 响应头
cpp
res.set_header("Content-Type", "text/event-stream");
res.set_header("Cache-Control", "no-cache");
res.set_header("Connection", "keep-alive");
这三个头部是 SSE 协议的标准要求,告诉客户端"这是一个持续不断的数据流,不要缓存,保持连接"。
Step 2:发送 endpoint 事件
cpp
// 首次连接时,告诉客户端 POST 到哪个地址
std::string event_endpoint = "event: endpoint\ndata: /messages?session_id=" + sessionId + "\n\n";
sink.write(event_endpoint.data(), event_endpoint.size());
这是 MCP 协议的握手步骤。客户端收到这个事件后,才知道应该把请求 POST 到哪个 URL,实现了"动态路由分配"。
Step 3:心跳保活
cpp
// 每隔 15 秒发一次 SSE 注释行作为心跳
const char* ping = ": ping\n\n";
if (!sink.write(ping, std::strlen(ping))) {
return terminate(); // 写失败 = 客户端已断开
}
SSE 以 : 开头的行是注释,客户端会忽略其内容,但这次写操作可以检测连接是否还活着(写失败就说明对端已断开)。
Step 4:等待并推送消息
cpp
// 等待出站队列有消息,最多等 200ms
outgoing_cv_.wait_for(lock, std::chrono::milliseconds(200), [this]() {
return !outgoing_messages_.empty() || !sse_active_.load();
});
if (!outgoing_messages_.empty()) {
std::string message = outgoing_messages_.front();
outgoing_messages_.pop();
// SSE 标准格式:data: <内容>\n\n
std::string sse_msg = "data: " + message + "\n\n";
sink.write(sse_msg.data(), sse_msg.size());
}
return true; // 返回 true = 继续保持流式输出
content_provider 回调会被框架反复调用,返回 true 表示"继续",false 表示"关闭连接"。这里用 wait_for 而不是 wait,是为了周期性地检查心跳定时器,避免在没有消息时永久阻塞。
4.6 Read():生产者-消费者模型
cpp
std::pair<size_t, std::string> SSE::Read() {
std::unique_lock<std::mutex> lock(incoming_mutex_);
// 阻塞等待,直到:有新消息 OR 服务停止
incoming_cv_.wait(lock, [this]() {
return !incoming_messages_.empty() || !server_running_.load();
});
if (!server_running_.load() && incoming_messages_.empty()) {
return {0, ""}; // 服务已停止,返回空
}
std::string message = incoming_messages_.front();
incoming_messages_.pop();
return {message.length(), message};
}
这是经典的生产者-消费者模型:
- 生产者 :HTTP POST 处理线程,把客户端消息
push进incoming_messages_ - 消费者 :业务线程,调用
Read()阻塞等待并pop消息
用条件变量 wait 而不是自旋(while(queue.empty()) {}),是因为自旋会不断消耗 CPU,而 wait 会让线程睡眠,直到被 notify_one() 唤醒。
4.7 Stop():优雅关闭
cpp
void SSE::Stop() {
server_running_.store(false);
client_connected_.store(false);
sse_active_.store(false);
if (server_) server_->stop();
if (server_thread_.joinable()) server_thread_.join();
// 唤醒所有可能正在 wait 的线程,避免永久阻塞
incoming_cv_.notify_all();
outgoing_cv_.notify_all();
}
最后两行 notify_all() 非常重要。如果直接退出,那些正在 wait 的线程(比如正在调用 Read() 的业务线程)会永远阻塞,造成进程无法退出。notify_all() 把它们全部唤醒,让它们检查到 server_running_ == false 后自然退出。
Transport 抽象的本质,是用两个线程安全队列,把"网络通信"变成"生产者-消费者消息传递",让业务层只关心消息内容,彻底屏蔽底层传输协议的复杂性。
五、完整通信时序图
客户端 服务端
| |
|------- GET /sse ----------------------→ | HandleSSEConnection()
|←------ event: endpoint\ndata:/messages-- | 发送 endpoint 事件
| |
| [SSE 长连接保持,每 15s 心跳] |
| |
|------- POST /messages ----------------→ | HandlePostMessage()
| | → incoming_messages_.push()
| | → incoming_cv_.notify_one()
|←------ {"status":"received"} ---------- |
| |
| 业务线程 ← Read() 从队列取消息
| 业务线程 → 处理逻辑
| 业务线程 → Write() → outgoing_messages_.push()
| | → outgoing_cv_.notify_one()
| |
|←------ data: {"result":"ok"}\n\n ------ | SSE content_provider 取出并发送
| |
六、两种 Transport 的横向对比
| 特性 | Stdio |
SSE |
|---|---|---|
| 通信方式 | stdin / stdout | HTTP SSE + POST |
| 使用场景 | CLI、子进程、MCP | 浏览器、Web 客户端 |
| 双向通信 | 天然支持(读写各一个流) | 拆分为两条 HTTP 通道 |
| 并发复杂度 | 低(单线程读写) | 高(多线程 + 队列 + 条件变量) |
| 心跳保活 | 不需要 | 需要(15s ping) |
| CORS 处理 | 不需要 | 需要(OPTIONS 预检) |
| 错误检测 | EOF | sink.write() 失败 |
| 代码量 | ~70 行 | ~360 行 |
七、Transport Abstraction 总结
1. 协议可替换,业务零改动
今天用 SSE,明天改成 WebSocket,业务代码完全不用动:
cpp
// 只需改这一行
std::unique_ptr<ITransport> transport = std::make_unique<WebSocketTransport>();
2. 把网络通信抽象为线程安全队列
网络 → 队列 → 业务 → 队列 → 网络
而不是:
网络 ↔ 业务(强耦合)
3. 可测试性极强
写一个 MockTransport,不需要任何网络,就能测试业务逻辑:
cpp
class MockTransport : public ITransport {
std::queue<std::string> in, out;
std::pair<size_t, std::string> Read() override { /* 从 in 取 */ }
void Write(const std::string& data) override { out.push(data); }
// ...
};
4. 线程解耦
- HTTP 接收线程:负责接收,写入 incoming 队列
- SSE 发送线程:负责发送,读取 outgoing 队列
- 业务线程:只调用
Read()和Write(),完全不感知网络
八、优化思路
StdioTransport:
- 支持
Content-Length帧协议(像 LSP 那样),解决多行 JSON 问题 IsRunning()应检测 stdin 是否已 EOFWriteAsync()缺少线程安全保护,多线程写 stdout 可能交叉
SseTransport:
- 目前是单 SSE 连接模型,可以扩展为多 session(每个 session 独立的队列)
- 队列无界,高并发下可能无限增长,需要 backpressure 机制
- 断线重连后的消息重放能力(断线期间的消息丢失)