MCP HTTP 传输详解:比 SSE 简单,但有一个意外的坑

标签Java MCP HTTP OkHttp Session SSE格式解析 j-langchain
前置阅读MCP SSE 传输详解:双通道设计与异步请求匹配
适合人群:需要对接支持 HTTP 传输的 MCP 服务、或希望理解三种传输方式差异的 Java 开发者


一、HTTP 传输和 SSE 传输有什么区别

上一篇文章介绍了 SSE 传输:客户端先建立一条 SSE 长连接(下行),再通过 HTTP POST 发请求(上行),两个通道配合,用 CompletableFuture 做异步匹配。

HTTP 传输去掉了 SSE 长连接,变成单通道:

复制代码
SSE 传输(双通道):
  GET /sse  ─────── 长连接,服务器推送响应
  POST /messages/... ── 客户端发送请求

HTTP 传输(单通道):
  POST /mcp ── 发请求
  ← 响应  ──── 等待响应,同一次连接内返回

不需要管理长连接,不需要 CompletableFuture 和 Map,同步阻塞等待响应,实现上比 SSE 简单很多。

但有一个意外:虽然用的是标准 HTTP,但响应体的格式是 SSE 。这意味着你需要在 HTTP 响应里解析 data: 前缀的文本,才能拿到真正的 JSON-RPC 响应。


二、完整通信流程

复制代码
客户端                                      服务器
  │                                          │
  │──── 1. POST /mcp(initialize)─────────> │  Accept 必须包含 text/event-stream
  │<─── 2. 200 OK(SSE 格式响应)──────────   │  响应头含 mcp-session-id
  │                                          │
  │  保存 session_id                         │
  │                                          │
  │──── 3. POST /mcp(initialized 通知)───> │  携带 mcp-session-id
  │<─── 4. 200 OK ──────────────────────     │
  │                                          │
  │──── 5. POST /mcp(tools/list)─────────> │  携带 mcp-session-id
  │<─── 6. 200 OK(SSE 格式响应)──────────   │
  │                                          │
  │──── 7. DELETE /mcp ────────────────────> │  清理 session
  │<─── 8. 200 OK ──────────────────────     │

三、核心实现

第一步:发送请求,提取 session_id

第一次请求不携带 session,服务器在响应头里返回 mcp-session-id,后续所有请求都需要带上它。

用 OkHttp 的拦截器集中处理 session 提取,避免每个请求都重复写这段逻辑:

java 复制代码
public McpHttpConnection(String serverName, ServerConfig config) {
    super(serverName, config);
    this.baseUrl = config.url;

    this.httpClient = new OkHttpClient.Builder()
        .connectTimeout(10, TimeUnit.SECONDS)
        .readTimeout(30, TimeUnit.SECONDS)
        .addInterceptor(chain -> {
            Response response = chain.proceed(chain.request());

            // 自动提取 session_id(只取第一次)
            String mcpSessionId = response.header("mcp-session-id");
            if (mcpSessionId != null) {
                if (sessionId == null) {
                    sessionId = mcpSessionId;
                    log.info("[{}] 收到 session_id:{}", serverName, sessionId);
                } else if (!sessionId.equals(mcpSessionId)) {
                    // session 发生变更,记录日志
                    log.warn("[{}] session_id 变更:{} → {}", serverName, sessionId, mcpSessionId);
                    sessionId = mcpSessionId;
                }
            }
            return response;
        })
        .build();
}

第二步:发送 HTTP 请求

每次请求的结构是固定的:POST + Content-Type: application/json + Accept: application/json, text/event-stream + 可选的 mcp-session-id

java 复制代码
private String sendHttpRequest(String jsonBody) throws IOException {
    RequestBody body = RequestBody.create(jsonBody,
        MediaType.get("application/json; charset=utf-8"));

    Request.Builder builder = new Request.Builder()
        .url(baseUrl)
        .post(body)
        .header("Content-Type", "application/json")
        .header("Accept", "application/json, text/event-stream");  // 两种格式都要声明

    if (sessionId != null) {
        builder.header("mcp-session-id", sessionId);  // 握手后的所有请求都携带
    }

    try (Response response = httpClient.newCall(builder.build()).execute()) {
        if (!response.isSuccessful()) {
            String errorBody = response.body() != null ? response.body().string() : "";
            lastError = String.format("HTTP %d: %s", response.code(), errorBody);
            throw new IOException(lastError);
        }
        return response.body() != null ? response.body().string() : "{}";
    }
}

Accept 头必须同时包含 application/jsontext/event-stream。只写 application/json 的话,部分服务器会返回 406 Not Acceptable

第三步:解析 SSE 格式的响应体

这是 HTTP 传输里最容易踩坑的地方。响应体看起来像这样:

复制代码
event: message
data: {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05",...}}

不能直接当 JSON 解析,需要先提取 data: 行的内容:

java 复制代码
private String parseSseResponse(String sseText) throws IOException {
    if (sseText == null || sseText.trim().isEmpty()) {
        return "{}";
    }

    // 如果不含 data:,可能是纯 JSON(少数服务器实现),直接返回
    if (!sseText.contains("data:")) {
        return sseText.trim();
    }

    StringBuilder jsonData = new StringBuilder();

    try (BufferedReader reader = new BufferedReader(new StringReader(sseText))) {
        String line;
        while ((line = reader.readLine()) != null) {
            line = line.trim();
            if (line.startsWith("data:")) {
                String data = line.substring(5).trim();
                if (jsonData.length() > 0) {
                    jsonData.append("\n");  // 兼容多行 data
                }
                jsonData.append(data);
            }
            // event: 和 : 注释行都忽略
        }
    }

    String result = jsonData.toString().trim();
    if (result.isEmpty()) {
        throw new IOException("SSE 响应中没有 data 字段:" + sseText);
    }
    return result;
}

第四步:发送 JSON-RPC 请求

将请求构建、HTTP 发送、SSE 解析串起来:

java 复制代码
public synchronized McpResponse sendRequest(String method, Object params) throws Exception {
    if (!connected) {
        throw new IllegalStateException("未建立连接:" + serverName);
    }

    McpRequest request = new McpRequest();
    request.id     = nextRequestId();
    request.method = method;
    request.params = params;

    String requestJson  = mapper.writeValueAsString(request);
    String responseText = sendHttpRequest(requestJson);
    String responseJson = parseSseResponse(responseText);   // 处理 SSE 格式

    McpResponse response = mapper.readValue(responseJson, McpResponse.class);
    if (response.error != null) {
        throw new McpException(response.error.code, response.error.message, response.error.data);
    }
    return response;
}

第五步:发送通知

通知没有 id,也不解析响应(通知没有对应的响应):

java 复制代码
protected void sendNotification(String method, Object params) throws Exception {
    Map<String, Object> notification = new HashMap<>();
    notification.put("jsonrpc", "2.0");
    notification.put("method", method);
    notification.put("params", params);

    sendHttpRequest(mapper.writeValueAsString(notification));
    // 不读取响应内容
}

第六步:建立连接与关闭

java 复制代码
public void connect() throws IOException {
    try {
        connected = true;
        performHandshake();  // 三步握手,session_id 在第一次请求后由拦截器自动提取
        log.info("[{}] HTTP 连接建立完成,session:{}", serverName, sessionId);
    } catch (Exception e) {
        connected = false;
        throw new IOException("连接失败", e);
    }
}

public void close() {
    // 发送 DELETE 请求,通知服务器释放 session 资源
    if (connected && sessionId != null) {
        try {
            Request request = new Request.Builder()
                .url(baseUrl)
                .delete()
                .header("mcp-session-id", sessionId)
                .header("Accept", "application/json, text/event-stream")
                .build();

            try (Response response = httpClient.newCall(request).execute()) {
                log.info("[{}] session 清理完成,状态码:{}", serverName, response.code());
            }
        } catch (Exception e) {
            log.warn("[{}] session 清理失败(可忽略):{}", serverName, e.getMessage());
        }
    }
    connected = false;
    sessionId = null;
}

四、三个容易踩坑的地方

坑一:Accept 头缺少 text/event-stream

症状 :第一次请求收到 406 Not Acceptable

原因 :服务器检查 Accept 头,如果不包含 text/event-stream 就拒绝请求,因为它要用 SSE 格式返回响应。

解决Accept: application/json, text/event-stream 两种类型都要声明,缺一不可。


坑二:直接把响应体当 JSON 解析

症状 :抛出 JsonParseException: Unexpected character ('e'),因为响应体以 event: 开头。

原因 :服务器返回的不是纯 JSON,而是 SSE 格式的文本,需要先提取 data: 行再解析。

解决 :所有响应体都先经过 parseSseResponse() 处理,兼容纯 JSON 和 SSE 两种格式。


坑三:后续请求忘带 session_id

症状 :第一次请求(initialize)成功,后续请求返回 401403

原因 :服务器用 session_id 维持上下文状态,不带 session 的请求被视为新连接或未授权请求。

解决:用 OkHttp 拦截器统一处理,提取 session_id 并在后续所有请求中自动添加,不需要每次手动处理。


五、与 SSE 传输的对比

理解三种传输方式,关键在于"请求/响应怎么匹配":

java 复制代码
// Stdio:同步管道,写完直接读,天然对应
stdin.write(request + "\n"); stdin.flush();
String response = stdout.readLine();

// SSE:异步双通道,用 Map + Future 按 id 匹配
pendingResponses.put(requestId, new CompletableFuture<>());
sendHttpPost(request);
return pendingResponses.get(requestId).get(30, TimeUnit.SECONDS);

// HTTP:同步单通道,阻塞等待,最简单
Response response = httpClient.newCall(request).execute();
String json = parseSseResponse(response.body().string());  // 多一步 SSE 解析

HTTP 的匹配方式和 Stdio 一样直接,但多了 SSE 格式解析这一步。

维度 Stdio SSE HTTP
适用场景 本地 npx/进程 远程服务,需要推送 远程服务,简单请求响应
并发模型 同步单线程 异步多线程 同步单线程
实现复杂度
特殊处理 stderr 防死锁 双通道 + Future 匹配 SSE 格式响应解析
session 管理 无需 无需(SSE 保持连接) 需要(无状态 HTTP)

六、生产环境补充:重试和超时

默认的 OkHttp 超时对于某些慢速 MCP 服务可能不够,按需调整:

java 复制代码
// 按请求类型设置不同超时(工具调用可能耗时较长)
public McpResponse sendRequestWithTimeout(String method, Object params, int timeoutSec) throws Exception {
    OkHttpClient clientWithTimeout = httpClient.newBuilder()
        .readTimeout(timeoutSec, TimeUnit.SECONDS)
        .build();
    // 用 clientWithTimeout 发送
}

简单的指数退避重试:

java 复制代码
public class RetryInterceptor implements Interceptor {
    private final int maxRetries;

    @Override
    public Response intercept(Chain chain) throws IOException {
        IOException lastException = null;
        for (int i = 0; i <= maxRetries; i++) {
            try {
                Response response = chain.proceed(chain.request());
                if (response.isSuccessful()) return response;
            } catch (IOException e) {
                lastException = e;
                if (i < maxRetries) {
                    try { Thread.sleep(1000L * (1 << i)); }  // 1s, 2s, 4s...
                    catch (InterruptedException ie) { Thread.currentThread().interrupt(); break; }
                }
            }
        }
        throw lastException != null ? lastException : new IOException("请求失败");
    }
}

七、总结

HTTP 传输是三种方式中实现最简单的,核心逻辑只有四步:POST 发请求 → 提取 session_id → 解析 SSE 格式响应 → DELETE 清理 session

最容易出问题的地方不是协议本身,而是两个细节:Accept 头必须声明两种格式,以及响应体需要经过 SSE 解析才能拿到 JSON。

在 j-langchain 中,这些都封装在 McpHttpConnection 内部,对外暴露的接口与 Stdio 和 SSE 完全相同,使用方不需要关心传输层的任何差异。


📎 相关资源

相关推荐
花千树-0102 小时前
三个 Agent 并行调研:用 concurrent 节点构建并发-汇聚式旅游规划助手
java·langchain·agent·function call·multi agent·mcp·harness
2501_913061342 小时前
网络原理之HTTP
java·网络·面试
yaaakaaang2 小时前
二十、状态模式
java·状态模式
一只大袋鼠2 小时前
MyBatis 进阶实战(四): 连接池、动态 SQL、多表关联(一对多 / 多对一 / 多对多)
java·开发语言·数据库·sql·mysql·mybatis
是小蟹呀^2 小时前
【整理】Agent中的ReAct架构
langchain·agent·react
电商API&Tina2 小时前
【1688API接口】1688 开放平台 API 接入心得
java·开发语言·数据库·python·sql·json
新酱爱学习2 小时前
从一次 OpenClaw 请求抓包,聊聊 Skill 的运行原理
前端·人工智能·mcp
Rabitebla2 小时前
【C++】手撕日期类——运算符重载完全指南(含易错点+底层逻辑分析)
java·c语言·开发语言·数据结构·c++·算法·链表
callJJ2 小时前
SpringBoot 自动配置原理详解——从“约定优于配置“到源码全程追踪
java·spring boot·后端·spring