MCP项目笔记四(Transport)

深入理解 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:

  • firstsize_t):实际读取的字节数,用于判断读取是否有效
  • secondstring):读到的内容,在 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 结构化绑定

上层代码对 SSEStdioWebSocket 完全透明。


三、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;
}

三个动作:

  1. 输出 JSON 字符串
  2. std::endl:追加换行(协议要求的消息分隔符)
  3. 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 处理线程,把客户端消息 pushincoming_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 是否已 EOF
  • WriteAsync() 缺少线程安全保护,多线程写 stdout 可能交叉

SseTransport:

  • 目前是单 SSE 连接模型,可以扩展为多 session(每个 session 独立的队列)
  • 队列无界,高并发下可能无限增长,需要 backpressure 机制
  • 断线重连后的消息重放能力(断线期间的消息丢失)
相关推荐
Felven1 小时前
C. Stable Groups
c语言·开发语言
码农的小菜园1 小时前
Java线程池学习笔记
java·笔记·学习
SuperEugene1 小时前
Vue3 + Element Plus 表单校验实战:规则复用、自定义校验、提示语统一,告别混乱避坑|表单与表格规范篇
开发语言·前端·javascript·vue.js·前端框架
2401_894241921 小时前
基于C++的数据库连接池
开发语言·c++·算法
阿贵---1 小时前
C++中的适配器模式
开发语言·c++·算法
C羊驼1 小时前
C语言学习笔记(十二):动态内存管理
c语言·开发语言·经验分享·笔记·青少年编程
SuperEugene2 小时前
Vue3 + Element Plus 表格查询规范:条件管理、分页联动 + 避坑,标准化写法|表单与表格规范篇
开发语言·前端·javascript·vue.js·前端框架
倾心琴心2 小时前
【agent辅助热仿真学习】实践1 hotspot 热仿真代码流程学习
ai·agent·芯片·热仿真·求解
qq_466302452 小时前
vs2022 与Qt版本兼容 带来的警告
c++·qt