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:
Christian Tzolov 2025-08-08 21:31:29 +02:00 • committed by GitHub
commit cbfdb14e40
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 792 additions and 4785 deletions

View file

@ -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) {

View file

@ -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) {
}
}

View file

@ -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