modelcontextprotocol / modelcontextprotocol/java-sdk

HttpClientStreamableHttpTransport: reconnect() called before status code check causes duplicate connections on 405

未關閉
#773 2 則留言 0 個 reaction 已指派 0 人 在 GitHub 檢視

還沒有人認領這個 Issue。

waiting for user
主要語言
Java
星號
3.7k
分支
1.1k
平均合併
1 天 15 小時
30 天內合併 PR
9

描述

Description

When connecting to an MCP server that doesn't support Streamable HTTP transport (returns 405 Method Not Allowed), HttpClientStreamableHttpTransport still calls markInitialized() and reconnect(), which triggers an unnecessary GET request.

This causes issues when using transport fallback (Streamable HTTP → SSE), as duplicate SSE sessions are created on the upstream server.

Steps to Reproduce

  1. Set up an MCP proxy/server that only supports SSE transport (not Streamable HTTP)
  2. Return 405 Method Not Allowed for POST requests to the Streamable HTTP endpoint
  3. Use a client that tries both transports in parallel (Streamable HTTP and SSE)
  4. Observe that the Streamable HTTP transport sends an additional GET request after receiving 405

Expected Behavior

When the server returns an error status (like 405), reconnect() should NOT be called. The transport should simply fail and let the fallback mechanism use SSE transport.

Actual Behavior

markInitialized() and reconnect() are called BEFORE checking the status code, so even error responses trigger a GET request:

https://github.com/modelcontextprotocol/java-sdk/blob/main/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java#L468-L481

})).flatMap(responseEvent -> {
    if (transportSession.markInitialized(
            responseEvent.responseInfo().headers().firstValue("mcp-session-id").orElseGet(() -> null))) {
        // Once we have a session, we try to open an async stream for
        // the server to send notifications and requests out-of-band.

        reconnect(null).contextWrite(deliveredSink.contextView()).subscribe();
    }

    String sessionRepresentation = sessionIdOrPlaceholder(transportSession);

    int statusCode = responseEvent.responseInfo().statusCode();

    if (statusCode >= 200 && statusCode < 300) {

Also Affected: WebClientStreamableHttpTransport

The same bug exists in WebClientStreamableHttpTransport (lines 312-324):

https://github.com/modelcontextprotocol/java-sdk/blob/main/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java#L312-L324

.exchangeToFlux(response -> {
    if (transportSession
        .markInitialized(response.headers().asHttpHeaders().getFirst(HttpHeaders.MCP_SESSION_ID))) {
        // Once we have a session, we try to open an async stream for
        // the server to send notifications and requests out-of-band.
        reconnect(null).contextWrite(sink.contextView()).subscribe();
    }

    String sessionRepresentation = sessionIdOrPlaceholder(transportSession);

    // The spec mentions only ACCEPTED, but the existing SDKs can return
    // 200 OK for notifications
    if (response.statusCode().is2xxSuccessful()) {

Both implementations have the identical issue: markInitialized() and reconnect() are called before checking the HTTP status code.

Note: Why this is still a bug even though reconnect() handles 405

I noticed that reconnect() already has 405 handling logic:

} else if (statusCode == METHOD_NOT_ALLOWED) {
    logger.debug("The server does not support SSE streams, using request-response mode.");
    return Flux.empty();
}

However, this doesn't prevent the issue because:

  1. The GET request is already sent - By the time reconnect() handles the 405 response, the HTTP request has already been made
  2. Upstream creates a new session - When the GET request reaches the upstream server, it creates a new SSE session before returning any response
  3. Duplicate sessions exist - Now there are two sessions on upstream: one from SSE transport's GET, another from Streamable HTTP's reconnect() GET

The flow:

1. SSE transport: GET /sse → upstream creates session #1
2. Streamable HTTP: POST /mcp → 405
3. markInitialized(null) → returns true
4. reconnect() → GET /mcp sent → upstream creates session #2 (the damage is done!)
5. reconnect() receives response, handles 405 gracefully (but too late)
6. SSE transport: POST to session #1 → success
7. Result: session #2 is orphaned, potential timeout issues

The fix should prevent the GET request from being sent in the first place, not just handle the response gracefully.

Suggested Fix

Move markInitialized() and reconnect() inside the success status code check.

For HttpClientStreamableHttpTransport:

})).flatMap(responseEvent -> {
    String sessionRepresentation = sessionIdOrPlaceholder(transportSession);

    int statusCode = responseEvent.responseInfo().statusCode();

    if (statusCode >= 200 && statusCode < 300) {
        // Only initialize session and open async stream for successful responses
        if (transportSession.markInitialized(
                responseEvent.responseInfo().headers().firstValue("mcp-session-id").orElseGet(() -> null))) {
            // Once we have a session, we try to open an async stream for
            // the server to send notifications and requests out-of-band.
            reconnect(null).contextWrite(deliveredSink.contextView()).subscribe();
        }

For WebClientStreamableHttpTransport:

.exchangeToFlux(response -> {
    String sessionRepresentation = sessionIdOrPlaceholder(transportSession);

    if (response.statusCode().is2xxSuccessful()) {
        // Only initialize session and open async stream for successful responses
        if (transportSession
            .markInitialized(response.headers().asHttpHeaders().getFirst(HttpHeaders.MCP_SESSION_ID))) {
            reconnect(null).contextWrite(sink.contextView()).subscribe();
        }

Impact

This bug affects any scenario where:

  • A proxy or server doesn't support Streamable HTTP (returns 405)
  • Client implements transport fallback (try Streamable HTTP first, fall back to SSE)
  • Results in duplicate upstream sessions and potential timeout issues

Environment

  • MCP Java SDK version: latest (this code has been present since the initial Streamable HTTP implementation in commit c711f83)

貢獻指南

開啟貢獻指南

從這裡開始

  1. 先讀完整個 Issue,再讀專案的貢獻指南。
  2. 在 Issue 下留言說明你要接手 —— 這能避免兩個人做同樣的事。
  3. Fork 儲存庫,在一個分支上完成修改。
  4. 送出 Pull Request,並在描述裡引用這個 Issue 編號。

研究方向

從 mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java 和 mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java 中的回應處理器開始。檢查 markInitialized() 和 reconnect() 周圍的狀態碼分支。完成標準是:非 2xx 回應(尤其是 405)不會觸發 GET,同時成功回應會保留工作階段初始化,且回退到 SSE 時不會產生重複工作階段。

由索引模型根據 Issue 內容生成。

評估

技術堆疊
java, spring
領域
backend-api-design, networking
Issue 類型
缺陷
難度
3/5
預估耗時
1-2 天
活躍度
冷清
描述清晰度
描述清楚
新手友好度
75/100

把新 issue 寄到你的電子郵件信箱

精選適合新手參與的 GitHub issue 摘要。