Module and Package restructoring

- mcp-transport -> mcp-spring
- mcp-webflux-sse-transport -> mcp-spring-webflux
 - pom coordinates changed accordingly
- mpc-webmvc-sse-transport -> mcp-spring-webmvc
 - pom coordinates changed accordingly
- docs contains the mdx version of the documentation
This commit is contained in:
Christian Tzolov 2025-01-29 13:40:48 +01:00
commit 585f30cf33
8 changed files with 51 additions and 133 deletions

View file

@ -0,0 +1,462 @@
/*
* Copyright 2024 - 2024 the original author or authors.
*/
package io.modelcontextprotocol;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport;
import io.modelcontextprotocol.client.transport.WebFluxSseClientTransport;
import io.modelcontextprotocol.server.McpServer;
import io.modelcontextprotocol.server.McpServerFeatures;
import io.modelcontextprotocol.server.transport.WebFluxSseServerTransport;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities;
import io.modelcontextprotocol.spec.McpSchema.CreateMessageRequest;
import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult;
import io.modelcontextprotocol.spec.McpSchema.InitializeResult;
import io.modelcontextprotocol.spec.McpSchema.Role;
import io.modelcontextprotocol.spec.McpSchema.Root;
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import reactor.netty.DisposableServer;
import reactor.netty.http.server.HttpServer;
import reactor.test.StepVerifier;
import org.springframework.http.server.reactive.HttpHandler;
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
import org.springframework.web.client.RestClient;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.server.RouterFunctions;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.awaitility.Awaitility.await;
public class WebFluxSseIntegrationTests {
private static final int PORT = 8182;
private static final String MESSAGE_ENDPOINT = "/mcp/message";
private DisposableServer httpServer;
private WebFluxSseServerTransport mcpServerTransport;
ConcurrentHashMap<String, McpClient.SyncSpec> clientBulders = new ConcurrentHashMap<>();
@BeforeEach
public void before() {
this.mcpServerTransport = new WebFluxSseServerTransport(new ObjectMapper(), MESSAGE_ENDPOINT);
HttpHandler httpHandler = RouterFunctions.toHttpHandler(mcpServerTransport.getRouterFunction());
ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler);
this.httpServer = HttpServer.create().port(PORT).handle(adapter).bindNow();
clientBulders.put("httpclient", McpClient.sync(new HttpClientSseClientTransport("http://localhost:" + PORT)));
clientBulders.put("webflux",
McpClient.sync(new WebFluxSseClientTransport(WebClient.builder().baseUrl("http://localhost:" + PORT))));
}
@AfterEach
public void after() {
if (httpServer != null) {
httpServer.disposeNow();
}
}
// ---------------------------------------
// Sampling Tests
// ---------------------------------------
@Test
void testCreateMessageWithoutInitialization() {
var mcpAsyncServer = McpServer.async(mcpServerTransport).serverInfo("test-server", "1.0.0").build();
var messages = List
.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Test message")));
var modelPrefs = new McpSchema.ModelPreferences(List.of(), 1.0, 1.0, 1.0);
var request = new McpSchema.CreateMessageRequest(messages, modelPrefs, null,
McpSchema.CreateMessageRequest.ContextInclusionStrategy.NONE, null, 100, List.of(), Map.of());
StepVerifier.create(mcpAsyncServer.createMessage(request)).verifyErrorSatisfies(error -> {
assertThat(error).isInstanceOf(McpError.class)
.hasMessage("Client must be initialized. Call the initialize method first!");
});
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient", "webflux" })
void testCreateMessageWithoutSamplingCapabilities(String clientType) {
var mcpAsyncServer = McpServer.async(mcpServerTransport).serverInfo("test-server", "1.0.0").build();
var clientBuilder = clientBulders.get(clientType);
var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")).build();
InitializeResult initResult = client.initialize();
assertThat(initResult).isNotNull();
var messages = List
.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Test message")));
var modelPrefs = new McpSchema.ModelPreferences(List.of(), 1.0, 1.0, 1.0);
var request = new McpSchema.CreateMessageRequest(messages, modelPrefs, null,
McpSchema.CreateMessageRequest.ContextInclusionStrategy.NONE, null, 100, List.of(), Map.of());
StepVerifier.create(mcpAsyncServer.createMessage(request)).verifyErrorSatisfies(error -> {
assertThat(error).isInstanceOf(McpError.class)
.hasMessage("Client must be configured with sampling capabilities");
});
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient", "webflux" })
void testCreateMessageSuccess(String clientType) throws InterruptedException {
var clientBuilder = clientBulders.get(clientType);
var mcpAsyncServer = McpServer.async(mcpServerTransport).serverInfo("test-server", "1.0.0").build();
Function<CreateMessageRequest, CreateMessageResult> samplingHandler = request -> {
assertThat(request.messages()).hasSize(1);
assertThat(request.messages().get(0).content()).isInstanceOf(McpSchema.TextContent.class);
return new CreateMessageResult(Role.USER, new McpSchema.TextContent("Test message"), "MockModelName",
CreateMessageResult.StopReason.STOP_SEQUENCE);
};
var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
.capabilities(ClientCapabilities.builder().sampling().build())
.sampling(samplingHandler)
.build();
InitializeResult initResult = client.initialize();
assertThat(initResult).isNotNull();
var messages = List
.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Test message")));
var modelPrefs = new McpSchema.ModelPreferences(List.of(), 1.0, 1.0, 1.0);
var request = new McpSchema.CreateMessageRequest(messages, modelPrefs, null,
McpSchema.CreateMessageRequest.ContextInclusionStrategy.NONE, null, 100, List.of(), Map.of());
StepVerifier.create(mcpAsyncServer.createMessage(request)).consumeNextWith(result -> {
assertThat(result).isNotNull();
assertThat(result.role()).isEqualTo(Role.USER);
assertThat(result.content()).isInstanceOf(McpSchema.TextContent.class);
assertThat(((McpSchema.TextContent) result.content()).text()).isEqualTo("Test message");
assertThat(result.model()).isEqualTo("MockModelName");
assertThat(result.stopReason()).isEqualTo(CreateMessageResult.StopReason.STOP_SEQUENCE);
}).verifyComplete();
}
// ---------------------------------------
// Roots Tests
// ---------------------------------------
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient", "webflux" })
void testRootsSuccess(String clientType) {
var clientBuilder = clientBulders.get(clientType);
List<Root> roots = List.of(new Root("uri1://", "root1"), new Root("uri2://", "root2"));
AtomicReference<List<Root>> rootsRef = new AtomicReference<>();
var mcpServer = McpServer.sync(mcpServerTransport)
.rootsChangeConsumer(rootsUpdate -> rootsRef.set(rootsUpdate))
.build();
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
.roots(roots)
.build();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
assertThat(rootsRef.get()).isNull();
assertThat(mcpServer.listRoots().roots()).containsAll(roots);
mcpClient.rootsListChangedNotification();
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(roots);
});
// Remove a root
mcpClient.removeRoot(roots.get(0).uri());
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(List.of(roots.get(1)));
});
// Add a new root
var root3 = new Root("uri3://", "root3");
mcpClient.addRoot(root3);
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(List.of(roots.get(1), root3));
});
mcpClient.close();
mcpServer.close();
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient", "webflux" })
void testRootsWithoutCapability(String clientType) {
var clientBuilder = clientBulders.get(clientType);
var mcpServer = McpServer.sync(mcpServerTransport).rootsChangeConsumer(rootsUpdate -> {
}).build();
// Create client without roots capability
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().build()) // No
// roots
// capability
.build();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
// Attempt to list roots should fail
assertThatThrownBy(() -> mcpServer.listRoots().roots()).isInstanceOf(McpError.class)
.hasMessage("Roots not supported");
mcpClient.close();
mcpServer.close();
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient", "webflux" })
void testRootsWithEmptyRootsList(String clientType) {
var clientBuilder = clientBulders.get(clientType);
AtomicReference<List<Root>> rootsRef = new AtomicReference<>();
var mcpServer = McpServer.sync(mcpServerTransport)
.rootsChangeConsumer(rootsUpdate -> rootsRef.set(rootsUpdate))
.build();
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
.roots(List.of()) // Empty roots list
.build();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
mcpClient.rootsListChangedNotification();
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).isEmpty();
});
mcpClient.close();
mcpServer.close();
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient", "webflux" })
void testRootsWithMultipleConsumers(String clientType) {
var clientBuilder = clientBulders.get(clientType);
List<Root> roots = List.of(new Root("uri1://", "root1"));
AtomicReference<List<Root>> rootsRef1 = new AtomicReference<>();
AtomicReference<List<Root>> rootsRef2 = new AtomicReference<>();
var mcpServer = McpServer.sync(mcpServerTransport)
.rootsChangeConsumer(rootsUpdate -> rootsRef1.set(rootsUpdate))
.rootsChangeConsumer(rootsUpdate -> rootsRef2.set(rootsUpdate))
.build();
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
.roots(roots)
.build();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
mcpClient.rootsListChangedNotification();
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef1.get()).containsAll(roots);
assertThat(rootsRef2.get()).containsAll(roots);
});
mcpClient.close();
mcpServer.close();
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient", "webflux" })
void testRootsServerCloseWithActiveSubscription(String clientType) {
var clientBuilder = clientBulders.get(clientType);
List<Root> roots = List.of(new Root("uri1://", "root1"));
AtomicReference<List<Root>> rootsRef = new AtomicReference<>();
var mcpServer = McpServer.sync(mcpServerTransport)
.rootsChangeConsumer(rootsUpdate -> rootsRef.set(rootsUpdate))
.build();
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
.roots(roots)
.build();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
mcpClient.rootsListChangedNotification();
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(roots);
});
// Close server while subscription is active
mcpServer.close();
// Verify client can handle server closure gracefully
mcpClient.close();
}
// ---------------------------------------
// Tools Tests
// ---------------------------------------
String emptyJsonSchema = """
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {}
}
""";
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient", "webflux" })
void testToolCallSuccess(String clientType) {
var clientBuilder = clientBulders.get(clientType);
var callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null);
McpServerFeatures.SyncToolRegistration tool1 = new McpServerFeatures.SyncToolRegistration(
new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), request -> {
// perform a blocking call to a remote service
String response = RestClient.create()
.get()
.uri("https://github.com/modelcontextprotocol/specification/blob/main/README.md")
.retrieve()
.body(String.class);
assertThat(response).isNotBlank();
return callResponse;
});
var mcpServer = McpServer.sync(mcpServerTransport)
.capabilities(ServerCapabilities.builder().tools(true).build())
.tools(tool1)
.build();
var mcpClient = clientBuilder.build();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
assertThat(mcpClient.listTools().tools()).contains(tool1.tool());
CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
assertThat(response).isNotNull();
assertThat(response).isEqualTo(callResponse);
mcpClient.close();
mcpServer.close();
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient", "webflux" })
void testToolListChangeHandlingSuccess(String clientType) {
var clientBuilder = clientBulders.get(clientType);
var callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null);
McpServerFeatures.SyncToolRegistration tool1 = new McpServerFeatures.SyncToolRegistration(
new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), request -> {
// perform a blocking call to a remote service
String response = RestClient.create()
.get()
.uri("https://github.com/modelcontextprotocol/specification/blob/main/README.md")
.retrieve()
.body(String.class);
assertThat(response).isNotBlank();
return callResponse;
});
var mcpServer = McpServer.sync(mcpServerTransport)
.capabilities(ServerCapabilities.builder().tools(true).build())
.tools(tool1)
.build();
AtomicReference<List<Tool>> rootsRef = new AtomicReference<>();
var mcpClient = clientBuilder.toolsChangeConsumer(toolsUpdate -> {
// perform a blocking call to a remote service
String response = RestClient.create()
.get()
.uri("https://github.com/modelcontextprotocol/specification/blob/main/README.md")
.retrieve()
.body(String.class);
assertThat(response).isNotBlank();
rootsRef.set(toolsUpdate);
}).build();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
assertThat(rootsRef.get()).isNull();
assertThat(mcpClient.listTools().tools()).contains(tool1.tool());
mcpServer.notifyToolsListChanged();
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(List.of(tool1.tool()));
});
// Remove a tool
mcpServer.removeTool("tool1");
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).isEmpty();
});
// Add a new tool
McpServerFeatures.SyncToolRegistration tool2 = new McpServerFeatures.SyncToolRegistration(
new McpSchema.Tool("tool2", "tool2 description", emptyJsonSchema), request -> callResponse);
mcpServer.addTool(tool2);
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(List.of(tool2.tool()));
});
mcpClient.close();
mcpServer.close();
}
}

View file

@ -0,0 +1,49 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package io.modelcontextprotocol.client;
import io.modelcontextprotocol.client.transport.WebFluxSseClientTransport;
import io.modelcontextprotocol.spec.ClientMcpTransport;
import org.junit.jupiter.api.Timeout;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.springframework.web.reactive.function.client.WebClient;
/**
* Tests for the {@link McpAsyncClient} with {@link WebFluxSseClientTransport}.
*
* @author Christian Tzolov
*/
@Timeout(15) // Giving extra time beyond the client timeout
class WebFluxSseMcpAsyncClientTests 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:v1")
.withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String()))
.withExposedPorts(3001)
.waitingFor(Wait.forHttp("/").forStatusCode(404));
@Override
protected ClientMcpTransport createMcpTransport() {
return new WebFluxSseClientTransport(WebClient.builder().baseUrl(host));
}
@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,49 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package io.modelcontextprotocol.client;
import io.modelcontextprotocol.client.transport.WebFluxSseClientTransport;
import io.modelcontextprotocol.spec.ClientMcpTransport;
import org.junit.jupiter.api.Timeout;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.springframework.web.reactive.function.client.WebClient;
/**
* Tests for the {@link McpSyncClient} with {@link WebFluxSseClientTransport}.
*
* @author Christian Tzolov
*/
@Timeout(15) // Giving extra time beyond the client timeout
class WebFluxSseMcpSyncClientTests 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:v1")
.withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String()))
.withExposedPorts(3001)
.waitingFor(Wait.forHttp("/").forStatusCode(404));
@Override
protected ClientMcpTransport createMcpTransport() {
return new WebFluxSseClientTransport(WebClient.builder().baseUrl(host));
}
@Override
protected void onStart() {
container.start();
int port = container.getMappedPort(3001);
host = "http://" + container.getHost() + ":" + port;
}
@Override
protected void onClose() {
container.stop();
}
}

View file

@ -0,0 +1,321 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package io.modelcontextprotocol.client.transport;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
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.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.publisher.Sinks;
import reactor.test.StepVerifier;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.reactive.function.client.WebClient;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Tests for the {@link WebFluxSseClientTransport} class.
*
* @author Christian Tzolov
*/
@Timeout(15)
class WebFluxSseClientTransportTests {
static String host = "http://localhost:3001";
@SuppressWarnings("resource")
GenericContainer<?> container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v1")
.withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String()))
.withExposedPorts(3001)
.waitingFor(Wait.forHttp("/").forStatusCode(404));
private TestSseClientTransport transport;
private WebClient.Builder webClientBuilder;
private ObjectMapper objectMapper;
// Test class to access protected methods
static class TestSseClientTransport extends WebFluxSseClientTransport {
private final AtomicInteger inboundMessageCount = new AtomicInteger(0);
private Sinks.Many<ServerSentEvent<String>> events = Sinks.many().unicast().onBackpressureBuffer();
public TestSseClientTransport(WebClient.Builder webClientBuilder, ObjectMapper objectMapper) {
super(webClientBuilder, objectMapper);
}
// @Override
// public Mono<Void> connect(Function<Mono<McpSchema.JSONRPCMessage>,
// Mono<McpSchema.JSONRPCMessage>> handler) {
// simulateEndpointEvent("https://localhost:3001");
// return super.connect(handler);
// }
@Override
protected Flux<ServerSentEvent<String>> eventStream() {
return super.eventStream().mergeWith(events.asFlux());
}
public String getLastEndpoint() {
return messageEndpointSink.asMono().block();
}
public int getInboundMessageCount() {
return inboundMessageCount.get();
}
public void simulateEndpointEvent(String jsonMessage) {
events.tryEmitNext(ServerSentEvent.<String>builder().event("endpoint").data(jsonMessage).build());
inboundMessageCount.incrementAndGet();
}
public void simulateMessageEvent(String jsonMessage) {
events.tryEmitNext(ServerSentEvent.<String>builder().event("message").data(jsonMessage).build());
inboundMessageCount.incrementAndGet();
}
}
void startContainer() {
container.start();
int port = container.getMappedPort(3001);
host = "http://" + container.getHost() + ":" + port;
}
@BeforeEach
void setUp() {
startContainer();
webClientBuilder = WebClient.builder().baseUrl(host);
objectMapper = new ObjectMapper();
transport = new TestSseClientTransport(webClientBuilder, objectMapper);
transport.connect(Function.identity()).block();
}
@AfterEach
void afterEach() {
if (transport != null) {
assertThatCode(() -> transport.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
}
cleanup();
}
void cleanup() {
container.stop();
}
@Test
void testEndpointEventHandling() {
assertThat(transport.getLastEndpoint()).startsWith("/message?");
}
@Test
void constructorValidation() {
assertThatThrownBy(() -> new WebFluxSseClientTransport(null)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("WebClient.Builder must not be null");
assertThatThrownBy(() -> new WebFluxSseClientTransport(webClientBuilder, null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("ObjectMapper must not be null");
}
@Test
void testMessageProcessing() {
// Create a test message
JSONRPCRequest testMessage = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "test-method", "test-id",
Map.of("key", "value"));
// Simulate receiving the message
transport.simulateMessageEvent("""
{
"jsonrpc": "2.0",
"method": "test-method",
"id": "test-id",
"params": {"key": "value"}
}
""");
// Subscribe to messages and verify
StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete();
assertThat(transport.getInboundMessageCount()).isEqualTo(1);
}
@Test
void testResponseMessageProcessing() {
// Simulate receiving a response message
transport.simulateMessageEvent("""
{
"jsonrpc": "2.0",
"id": "test-id",
"result": {"status": "success"}
}
""");
// Create and send a request message
JSONRPCRequest testMessage = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "test-method", "test-id",
Map.of("key", "value"));
// Verify message handling
StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete();
assertThat(transport.getInboundMessageCount()).isEqualTo(1);
}
@Test
void testErrorMessageProcessing() {
// Simulate receiving an error message
transport.simulateMessageEvent("""
{
"jsonrpc": "2.0",
"id": "test-id",
"error": {
"code": -32600,
"message": "Invalid Request"
}
}
""");
// Create and send a request message
JSONRPCRequest testMessage = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "test-method", "test-id",
Map.of("key", "value"));
// Verify message handling
StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete();
assertThat(transport.getInboundMessageCount()).isEqualTo(1);
}
@Test
void testNotificationMessageProcessing() {
// Simulate receiving a notification message (no id)
transport.simulateMessageEvent("""
{
"jsonrpc": "2.0",
"method": "update",
"params": {"status": "processing"}
}
""");
// Verify the notification was processed
assertThat(transport.getInboundMessageCount()).isEqualTo(1);
}
@Test
void testGracefulShutdown() {
// Test graceful shutdown
StepVerifier.create(transport.closeGracefully()).verifyComplete();
// Create a test message
JSONRPCRequest testMessage = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "test-method", "test-id",
Map.of("key", "value"));
// Verify message is not processed after shutdown
StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete();
// Message count should remain 0 after shutdown
assertThat(transport.getInboundMessageCount()).isEqualTo(0);
}
@Test
void testRetryBehavior() {
// Create a WebClient that simulates connection failures
WebClient.Builder failingWebClientBuilder = WebClient.builder().baseUrl("http://non-existent-host");
WebFluxSseClientTransport failingTransport = new WebFluxSseClientTransport(failingWebClientBuilder);
// Verify that the transport attempts to reconnect
StepVerifier.create(Mono.delay(Duration.ofSeconds(2))).expectNextCount(1).verifyComplete();
// Clean up
failingTransport.closeGracefully().block();
}
@Test
void testMultipleMessageProcessing() {
// Simulate receiving multiple messages in sequence
transport.simulateMessageEvent("""
{
"jsonrpc": "2.0",
"method": "method1",
"id": "id1",
"params": {"key": "value1"}
}
""");
transport.simulateMessageEvent("""
{
"jsonrpc": "2.0",
"method": "method2",
"id": "id2",
"params": {"key": "value2"}
}
""");
// Create and send corresponding messages
JSONRPCRequest message1 = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "method1", "id1",
Map.of("key", "value1"));
JSONRPCRequest message2 = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "method2", "id2",
Map.of("key", "value2"));
// Verify both messages are processed
StepVerifier.create(transport.sendMessage(message1).then(transport.sendMessage(message2))).verifyComplete();
// Verify message count
assertThat(transport.getInboundMessageCount()).isEqualTo(2);
}
@Test
void testMessageOrderPreservation() {
// Simulate receiving messages in a specific order
transport.simulateMessageEvent("""
{
"jsonrpc": "2.0",
"method": "first",
"id": "1",
"params": {"sequence": 1}
}
""");
transport.simulateMessageEvent("""
{
"jsonrpc": "2.0",
"method": "second",
"id": "2",
"params": {"sequence": 2}
}
""");
transport.simulateMessageEvent("""
{
"jsonrpc": "2.0",
"method": "third",
"id": "3",
"params": {"sequence": 3}
}
""");
// Verify message count and order
assertThat(transport.getInboundMessageCount()).isEqualTo(3);
}
}

View file

@ -0,0 +1,54 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package io.modelcontextprotocol.server;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.server.transport.WebFluxSseServerTransport;
import io.modelcontextprotocol.spec.ServerMcpTransport;
import org.junit.jupiter.api.Timeout;
import reactor.netty.DisposableServer;
import reactor.netty.http.server.HttpServer;
import org.springframework.http.server.reactive.HttpHandler;
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
import org.springframework.web.reactive.function.server.RouterFunctions;
/**
* Tests for {@link McpAsyncServer} using {@link WebFluxSseServerTransport}.
*
* @author Christian Tzolov
*/
@Timeout(15) // Giving extra time beyond the client timeout
class WebFluxSseMcpAsyncServerTests extends AbstractMcpAsyncServerTests {
private static final int PORT = 8181;
private static final String MESSAGE_ENDPOINT = "/mcp/message";
private DisposableServer httpServer;
@Override
protected ServerMcpTransport createMcpTransport() {
var transport = new WebFluxSseServerTransport(new ObjectMapper(), MESSAGE_ENDPOINT);
HttpHandler httpHandler = RouterFunctions.toHttpHandler(transport.getRouterFunction());
ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler);
httpServer = HttpServer.create().port(PORT).handle(adapter).bindNow();
return transport;
}
@Override
protected void onStart() {
}
@Override
protected void onClose() {
if (httpServer != null) {
httpServer.disposeNow();
}
}
}

View file

@ -0,0 +1,54 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package io.modelcontextprotocol.server;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.server.transport.WebFluxSseServerTransport;
import io.modelcontextprotocol.spec.ServerMcpTransport;
import org.junit.jupiter.api.Timeout;
import reactor.netty.DisposableServer;
import reactor.netty.http.server.HttpServer;
import org.springframework.http.server.reactive.HttpHandler;
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
import org.springframework.web.reactive.function.server.RouterFunctions;
/**
* Tests for {@link McpSyncServer} using {@link WebFluxSseServerTransport}.
*
* @author Christian Tzolov
*/
@Timeout(15) // Giving extra time beyond the client timeout
class WebFluxSseMcpSyncServerTests extends AbstractMcpSyncServerTests {
private static final int PORT = 8182;
private static final String MESSAGE_ENDPOINT = "/mcp/message";
private DisposableServer httpServer;
private WebFluxSseServerTransport transport;
@Override
protected ServerMcpTransport createMcpTransport() {
transport = new WebFluxSseServerTransport(new ObjectMapper(), MESSAGE_ENDPOINT);
return transport;
}
@Override
protected void onStart() {
HttpHandler httpHandler = RouterFunctions.toHttpHandler(transport.getRouterFunction());
ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler);
httpServer = HttpServer.create().port(PORT).handle(adapter).bindNow();
}
@Override
protected void onClose() {
if (httpServer != null) {
httpServer.disposeNow();
}
}
}

View file

@ -0,0 +1,69 @@
/*
* Copyright 2024 - 2024 the original author or authors.
*/
package io.modelcontextprotocol.server.transport;
import java.io.IOException;
import java.io.InputStream;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
public class BlockingInputStream extends InputStream {
private final BlockingQueue<Integer> queue = new LinkedBlockingQueue<>();
private volatile boolean completed = false;
private volatile boolean closed = false;
@Override
public int read() throws IOException {
if (closed) {
throw new IOException("Stream is closed");
}
try {
Integer value = queue.poll();
if (value == null) {
if (completed) {
return -1;
}
value = queue.take(); // Blocks until data is available
if (value == null && completed) {
return -1;
}
}
return value;
}
catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Read interrupted", e);
}
}
public void write(int b) {
if (!closed && !completed) {
queue.offer(b);
}
}
public void write(byte[] data) {
if (!closed && !completed) {
for (byte b : data) {
queue.offer((int) b & 0xFF);
}
}
}
public void complete() {
this.completed = true;
}
@Override
public void close() {
this.closed = true;
this.completed = true;
this.queue.clear();
}
}

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE configuration>
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- Main MCP package -->
<logger name="org.springframework.ai.mcp" level="INFO"/>
<!-- Client packages -->
<logger name="org.springframework.ai.mcp.client" level="INFO"/>
<!-- Spec package -->
<logger name="org.springframework.ai.mcp.spec" level="INFO"/>
<!-- Root logger -->
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>