标签 :
JavaMCPHTTPOkHttpSessionSSE格式解析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/json 和 text/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)成功,后续请求返回 401 或 403。
原因 :服务器用 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 完全相同,使用方不需要关心传输层的任何差异。
📎 相关资源
- j-langchain GitHub:https://github.com/flower-trees/j-langchain
- j-langchain Gitee 镜像:https://gitee.com/flower-trees-z/j-langchain
- MCP 官方规范:https://modelcontextprotocol.io
- OkHttp SSE 文档:https://square.github.io/okhttp/