feat: WebClient Streamable HTTP support (#292)

An implementation of Streamable HTTP Client with WebFlux WebClient.
Aside from implementing the specification, several improvements have been incorporated throughout the client-side of the architecture.

The changes cover:
- resilience tests using toxiproxy in testcontainers
- integration tests using updated everything-server with streamableHttp support
- improved logging
- session invalidation handling (both transport session and JSON-RPC concept of session)
- implicit initialization and burst protection (in case of concurrent `Mcp(Sync|Async)Client` use
- more logging, e.g. stdio process lifecycle logs

Related #72, #273, #253, #107, #105

Signed-off-by: Dariusz Jędrzejczyk <dariusz.jedrzejczyk@broadcom.com>
This commit is contained in:
Dariusz Jędrzejczyk 2025-06-10 18:33:40 +02:00 • committed by GitHub
commit 2f944349cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 1501 additions and 242 deletions

View file

@ -0,0 +1,222 @@
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
*/
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;
}
private 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);
}
}
private 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);
}
}
private 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

@ -110,14 +110,16 @@ public abstract class AbstractMcpAsyncClientTests {
onClose();
}
<T> void verifyInitializationTimeout(Function<McpAsyncClient, Mono<T>> operation, String action) {
<T> void verifyNotificationSucceedsWithImplicitInitialization(Function<McpAsyncClient, Mono<T>> operation,
String action) {
withClient(createMcpTransport(), mcpAsyncClient -> {
StepVerifier.withVirtualTime(() -> operation.apply(mcpAsyncClient))
.expectSubscription()
.thenAwait(getInitializationTimeout())
.consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class)
.hasMessage("Client must be initialized before " + action))
.verify();
StepVerifier.create(operation.apply(mcpAsyncClient)).verifyComplete();
});
}
<T> void verifyCallSucceedsWithImplicitInitialization(Function<McpAsyncClient, Mono<T>> operation, String action) {
withClient(createMcpTransport(), mcpAsyncClient -> {
StepVerifier.create(operation.apply(mcpAsyncClient)).expectNextCount(1).verifyComplete();
});
}
@ -133,7 +135,7 @@ public abstract class AbstractMcpAsyncClientTests {
@Test
void testListToolsWithoutInitialization() {
verifyInitializationTimeout(client -> client.listTools(null), "listing tools");
verifyCallSucceedsWithImplicitInitialization(client -> client.listTools(null), "listing tools");
}
@Test
@ -153,7 +155,7 @@ public abstract class AbstractMcpAsyncClientTests {
@Test
void testPingWithoutInitialization() {
verifyInitializationTimeout(client -> client.ping(), "pinging the server");
verifyCallSucceedsWithImplicitInitialization(client -> client.ping(), "pinging the server");
}
@Test
@ -168,7 +170,7 @@ public abstract class AbstractMcpAsyncClientTests {
@Test
void testCallToolWithoutInitialization() {
CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", ECHO_TEST_MESSAGE));
verifyInitializationTimeout(client -> client.callTool(callToolRequest), "calling tools");
verifyCallSucceedsWithImplicitInitialization(client -> client.callTool(callToolRequest), "calling tools");
}
@Test
@ -202,7 +204,7 @@ public abstract class AbstractMcpAsyncClientTests {
@Test
void testListResourcesWithoutInitialization() {
verifyInitializationTimeout(client -> client.listResources(null), "listing resources");
verifyCallSucceedsWithImplicitInitialization(client -> client.listResources(null), "listing resources");
}
@Test
@ -233,7 +235,7 @@ public abstract class AbstractMcpAsyncClientTests {
@Test
void testListPromptsWithoutInitialization() {
verifyInitializationTimeout(client -> client.listPrompts(null), "listing " + "prompts");
verifyCallSucceedsWithImplicitInitialization(client -> client.listPrompts(null), "listing " + "prompts");
}
@Test
@ -258,7 +260,7 @@ public abstract class AbstractMcpAsyncClientTests {
@Test
void testGetPromptWithoutInitialization() {
GetPromptRequest request = new GetPromptRequest("simple_prompt", Map.of());
verifyInitializationTimeout(client -> client.getPrompt(request), "getting " + "prompts");
verifyCallSucceedsWithImplicitInitialization(client -> client.getPrompt(request), "getting " + "prompts");
}
@Test
@ -279,7 +281,7 @@ public abstract class AbstractMcpAsyncClientTests {
@Test
void testRootsListChangedWithoutInitialization() {
verifyInitializationTimeout(client -> client.rootsListChangedNotification(),
verifyNotificationSucceedsWithImplicitInitialization(client -> client.rootsListChangedNotification(),
"sending roots list changed notification");
}
@ -354,7 +356,8 @@ public abstract class AbstractMcpAsyncClientTests {
@Test
void testListResourceTemplatesWithoutInitialization() {
verifyInitializationTimeout(client -> client.listResourceTemplates(), "listing resource templates");
verifyCallSucceedsWithImplicitInitialization(client -> client.listResourceTemplates(),
"listing resource templates");
}
@Test
@ -447,8 +450,8 @@ public abstract class AbstractMcpAsyncClientTests {
@Test
void testLoggingLevelsWithoutInitialization() {
verifyInitializationTimeout(client -> client.setLoggingLevel(McpSchema.LoggingLevel.DEBUG),
"setting logging level");
verifyNotificationSucceedsWithImplicitInitialization(
client -> client.setLoggingLevel(McpSchema.LoggingLevel.DEBUG), "setting logging level");
}
@Test

View file

@ -5,6 +5,7 @@
package io.modelcontextprotocol.client;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
@ -12,7 +13,6 @@ import java.util.function.Consumer;
import java.util.function.Function;
import io.modelcontextprotocol.spec.McpClientTransport;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
@ -112,33 +112,18 @@ public abstract class AbstractMcpSyncClientTests {
static final Object DUMMY_RETURN_VALUE = new Object();
<T> void verifyNotificationTimesOut(Consumer<McpSyncClient> operation, String action) {
verifyCallTimesOut(client -> {
<T> void verifyNotificationSucceedsWithImplicitInitialization(Consumer<McpSyncClient> operation, String action) {
verifyCallSucceedsWithImplicitInitialization(client -> {
operation.accept(client);
return DUMMY_RETURN_VALUE;
}, action);
}
<T> void verifyCallTimesOut(Function<McpSyncClient, T> blockingOperation, String action) {
<T> void verifyCallSucceedsWithImplicitInitialization(Function<McpSyncClient, T> blockingOperation, String action) {
withClient(createMcpTransport(), mcpSyncClient -> {
// This scheduler is not replaced by virtual time scheduler
Scheduler customScheduler = Schedulers.newBoundedElastic(1, 1, "actualBoundedElastic");
StepVerifier.withVirtualTime(() -> Mono.fromSupplier(() -> blockingOperation.apply(mcpSyncClient))
// Offload the blocking call to the real scheduler
.subscribeOn(customScheduler))
.expectSubscription()
// This works without actually waiting but executes all the
// tasks pending execution on the VirtualTimeScheduler.
// It is possible to execute the blocking code from the operation
// because it is blocked on a dedicated Scheduler and the main
// flow is not blocked and uses the VirtualTimeScheduler.
.thenAwait(getInitializationTimeout())
.consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class)
.hasMessage("Client must be initialized before " + action))
.verify();
customScheduler.dispose();
StepVerifier.create(Mono.fromSupplier(() -> blockingOperation.apply(mcpSyncClient)))
.expectNextCount(1)
.verifyComplete();
});
}
@ -154,7 +139,7 @@ public abstract class AbstractMcpSyncClientTests {
@Test
void testListToolsWithoutInitialization() {
verifyCallTimesOut(client -> client.listTools(null), "listing tools");
verifyCallSucceedsWithImplicitInitialization(client -> client.listTools(null), "listing tools");
}
@Test
@ -175,8 +160,8 @@ public abstract class AbstractMcpSyncClientTests {
@Test
void testCallToolsWithoutInitialization() {
verifyCallTimesOut(client -> client.callTool(new CallToolRequest("add", Map.of("a", 3, "b", 4))),
"calling tools");
verifyCallSucceedsWithImplicitInitialization(
client -> client.callTool(new CallToolRequest("add", Map.of("a", 3, "b", 4))), "calling tools");
}
@Test
@ -200,7 +185,7 @@ public abstract class AbstractMcpSyncClientTests {
@Test
void testPingWithoutInitialization() {
verifyCallTimesOut(client -> client.ping(), "pinging the server");
verifyCallSucceedsWithImplicitInitialization(client -> client.ping(), "pinging the server");
}
@Test
@ -214,7 +199,7 @@ public abstract class AbstractMcpSyncClientTests {
@Test
void testCallToolWithoutInitialization() {
CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", TEST_MESSAGE));
verifyCallTimesOut(client -> client.callTool(callToolRequest), "calling tools");
verifyCallSucceedsWithImplicitInitialization(client -> client.callTool(callToolRequest), "calling tools");
}
@Test
@ -243,7 +228,7 @@ public abstract class AbstractMcpSyncClientTests {
@Test
void testRootsListChangedWithoutInitialization() {
verifyNotificationTimesOut(client -> client.rootsListChangedNotification(),
verifyNotificationSucceedsWithImplicitInitialization(client -> client.rootsListChangedNotification(),
"sending roots list changed notification");
}
@ -257,7 +242,7 @@ public abstract class AbstractMcpSyncClientTests {
@Test
void testListResourcesWithoutInitialization() {
verifyCallTimesOut(client -> client.listResources(null), "listing resources");
verifyCallSucceedsWithImplicitInitialization(client -> client.listResources(null), "listing resources");
}
@Test
@ -333,8 +318,14 @@ public abstract class AbstractMcpSyncClientTests {
@Test
void testReadResourceWithoutInitialization() {
Resource resource = new Resource("test://uri", "Test Resource", null, null, null);
verifyCallTimesOut(client -> client.readResource(resource), "reading resources");
AtomicReference<List<Resource>> resources = new AtomicReference<>();
withClient(createMcpTransport(), mcpSyncClient -> {
mcpSyncClient.initialize();
resources.set(mcpSyncClient.listResources().resources());
});
verifyCallSucceedsWithImplicitInitialization(client -> client.readResource(resources.get().get(0)),
"reading resources");
}
@Test
@ -355,7 +346,8 @@ public abstract class AbstractMcpSyncClientTests {
@Test
void testListResourceTemplatesWithoutInitialization() {
verifyCallTimesOut(client -> client.listResourceTemplates(null), "listing resource templates");
verifyCallSucceedsWithImplicitInitialization(client -> client.listResourceTemplates(null),
"listing resource templates");
}
@Test
@ -413,8 +405,8 @@ public abstract class AbstractMcpSyncClientTests {
@Test
void testLoggingLevelsWithoutInitialization() {
verifyNotificationTimesOut(client -> client.setLoggingLevel(McpSchema.LoggingLevel.DEBUG),
"setting logging level");
verifyNotificationSucceedsWithImplicitInitialization(
client -> client.setLoggingLevel(McpSchema.LoggingLevel.DEBUG), "setting logging level");
}
@Test