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
7 changed files with 1501 additions and 242 deletions

View file

@ -0,0 +1,17 @@
package io.modelcontextprotocol.client;
import com.fasterxml.jackson.databind.ObjectMapper;
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;
@Timeout(15)
public class WebClientStreamableHttpAsyncClientResiliencyTests extends AbstractMcpAsyncClientResiliencyTests {
@Override
protected McpClientTransport createMcpTransport() {
return WebClientStreamableHttpTransport.builder(WebClient.builder().baseUrl(host)).build();
}
}

View file

@ -0,0 +1,42 @@
package io.modelcontextprotocol.client;
import com.fasterxml.jackson.databind.ObjectMapper;
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.testcontainers.images.builder.ImageFromDockerfile;
@Timeout(15)
public class WebClientStreamableHttpAsyncClientTests extends AbstractMcpAsyncClientTests {
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 WebClientStreamableHttpTransport.builder(WebClient.builder().baseUrl(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,41 @@
package io.modelcontextprotocol.client;
import com.fasterxml.jackson.databind.ObjectMapper;
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;
@Timeout(15)
public class WebClientStreamableHttpSyncClientTests 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 WebClientStreamableHttpTransport.builder(WebClient.builder().baseUrl(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

@ -26,7 +26,8 @@ class WebFluxSseMcpAsyncClientTests extends AbstractMcpAsyncClientTests {
// Uses the https://github.com/tzolov/mcp-everything-server-docker-image
@SuppressWarnings("resource")
GenericContainer<?> container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v1")
GenericContainer<?> container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v2")
.withCommand("node dist/index.js sse")
.withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String()))
.withExposedPorts(3001)
.waitingFor(Wait.forHttp("/").forStatusCode(404));

View file

@ -26,7 +26,8 @@ class WebFluxSseMcpSyncClientTests extends AbstractMcpSyncClientTests {
// Uses the https://github.com/tzolov/mcp-everything-server-docker-image
@SuppressWarnings("resource")
GenericContainer<?> container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v1")
GenericContainer<?> container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v2")
.withCommand("node dist/index.js sse")
.withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String()))
.withExposedPorts(3001)
.waitingFor(Wait.forHttp("/").forStatusCode(404));

View file

@ -41,7 +41,8 @@ class WebFluxSseClientTransportTests {
static String host = "http://localhost:3001";
@SuppressWarnings("resource")
GenericContainer<?> container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v1")
GenericContainer<?> container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v2")
.withCommand("node dist/index.js sse")
.withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String()))
.withExposedPorts(3001)
.waitingFor(Wait.forHttp("/").forStatusCode(404));

View file

@ -9,13 +9,13 @@
</appender>
<!-- Main MCP package -->
<logger name="org.springframework.ai.mcp" level="INFO"/>
<logger name="io.modelcontextprotocol" level="INFO"/>
<!-- Client packages -->
<logger name="org.springframework.ai.mcp.client" level="INFO"/>
<logger name="io.modelcontextprotocol.client" level="INFO"/>
<!-- Spec package -->
<logger name="org.springframework.ai.mcp.spec" level="INFO"/>
<logger name="io.modelcontextprotocol.spec" level="INFO"/>
<!-- Root logger -->