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
8 changed files with 1601 additions and 394 deletions

View file

@ -15,8 +15,6 @@
*/
package io.modelcontextprotocol;
import java.util.Map;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpServerSession;
import io.modelcontextprotocol.spec.McpServerSession.Factory;

View file

@ -0,0 +1,226 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package io.modelcontextprotocol.client;
import eu.rekawek.toxiproxy.Proxy;
import eu.rekawek.toxiproxy.ToxiproxyClient;
import eu.rekawek.toxiproxy.model.ToxicDirection;
import io.modelcontextprotocol.spec.McpClientTransport;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpTransport;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.Network;
import org.testcontainers.containers.ToxiproxyContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import reactor.test.StepVerifier;
import java.io.IOException;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import java.util.function.Function;
import static org.assertj.core.api.Assertions.assertThatCode;
/**
* Resiliency test suite for the {@link McpAsyncClient} that can be used with different
* {@link McpTransport} implementations that support Streamable HTTP.
*
* The purpose of these tests is to allow validating the transport layer resiliency
* instead of the functionality offered by the logical layer of MCP concepts such as
* tools, resources, prompts, etc.
*
* @author Dariusz Jędrzejczyk
*/
// KEEP IN SYNC with the class in mcp-test module
public abstract class AbstractMcpAsyncClientResiliencyTests {
private static final Logger logger = LoggerFactory.getLogger(AbstractMcpAsyncClientResiliencyTests.class);
static Network network = Network.newNetwork();
static String host = "http://localhost:3001";
// Uses the https://github.com/tzolov/mcp-everything-server-docker-image
@SuppressWarnings("resource")
static GenericContainer<?> container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v2")
.withCommand("node dist/index.js streamableHttp")
.withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String()))
.withNetwork(network)
.withNetworkAliases("everything-server")
.withExposedPorts(3001)
.waitingFor(Wait.forHttp("/").forStatusCode(404));
static ToxiproxyContainer toxiproxy = new ToxiproxyContainer("ghcr.io/shopify/toxiproxy:2.5.0").withNetwork(network)
.withExposedPorts(8474, 3000);
static Proxy proxy;
static {
container.start();
toxiproxy.start();
final ToxiproxyClient toxiproxyClient = new ToxiproxyClient(toxiproxy.getHost(), toxiproxy.getControlPort());
try {
proxy = toxiproxyClient.createProxy("everything-server", "0.0.0.0:3000", "everything-server:3001");
}
catch (IOException e) {
throw new RuntimeException("Can't create proxy!", e);
}
final String ipAddressViaToxiproxy = toxiproxy.getHost();
final int portViaToxiproxy = toxiproxy.getMappedPort(3000);
host = "http://" + ipAddressViaToxiproxy + ":" + portViaToxiproxy;
}
static void disconnect() {
long start = System.nanoTime();
try {
// disconnect
// proxy.toxics().bandwidth("CUT_CONNECTION_DOWNSTREAM",
// ToxicDirection.DOWNSTREAM, 0);
// proxy.toxics().bandwidth("CUT_CONNECTION_UPSTREAM",
// ToxicDirection.UPSTREAM, 0);
proxy.toxics().resetPeer("RESET_DOWNSTREAM", ToxicDirection.DOWNSTREAM, 0);
proxy.toxics().resetPeer("RESET_UPSTREAM", ToxicDirection.UPSTREAM, 0);
logger.info("Disconnect took {} ms", Duration.ofNanos(System.nanoTime() - start).toMillis());
}
catch (IOException e) {
throw new RuntimeException("Failed to disconnect", e);
}
}
static void reconnect() {
long start = System.nanoTime();
try {
proxy.toxics().get("RESET_UPSTREAM").remove();
proxy.toxics().get("RESET_DOWNSTREAM").remove();
// proxy.toxics().get("CUT_CONNECTION_DOWNSTREAM").remove();
// proxy.toxics().get("CUT_CONNECTION_UPSTREAM").remove();
logger.info("Reconnect took {} ms", Duration.ofNanos(System.nanoTime() - start).toMillis());
}
catch (IOException e) {
throw new RuntimeException("Failed to reconnect", e);
}
}
static void restartMcpServer() {
container.stop();
container.start();
}
abstract McpClientTransport createMcpTransport();
protected Duration getRequestTimeout() {
return Duration.ofSeconds(14);
}
protected Duration getInitializationTimeout() {
return Duration.ofSeconds(2);
}
McpAsyncClient client(McpClientTransport transport) {
return client(transport, Function.identity());
}
McpAsyncClient client(McpClientTransport transport, Function<McpClient.AsyncSpec, McpClient.AsyncSpec> customizer) {
AtomicReference<McpAsyncClient> client = new AtomicReference<>();
assertThatCode(() -> {
McpClient.AsyncSpec builder = McpClient.async(transport)
.requestTimeout(getRequestTimeout())
.initializationTimeout(getInitializationTimeout())
.capabilities(McpSchema.ClientCapabilities.builder().roots(true).build());
builder = customizer.apply(builder);
client.set(builder.build());
}).doesNotThrowAnyException();
return client.get();
}
void withClient(McpClientTransport transport, Consumer<McpAsyncClient> c) {
withClient(transport, Function.identity(), c);
}
void withClient(McpClientTransport transport, Function<McpClient.AsyncSpec, McpClient.AsyncSpec> customizer,
Consumer<McpAsyncClient> c) {
var client = client(transport, customizer);
try {
c.accept(client);
}
finally {
StepVerifier.create(client.closeGracefully()).expectComplete().verify(Duration.ofSeconds(10));
}
}
@Test
void testPing() {
withClient(createMcpTransport(), mcpAsyncClient -> {
StepVerifier.create(mcpAsyncClient.initialize()).expectNextCount(1).verifyComplete();
disconnect();
StepVerifier.create(mcpAsyncClient.ping()).expectError().verify();
reconnect();
StepVerifier.create(mcpAsyncClient.ping()).expectNextCount(1).verifyComplete();
});
}
@Test
void testSessionInvalidation() {
withClient(createMcpTransport(), mcpAsyncClient -> {
StepVerifier.create(mcpAsyncClient.initialize()).expectNextCount(1).verifyComplete();
restartMcpServer();
// The first try will face the session mismatch exception and the second one
// will go through the re-initialization process.
StepVerifier.create(mcpAsyncClient.ping().retry(1)).expectNextCount(1).verifyComplete();
});
}
@Test
void testCallTool() {
withClient(createMcpTransport(), mcpAsyncClient -> {
AtomicReference<List<McpSchema.Tool>> tools = new AtomicReference<>();
StepVerifier.create(mcpAsyncClient.initialize()).expectNextCount(1).verifyComplete();
StepVerifier.create(mcpAsyncClient.listTools())
.consumeNextWith(list -> tools.set(list.tools()))
.verifyComplete();
disconnect();
String name = tools.get().get(0).name();
// Assuming this is the echo tool
McpSchema.CallToolRequest request = new McpSchema.CallToolRequest(name, Map.of("message", "hello"));
StepVerifier.create(mcpAsyncClient.callTool(request)).expectError().verify();
reconnect();
StepVerifier.create(mcpAsyncClient.callTool(request)).expectNextCount(1).verifyComplete();
});
}
@Test
void testSessionClose() {
withClient(createMcpTransport(), mcpAsyncClient -> {
StepVerifier.create(mcpAsyncClient.initialize()).expectNextCount(1).verifyComplete();
// In case of Streamable HTTP this call should issue a HTTP DELETE request
// invalidating the session
StepVerifier.create(mcpAsyncClient.closeGracefully()).expectComplete().verify();
// The next use should immediately re-initialize with no issue and send the
// request without any broken connections.
StepVerifier.create(mcpAsyncClient.ping()).expectNextCount(1).verifyComplete();
});
}
}

View file

@ -0,0 +1,39 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package io.modelcontextprotocol.client;
import java.io.IOException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.spec.McpClientTransport;
import reactor.test.StepVerifier;
@Timeout(15)
public class HttpClientStreamableHttpAsyncClientResiliencyTests extends AbstractMcpAsyncClientResiliencyTests {
@Override
protected McpClientTransport createMcpTransport() {
return HttpClientStreamableHttpTransport.builder(host).build();
}
@Test
void testPingWithExactExceptionType() {
withClient(createMcpTransport(), mcpAsyncClient -> {
StepVerifier.create(mcpAsyncClient.initialize()).expectNextCount(1).verifyComplete();
disconnect();
StepVerifier.create(mcpAsyncClient.ping()).expectError(IOException.class).verify();
reconnect();
StepVerifier.create(mcpAsyncClient.ping()).expectNextCount(1).verifyComplete();
});
}
}

View file

@ -0,0 +1,41 @@
package io.modelcontextprotocol.client;
import org.junit.jupiter.api.Timeout;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.spec.McpClientTransport;
@Timeout(15)
public class HttpClientStreamableHttpAsyncClientTests extends AbstractMcpAsyncClientTests {
private String host = "http://localhost:3001";
// Uses the https://github.com/tzolov/mcp-everything-server-docker-image
@SuppressWarnings("resource")
GenericContainer<?> container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v2")
.withCommand("node dist/index.js streamableHttp")
.withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String()))
.withExposedPorts(3001)
.waitingFor(Wait.forHttp("/").forStatusCode(404));
@Override
protected McpClientTransport createMcpTransport() {
return HttpClientStreamableHttpTransport.builder(host).build();
}
@Override
protected void onStart() {
container.start();
int port = container.getMappedPort(3001);
host = "http://" + container.getHost() + ":" + port;
}
@Override
public void onClose() {
container.stop();
}
}

View file

@ -0,0 +1,40 @@
package io.modelcontextprotocol.client;
import org.junit.jupiter.api.Timeout;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.spec.McpClientTransport;
@Timeout(15)
public class HttpClientStreamableHttpSyncClientTests extends AbstractMcpSyncClientTests {
static String host = "http://localhost:3001";
// Uses the https://github.com/tzolov/mcp-everything-server-docker-image
@SuppressWarnings("resource")
GenericContainer<?> container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v2")
.withCommand("node dist/index.js streamableHttp")
.withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String()))
.withExposedPorts(3001)
.waitingFor(Wait.forHttp("/").forStatusCode(404));
@Override
protected McpClientTransport createMcpTransport() {
return HttpClientStreamableHttpTransport.builder(host).build();
}
@Override
protected void onStart() {
container.start();
int port = container.getMappedPort(3001);
host = "http://" + container.getHost() + ":" + port;
}
@Override
public void onClose() {
container.stop();
}
}

View file

@ -0,0 +1,137 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package io.modelcontextprotocol.client;
import static org.assertj.core.api.Assertions.assertThatCode;
import java.io.IOException;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.Network;
import org.testcontainers.containers.ToxiproxyContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import eu.rekawek.toxiproxy.Proxy;
import eu.rekawek.toxiproxy.ToxiproxyClient;
import eu.rekawek.toxiproxy.model.ToxicDirection;
import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport;
import io.modelcontextprotocol.spec.McpClientTransport;
import io.modelcontextprotocol.spec.McpSchema;
import reactor.test.StepVerifier;
@Timeout(15)
public class HttpSseMcpAsyncClientLostConnectionTests {
private static final Logger logger = LoggerFactory.getLogger(HttpSseMcpAsyncClientLostConnectionTests.class);
static Network network = Network.newNetwork();
static String host = "http://localhost:3001";
// Uses the https://github.com/tzolov/mcp-everything-server-docker-image
@SuppressWarnings("resource")
static GenericContainer<?> container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v2")
.withCommand("node dist/index.js sse")
.withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String()))
.withNetwork(network)
.withNetworkAliases("everything-server")
.withExposedPorts(3001)
.waitingFor(Wait.forHttp("/").forStatusCode(404));
static ToxiproxyContainer toxiproxy = new ToxiproxyContainer("ghcr.io/shopify/toxiproxy:2.5.0").withNetwork(network)
.withExposedPorts(8474, 3000);
static Proxy proxy;
static {
container.start();
toxiproxy.start();
final ToxiproxyClient toxiproxyClient = new ToxiproxyClient(toxiproxy.getHost(), toxiproxy.getControlPort());
try {
proxy = toxiproxyClient.createProxy("everything-server", "0.0.0.0:3000", "everything-server:3001");
}
catch (IOException e) {
throw new RuntimeException("Can't create proxy!", e);
}
final String ipAddressViaToxiproxy = toxiproxy.getHost();
final int portViaToxiproxy = toxiproxy.getMappedPort(3000);
host = "http://" + ipAddressViaToxiproxy + ":" + portViaToxiproxy;
}
static void disconnect() {
long start = System.nanoTime();
try {
proxy.toxics().resetPeer("RESET_DOWNSTREAM", ToxicDirection.DOWNSTREAM, 0);
proxy.toxics().resetPeer("RESET_UPSTREAM", ToxicDirection.UPSTREAM, 0);
logger.info("Disconnect took {} ms", Duration.ofNanos(System.nanoTime() - start).toMillis());
}
catch (IOException e) {
throw new RuntimeException("Failed to disconnect", e);
}
}
static void reconnect() {
long start = System.nanoTime();
try {
proxy.toxics().get("RESET_UPSTREAM").remove();
proxy.toxics().get("RESET_DOWNSTREAM").remove();
logger.info("Reconnect took {} ms", Duration.ofNanos(System.nanoTime() - start).toMillis());
}
catch (IOException e) {
throw new RuntimeException("Failed to reconnect", e);
}
}
McpAsyncClient client(McpClientTransport transport) {
AtomicReference<McpAsyncClient> client = new AtomicReference<>();
assertThatCode(() -> {
McpClient.AsyncSpec builder = McpClient.async(transport)
.requestTimeout(Duration.ofSeconds(14))
.initializationTimeout(Duration.ofSeconds(2))
.capabilities(McpSchema.ClientCapabilities.builder().roots(true).build());
client.set(builder.build());
}).doesNotThrowAnyException();
return client.get();
}
void withClient(McpClientTransport transport, Consumer<McpAsyncClient> c) {
var client = client(transport);
try {
c.accept(client);
}
finally {
StepVerifier.create(client.closeGracefully()).expectComplete().verify(Duration.ofSeconds(10));
}
}
@Test
void testPingWithEaxctExceptionType() {
withClient(HttpClientSseClientTransport.builder(host).build(), mcpAsyncClient -> {
StepVerifier.create(mcpAsyncClient.initialize()).expectNextCount(1).verifyComplete();
disconnect();
// Veryfiy that the exception type is IOException and not TimeoutException
StepVerifier.create(mcpAsyncClient.ping()).expectError(IOException.class).verify();
reconnect();
StepVerifier.create(mcpAsyncClient.ping()).expectNextCount(1).verifyComplete();
});
}
}

View file

@ -4,12 +4,13 @@
package io.modelcontextprotocol.client;
import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport;
import io.modelcontextprotocol.spec.McpClientTransport;
import org.junit.jupiter.api.Timeout;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport;
import io.modelcontextprotocol.spec.McpClientTransport;
/**
* Tests for the {@link McpSyncClient} with {@link HttpClientSseClientTransport}.
*

View file

@ -7,23 +7,20 @@ package io.modelcontextprotocol.client.transport;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.JSONRPCRequest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import reactor.core.publisher.Mono;
@ -34,11 +31,6 @@ import org.springframework.http.codec.ServerSentEvent;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* Tests for the {@link HttpClientSseClientTransport} class.
@ -371,25 +363,4 @@ class HttpClientSseClientTransportTests {
customizedTransport.closeGracefully().block();
}
@Test
@SuppressWarnings("unchecked")
void testResolvingClientEndpoint() {
HttpClient httpClient = Mockito.mock(HttpClient.class);
HttpResponse<Void> httpResponse = Mockito.mock(HttpResponse.class);
CompletableFuture<HttpResponse<Void>> future = new CompletableFuture<>();
future.complete(httpResponse);
when(httpClient.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))).thenReturn(future);
HttpClientSseClientTransport transport = new HttpClientSseClientTransport(httpClient, HttpRequest.newBuilder(),
"http://example.com", "http://example.com/sse", new ObjectMapper());
transport.connect(Function.identity());
ArgumentCaptor<HttpRequest> httpRequestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
verify(httpClient).sendAsync(httpRequestCaptor.capture(), any(HttpResponse.BodyHandler.class));
assertThat(httpRequestCaptor.getValue().uri()).isEqualTo(URI.create("http://example.com/sse"));
transport.closeGracefully().block();
}
}