refactor: extract common integration test logic into abstract base classes (#473)
refactor: extract common integration test logic into abstract base classes - Move duplicated test methods from WebFlux and WebMvc integration test classes to abstract base classes - WebFluxSseIntegrationTests, WebFluxStreamableIntegrationTests now extend AbstractMcpClientServerIntegrationTests - WebFluxStatelessIntegrationTests, WebMvcStatelessIntegrationTests now extend AbstractStatelessIntegrationTests - Each concrete test class now only implements transport-specific setup methods (prepareClients, prepareAsyncServerBuilder, prepareSyncServerBuilder) - Eliminates ~1300+ lines of duplicated test code across multiple files - Improves maintainability by centralizing test logic in reusable base classes - Updates WebMvcSseServerTransportProvider to use builder pattern - Adds new HttpServletSseIntegrationTests extending AbstractMcpClientServerIntegrationTests - Removes HttpServletSseServerTransportProviderIntegrationTests - Standardizes timeout configurations and client setup across all integration tests Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
This commit is contained in:
parent
1edd1b64db
commit
cbfdb14e40
4 changed files with 792 additions and 4785 deletions
|
|
@ -19,10 +19,13 @@ import java.time.Duration;
|
|||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
|
@ -32,6 +35,8 @@ 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.CompleteRequest;
|
||||
import io.modelcontextprotocol.spec.McpSchema.CompleteResult;
|
||||
import io.modelcontextprotocol.spec.McpSchema.CreateMessageRequest;
|
||||
import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult;
|
||||
import io.modelcontextprotocol.spec.McpSchema.ElicitRequest;
|
||||
|
|
@ -737,7 +742,6 @@ public abstract class AbstractMcpClientServerIntegrationTests {
|
|||
// ---------------------------------------
|
||||
// Tools Tests
|
||||
// ---------------------------------------
|
||||
|
||||
String emptyJsonSchema = """
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
|
|
@ -941,6 +945,276 @@ public abstract class AbstractMcpClientServerIntegrationTests {
|
|||
mcpServer.close();
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Logging Tests
|
||||
// ---------------------------------------
|
||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||
@ValueSource(strings = { "httpclient" })
|
||||
void testLoggingNotification(String clientType) throws InterruptedException {
|
||||
int expectedNotificationsCount = 3;
|
||||
CountDownLatch latch = new CountDownLatch(expectedNotificationsCount);
|
||||
// Create a list to store received logging notifications
|
||||
List<McpSchema.LoggingMessageNotification> receivedNotifications = new CopyOnWriteArrayList<>();
|
||||
|
||||
var clientBuilder = clientBuilders.get(clientType);
|
||||
;
|
||||
// Create server with a tool that sends logging notifications
|
||||
McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
|
||||
.tool(Tool.builder()
|
||||
.name("logging-test")
|
||||
.description("Test logging notifications")
|
||||
.inputSchema(emptyJsonSchema)
|
||||
.build())
|
||||
.callHandler((exchange, request) -> {
|
||||
|
||||
// Create and send notifications with different levels
|
||||
|
||||
//@formatter:off
|
||||
return exchange // This should be filtered out (DEBUG < NOTICE)
|
||||
.loggingNotification(McpSchema.LoggingMessageNotification.builder()
|
||||
.level(McpSchema.LoggingLevel.DEBUG)
|
||||
.logger("test-logger")
|
||||
.data("Debug message")
|
||||
.build())
|
||||
.then(exchange // This should be sent (NOTICE >= NOTICE)
|
||||
.loggingNotification(McpSchema.LoggingMessageNotification.builder()
|
||||
.level(McpSchema.LoggingLevel.NOTICE)
|
||||
.logger("test-logger")
|
||||
.data("Notice message")
|
||||
.build()))
|
||||
.then(exchange // This should be sent (ERROR > NOTICE)
|
||||
.loggingNotification(McpSchema.LoggingMessageNotification.builder()
|
||||
.level(McpSchema.LoggingLevel.ERROR)
|
||||
.logger("test-logger")
|
||||
.data("Error message")
|
||||
.build()))
|
||||
.then(exchange // This should be filtered out (INFO < NOTICE)
|
||||
.loggingNotification(McpSchema.LoggingMessageNotification.builder()
|
||||
.level(McpSchema.LoggingLevel.INFO)
|
||||
.logger("test-logger")
|
||||
.data("Another info message")
|
||||
.build()))
|
||||
.then(exchange // This should be sent (ERROR >= NOTICE)
|
||||
.loggingNotification(McpSchema.LoggingMessageNotification.builder()
|
||||
.level(McpSchema.LoggingLevel.ERROR)
|
||||
.logger("test-logger")
|
||||
.data("Another error message")
|
||||
.build()))
|
||||
.thenReturn(new CallToolResult("Logging test completed", false));
|
||||
//@formatter:on
|
||||
})
|
||||
.build();
|
||||
|
||||
var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().logging().tools(true).build())
|
||||
.tools(tool)
|
||||
.build();
|
||||
|
||||
try (
|
||||
// Create client with logging notification handler
|
||||
var mcpClient = clientBuilder.loggingConsumer(notification -> {
|
||||
receivedNotifications.add(notification);
|
||||
latch.countDown();
|
||||
}).build()) {
|
||||
|
||||
// Initialize client
|
||||
InitializeResult initResult = mcpClient.initialize();
|
||||
assertThat(initResult).isNotNull();
|
||||
|
||||
// Set minimum logging level to NOTICE
|
||||
mcpClient.setLoggingLevel(McpSchema.LoggingLevel.NOTICE);
|
||||
|
||||
// Call the tool that sends logging notifications
|
||||
CallToolResult result = mcpClient.callTool(new McpSchema.CallToolRequest("logging-test", Map.of()));
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
|
||||
assertThat(((McpSchema.TextContent) result.content().get(0)).text()).isEqualTo("Logging test completed");
|
||||
|
||||
assertThat(latch.await(5, TimeUnit.SECONDS)).as("Should receive notifications in reasonable time").isTrue();
|
||||
|
||||
// Should have received 3 notifications (1 NOTICE and 2 ERROR)
|
||||
assertThat(receivedNotifications).hasSize(expectedNotificationsCount);
|
||||
|
||||
Map<String, McpSchema.LoggingMessageNotification> notificationMap = receivedNotifications.stream()
|
||||
.collect(Collectors.toMap(n -> n.data(), n -> n));
|
||||
|
||||
// First notification should be NOTICE level
|
||||
assertThat(notificationMap.get("Notice message").level()).isEqualTo(McpSchema.LoggingLevel.NOTICE);
|
||||
assertThat(notificationMap.get("Notice message").logger()).isEqualTo("test-logger");
|
||||
assertThat(notificationMap.get("Notice message").data()).isEqualTo("Notice message");
|
||||
|
||||
// Second notification should be ERROR level
|
||||
assertThat(notificationMap.get("Error message").level()).isEqualTo(McpSchema.LoggingLevel.ERROR);
|
||||
assertThat(notificationMap.get("Error message").logger()).isEqualTo("test-logger");
|
||||
assertThat(notificationMap.get("Error message").data()).isEqualTo("Error message");
|
||||
|
||||
// Third notification should be ERROR level
|
||||
assertThat(notificationMap.get("Another error message").level()).isEqualTo(McpSchema.LoggingLevel.ERROR);
|
||||
assertThat(notificationMap.get("Another error message").logger()).isEqualTo("test-logger");
|
||||
assertThat(notificationMap.get("Another error message").data()).isEqualTo("Another error message");
|
||||
}
|
||||
mcpServer.close();
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Progress Tests
|
||||
// ---------------------------------------
|
||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||
@ValueSource(strings = { "httpclient" })
|
||||
void testProgressNotification(String clientType) throws InterruptedException {
|
||||
int expectedNotificationsCount = 4; // 3 notifications + 1 for another progress
|
||||
// token
|
||||
CountDownLatch latch = new CountDownLatch(expectedNotificationsCount);
|
||||
// Create a list to store received logging notifications
|
||||
List<McpSchema.ProgressNotification> receivedNotifications = new CopyOnWriteArrayList<>();
|
||||
|
||||
var clientBuilder = clientBuilders.get(clientType);
|
||||
|
||||
// Create server with a tool that sends logging notifications
|
||||
McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
|
||||
.tool(McpSchema.Tool.builder()
|
||||
.name("progress-test")
|
||||
.description("Test progress notifications")
|
||||
.inputSchema(emptyJsonSchema)
|
||||
.build())
|
||||
.callHandler((exchange, request) -> {
|
||||
|
||||
// Create and send notifications
|
||||
var progressToken = (String) request.meta().get("progressToken");
|
||||
|
||||
return exchange
|
||||
.progressNotification(
|
||||
new McpSchema.ProgressNotification(progressToken, 0.0, 1.0, "Processing started"))
|
||||
.then(exchange.progressNotification(
|
||||
new McpSchema.ProgressNotification(progressToken, 0.5, 1.0, "Processing data")))
|
||||
.then(// Send a progress notification with another progress value
|
||||
// should
|
||||
exchange.progressNotification(new McpSchema.ProgressNotification("another-progress-token",
|
||||
0.0, 1.0, "Another processing started")))
|
||||
.then(exchange.progressNotification(
|
||||
new McpSchema.ProgressNotification(progressToken, 1.0, 1.0, "Processing completed")))
|
||||
.thenReturn(new CallToolResult(("Progress test completed"), false));
|
||||
})
|
||||
.build();
|
||||
|
||||
var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.tools(tool)
|
||||
.build();
|
||||
|
||||
try (
|
||||
// Create client with progress notification handler
|
||||
var mcpClient = clientBuilder.progressConsumer(notification -> {
|
||||
receivedNotifications.add(notification);
|
||||
latch.countDown();
|
||||
}).build()) {
|
||||
|
||||
// Initialize client
|
||||
InitializeResult initResult = mcpClient.initialize();
|
||||
assertThat(initResult).isNotNull();
|
||||
|
||||
// Call the tool that sends progress notifications
|
||||
McpSchema.CallToolRequest callToolRequest = McpSchema.CallToolRequest.builder()
|
||||
.name("progress-test")
|
||||
.meta(Map.of("progressToken", "test-progress-token"))
|
||||
.build();
|
||||
CallToolResult result = mcpClient.callTool(callToolRequest);
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
|
||||
assertThat(((McpSchema.TextContent) result.content().get(0)).text()).isEqualTo("Progress test completed");
|
||||
|
||||
assertThat(latch.await(5, TimeUnit.SECONDS)).as("Should receive notifications in reasonable time").isTrue();
|
||||
|
||||
// Should have received 3 notifications
|
||||
assertThat(receivedNotifications).hasSize(expectedNotificationsCount);
|
||||
|
||||
Map<String, McpSchema.ProgressNotification> notificationMap = receivedNotifications.stream()
|
||||
.collect(Collectors.toMap(n -> n.message(), n -> n));
|
||||
|
||||
// First notification should be 0.0/1.0 progress
|
||||
assertThat(notificationMap.get("Processing started").progressToken()).isEqualTo("test-progress-token");
|
||||
assertThat(notificationMap.get("Processing started").progress()).isEqualTo(0.0);
|
||||
assertThat(notificationMap.get("Processing started").total()).isEqualTo(1.0);
|
||||
assertThat(notificationMap.get("Processing started").message()).isEqualTo("Processing started");
|
||||
|
||||
// Second notification should be 0.5/1.0 progress
|
||||
assertThat(notificationMap.get("Processing data").progressToken()).isEqualTo("test-progress-token");
|
||||
assertThat(notificationMap.get("Processing data").progress()).isEqualTo(0.5);
|
||||
assertThat(notificationMap.get("Processing data").total()).isEqualTo(1.0);
|
||||
assertThat(notificationMap.get("Processing data").message()).isEqualTo("Processing data");
|
||||
|
||||
// Third notification should be another progress token with 0.0/1.0 progress
|
||||
assertThat(notificationMap.get("Another processing started").progressToken())
|
||||
.isEqualTo("another-progress-token");
|
||||
assertThat(notificationMap.get("Another processing started").progress()).isEqualTo(0.0);
|
||||
assertThat(notificationMap.get("Another processing started").total()).isEqualTo(1.0);
|
||||
assertThat(notificationMap.get("Another processing started").message())
|
||||
.isEqualTo("Another processing started");
|
||||
|
||||
// Fourth notification should be 1.0/1.0 progress
|
||||
assertThat(notificationMap.get("Processing completed").progressToken()).isEqualTo("test-progress-token");
|
||||
assertThat(notificationMap.get("Processing completed").progress()).isEqualTo(1.0);
|
||||
assertThat(notificationMap.get("Processing completed").total()).isEqualTo(1.0);
|
||||
assertThat(notificationMap.get("Processing completed").message()).isEqualTo("Processing completed");
|
||||
}
|
||||
finally {
|
||||
mcpServer.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Completion Tests
|
||||
// ---------------------------------------
|
||||
@ParameterizedTest(name = "{0} : Completion call")
|
||||
@ValueSource(strings = { "httpclient" })
|
||||
void testCompletionShouldReturnExpectedSuggestions(String clientType) {
|
||||
var clientBuilder = clientBuilders.get(clientType);
|
||||
|
||||
var expectedValues = List.of("python", "pytorch", "pyside");
|
||||
var completionResponse = new McpSchema.CompleteResult(new CompleteResult.CompleteCompletion(expectedValues, 10, // total
|
||||
true // hasMore
|
||||
));
|
||||
|
||||
AtomicReference<CompleteRequest> samplingRequest = new AtomicReference<>();
|
||||
BiFunction<McpSyncServerExchange, CompleteRequest, CompleteResult> completionHandler = (mcpSyncServerExchange,
|
||||
request) -> {
|
||||
samplingRequest.set(request);
|
||||
return completionResponse;
|
||||
};
|
||||
|
||||
var mcpServer = prepareSyncServerBuilder().capabilities(ServerCapabilities.builder().completions().build())
|
||||
.prompts(new McpServerFeatures.SyncPromptSpecification(
|
||||
new McpSchema.Prompt("code_review", "Code review", "this is code review prompt",
|
||||
List.of(new McpSchema.PromptArgument("language", "Language", "string", false))),
|
||||
(mcpSyncServerExchange, getPromptRequest) -> null))
|
||||
.completions(new McpServerFeatures.SyncCompletionSpecification(
|
||||
new McpSchema.PromptReference("ref/prompt", "code_review", "Code review"), completionHandler))
|
||||
.build();
|
||||
|
||||
try (var mcpClient = clientBuilder.build()) {
|
||||
|
||||
InitializeResult initResult = mcpClient.initialize();
|
||||
assertThat(initResult).isNotNull();
|
||||
|
||||
CompleteRequest request = new CompleteRequest(
|
||||
new McpSchema.PromptReference("ref/prompt", "code_review", "Code review"),
|
||||
new CompleteRequest.CompleteArgument("language", "py"));
|
||||
|
||||
CompleteResult result = mcpClient.completeCompletion(request);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
|
||||
assertThat(samplingRequest.get().argument().name()).isEqualTo("language");
|
||||
assertThat(samplingRequest.get().argument().value()).isEqualTo("py");
|
||||
assertThat(samplingRequest.get().ref().type()).isEqualTo("ref/prompt");
|
||||
}
|
||||
|
||||
mcpServer.close();
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Ping Tests
|
||||
// ---------------------------------------
|
||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||
@ValueSource(strings = { "httpclient" })
|
||||
void testPingSuccess(String clientType) {
|
||||
|
|
@ -1003,7 +1277,6 @@ public abstract class AbstractMcpClientServerIntegrationTests {
|
|||
// ---------------------------------------
|
||||
// Tool Structured Output Schema Tests
|
||||
// ---------------------------------------
|
||||
|
||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||
@ValueSource(strings = { "httpclient" })
|
||||
void testStructuredOutputValidationSuccess(String clientType) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
* Copyright 2024 - 2024 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.server;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import org.apache.catalina.LifecycleException;
|
||||
import org.apache.catalina.LifecycleState;
|
||||
import org.apache.catalina.startup.Tomcat;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import io.modelcontextprotocol.client.McpClient;
|
||||
import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport;
|
||||
import io.modelcontextprotocol.server.McpServer.AsyncSpecification;
|
||||
import io.modelcontextprotocol.server.McpServer.SyncSpecification;
|
||||
import io.modelcontextprotocol.server.transport.HttpServletSseServerTransportProvider;
|
||||
import io.modelcontextprotocol.server.transport.TomcatTestUtil;
|
||||
|
||||
class HttpServletSseIntegrationTests extends AbstractMcpClientServerIntegrationTests {
|
||||
|
||||
private static final int PORT = TomcatTestUtil.findAvailablePort();
|
||||
|
||||
private static final String CUSTOM_SSE_ENDPOINT = "/somePath/sse";
|
||||
|
||||
private static final String CUSTOM_MESSAGE_ENDPOINT = "/otherPath/mcp/message";
|
||||
|
||||
private HttpServletSseServerTransportProvider mcpServerTransportProvider;
|
||||
|
||||
private Tomcat tomcat;
|
||||
|
||||
@BeforeEach
|
||||
public void before() {
|
||||
// Create and configure the transport provider
|
||||
mcpServerTransportProvider = HttpServletSseServerTransportProvider.builder()
|
||||
.objectMapper(new ObjectMapper())
|
||||
.messageEndpoint(CUSTOM_MESSAGE_ENDPOINT)
|
||||
.sseEndpoint(CUSTOM_SSE_ENDPOINT)
|
||||
.build();
|
||||
|
||||
tomcat = TomcatTestUtil.createTomcatServer("", PORT, mcpServerTransportProvider);
|
||||
try {
|
||||
tomcat.start();
|
||||
assertThat(tomcat.getServer().getState()).isEqualTo(LifecycleState.STARTED);
|
||||
}
|
||||
catch (Exception e) {
|
||||
throw new RuntimeException("Failed to start Tomcat", e);
|
||||
}
|
||||
|
||||
clientBuilders
|
||||
.put("httpclient",
|
||||
McpClient.sync(HttpClientSseClientTransport.builder("http://localhost:" + PORT)
|
||||
.sseEndpoint(CUSTOM_SSE_ENDPOINT)
|
||||
.build()).requestTimeout(Duration.ofHours(10)));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected AsyncSpecification<?> prepareAsyncServerBuilder() {
|
||||
return McpServer.async(this.mcpServerTransportProvider);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected SyncSpecification<?> prepareSyncServerBuilder() {
|
||||
return McpServer.sync(this.mcpServerTransportProvider);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
public void after() {
|
||||
if (mcpServerTransportProvider != null) {
|
||||
mcpServerTransportProvider.closeGracefully().block();
|
||||
}
|
||||
if (tomcat != null) {
|
||||
try {
|
||||
tomcat.stop();
|
||||
tomcat.destroy();
|
||||
}
|
||||
catch (LifecycleException e) {
|
||||
throw new RuntimeException("Failed to stop Tomcat", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void prepareClients(int port, String mcpEndpoint) {
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -55,7 +55,7 @@ class HttpServletStreamableIntegrationTests extends AbstractMcpClientServerInteg
|
|||
.put("httpclient",
|
||||
McpClient.sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT)
|
||||
.endpoint(MESSAGE_ENDPOINT)
|
||||
.build()).initializationTimeout(Duration.ofHours(10)).requestTimeout(Duration.ofHours(10)));
|
||||
.build()).requestTimeout(Duration.ofHours(10)));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue