feat: implement Streamable HTTP HttpClient transport and improve error handling (#337)

- Add HttpClientStreamableHttpTransport implementing the 2025-03-26 MCP specification
- Add ResponseSubscribers utility for handling different HTTP response body types
- Refactor HttpClientSseClientTransport to use reactive streams instead of FlowSseClient
  - Remove FlowSseClient in favor of reactive stream patterns
  - Use Disposable-based connection management instead of CountDownLatch
  - Replace message endpoint discovery with Sinks.One approach
- Improve error handling by replacing doOnError/onErrorResume with onErrorComplete
- Add test coverage for new Streamable HTTP transport
- Add resiliency tests for connection handling and session invalidation

BREAKING CHANGE: FlowSseClient has been removed in favor of reactive streams

Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>

Co-authored-by: Dariusz Jędrzejczyk <dariusz.jedrzejczyk@broadcom.com>
This commit is contained in:
Christian Tzolov 2025-06-13 07:05:47 +02:00
commit c711f8315a
4 changed files with 1601 additions and 394 deletions

View file

@ -129,11 +129,10 @@ public class WebClientStreamableHttpTransport implements McpClientTransport {
Function<String, Publisher<Void>> onClose = sessionId -> sessionId == null ? Mono.empty()
: webClient.delete().uri(this.endpoint).headers(httpHeaders -> {
httpHeaders.add("mcp-session-id", sessionId);
})
.retrieve()
.toBodilessEntity()
.doOnError(e -> logger.warn("Got error when closing transport", e))
.then();
}).retrieve().toBodilessEntity().onErrorComplete(e -> {
logger.warn("Got error when closing transport", e);
return true;
}).then();
return new DefaultMcpTransportSession(onClose);
}
@ -305,12 +304,12 @@ public class WebClientStreamableHttpTransport implements McpClientTransport {
}
})
.flatMap(jsonRpcMessage -> this.handler.get().apply(Mono.just(jsonRpcMessage)))
.onErrorResume(t -> {
.onErrorComplete(t -> {
// handle the error first
this.handleException(t);
// inform the caller of sendMessage
sink.error(t);
return Flux.empty();
return true;
})
.doFinally(s -> {
Disposable ref = disposableRef.getAndSet(null);

View file

@ -1,12 +1,12 @@
package io.modelcontextprotocol.client;
import io.modelcontextprotocol.client.transport.WebClientStreamableHttpTransport;
import io.modelcontextprotocol.spec.McpClientTransport;
import org.junit.jupiter.api.Timeout;
import org.springframework.web.reactive.function.client.WebClient;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.springframework.web.reactive.function.client.WebClient;
import io.modelcontextprotocol.client.transport.WebClientStreamableHttpTransport;
import io.modelcontextprotocol.spec.McpClientTransport;
@Timeout(15)
public class WebClientStreamableHttpAsyncClientTests extends AbstractMcpAsyncClientTests {

View file

@ -1,12 +1,12 @@
package io.modelcontextprotocol.client;
import io.modelcontextprotocol.client.transport.WebClientStreamableHttpTransport;
import io.modelcontextprotocol.spec.McpClientTransport;
import org.junit.jupiter.api.Timeout;
import org.springframework.web.reactive.function.client.WebClient;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.springframework.web.reactive.function.client.WebClient;
import io.modelcontextprotocol.client.transport.WebClientStreamableHttpTransport;
import io.modelcontextprotocol.spec.McpClientTransport;
@Timeout(15)
public class WebClientStreamableHttpSyncClientTests extends AbstractMcpSyncClientTests {

View file

@ -9,16 +9,16 @@
</appender>
<!-- Main MCP package -->
<logger name="io.modelcontextprotocol" level="DEBUG"/>
<logger name="io.modelcontextprotocol" level="INFO"/>
<!-- Client packages -->
<logger name="io.modelcontextprotocol.client" level="DEBUG"/>
<logger name="io.modelcontextprotocol.client" level="INFO"/>
<!-- Server transport package -->
<logger name="io.modelcontextprotocol.server.transport" level="DEBUG"/>
<logger name="io.modelcontextprotocol.server.transport" level="INFO"/>
<!-- Spec package -->
<logger name="io.modelcontextprotocol.spec" level="DEBUG"/>
<logger name="io.modelcontextprotocol.spec" level="INFO"/>
<!-- Root logger -->
<root level="INFO">