From 24fbb119a96e771278fa41eacf33bbf293f4b303 Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Mon, 31 Aug 2026 14:50:24 +0200 Subject: [PATCH] Use single Tomcat instance per test class - Instead of running one Tomcat per test method, run one per test - Improves the testing time of Servlet-based tests Signed-off-by: Daniel Garnier-Moiroux --- .../HttpServletSseIntegrationTests.java | 53 +++-- .../HttpServletStatelessIntegrationTests.java | 187 ++++++------------ ...ervletStatelessSharedIntegrationTests.java | 53 +++-- ...HttpServletStreamableIntegrationTests.java | 53 +++-- .../server/McpCompletionTests.java | 63 +++--- .../server/transport/TomcatTestUtil.java | 55 ++++++ 6 files changed, 236 insertions(+), 228 deletions(-) diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java index 7a07220..c4e334f 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java @@ -27,7 +27,9 @@ import jakarta.servlet.http.HttpServletResponse; import org.apache.catalina.LifecycleException; import org.apache.catalina.LifecycleState; import org.apache.catalina.startup.Tomcat; +import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; @@ -44,14 +46,42 @@ class HttpServletSseIntegrationTests extends AbstractMcpClientServerIntegrationT private static final String CUSTOM_MESSAGE_ENDPOINT = "/otherPath/mcp/message"; - private HttpServletSseServerTransportProvider mcpServerTransportProvider; + // Tomcat is started once for the whole class; each test swaps in its own transport + private static final TomcatTestUtil.DelegatingServlet MCP_SERVLET = new TomcatTestUtil.DelegatingServlet(); - private Tomcat tomcat; + private static Tomcat tomcat; + + private HttpServletSseServerTransportProvider mcpServerTransportProvider; static Stream clientsForTesting() { return Stream.of(Arguments.of("httpclient")); } + @BeforeAll + public static void beforeAll() { + tomcat = TomcatTestUtil.createTomcatServer("", PORT, MCP_SERVLET); + try { + tomcat.start(); + assertThat(tomcat.getServer().getState()).isEqualTo(LifecycleState.STARTED); + } + catch (Exception e) { + throw new RuntimeException("Failed to start Tomcat", e); + } + } + + @AfterAll + public static void afterAll() { + if (tomcat != null) { + try { + tomcat.stop(); + tomcat.destroy(); + } + catch (LifecycleException e) { + throw new RuntimeException("Failed to stop Tomcat", e); + } + } + } + @BeforeEach public void before() { // Create and configure the transport provider @@ -61,15 +91,7 @@ class HttpServletSseIntegrationTests extends AbstractMcpClientServerIntegrationT .sseEndpoint(CUSTOM_SSE_ENDPOINT) .maxRequestSize(MAX_REQUEST_SIZE) .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); - } + MCP_SERVLET.setDelegate(mcpServerTransportProvider); clientBuilders .put("httpclient", @@ -93,15 +115,6 @@ class HttpServletSseIntegrationTests extends AbstractMcpClientServerIntegrationT 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 diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java index 6f4ecb5..6111757 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java @@ -16,12 +16,7 @@ import java.util.Map; import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiFunction; import java.util.function.Function; -import java.util.function.Function; -import ch.qos.logback.classic.Level; -import ch.qos.logback.classic.Logger; -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.core.read.ListAppender; import ch.qos.logback.classic.Level; import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.spi.ILoggingEvent; @@ -54,21 +49,17 @@ import net.javacrumbs.jsonunit.core.Option; 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 org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; import org.slf4j.LoggerFactory; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; -import org.slf4j.LoggerFactory; -import reactor.core.publisher.Mono; -import reactor.test.StepVerifier; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.web.client.RestClient; - import static io.modelcontextprotocol.server.transport.HttpServletStatelessServerTransport.APPLICATION_JSON; import static io.modelcontextprotocol.server.transport.HttpServletStatelessServerTransport.TEXT_EVENT_STREAM; import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER; @@ -89,7 +80,9 @@ class HttpServletStatelessIntegrationTests { private static final int MAX_REQUEST_SIZE = 2048; - private HttpServletStatelessServerTransport mcpStatelessServerTransport; + private static Tomcat tomcat; + + private static HttpServletStatelessServerTransport mcpStatelessServerTransport; private final McpClient.SyncSpec clientBuilder = McpClient .sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT) @@ -98,11 +91,9 @@ class HttpServletStatelessIntegrationTests { .initializationTimeout(Duration.ofHours(10)) .requestTimeout(Duration.ofHours(10)); - private Tomcat tomcat; - - @BeforeEach - public void before() { - this.mcpStatelessServerTransport = HttpServletStatelessServerTransport.builder() + @BeforeAll + public static void beforeAll() { + mcpStatelessServerTransport = HttpServletStatelessServerTransport.builder() .messageEndpoint(CUSTOM_MESSAGE_ENDPOINT) .maxRequestSize(MAX_REQUEST_SIZE) .build(); @@ -117,8 +108,8 @@ class HttpServletStatelessIntegrationTests { } } - @AfterEach - public void after() { + @AfterAll + public static void afterAll() { if (mcpStatelessServerTransport != null) { mcpStatelessServerTransport.closeGracefully().block(); } @@ -155,7 +146,7 @@ class HttpServletStatelessIntegrationTests { return callResponse; }); - var mcpServer = McpServer.sync(mcpStatelessServerTransport) + McpServer.sync(mcpStatelessServerTransport) .capabilities(ServerCapabilities.builder().tools(true).build()) .tools(tool1) .build(); @@ -173,22 +164,16 @@ class HttpServletStatelessIntegrationTests { assertThat(response).isNotNull(); assertThat(response).isEqualTo(callResponse); } - finally { - mcpServer.close(); - } } @Test void testInitialize() { - var mcpServer = McpServer.sync(mcpStatelessServerTransport).build(); + McpServer.sync(mcpStatelessServerTransport).build(); try (var mcpClient = clientBuilder.build()) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); } - finally { - mcpServer.close(); - } } // --------------------------------------- @@ -208,7 +193,7 @@ class HttpServletStatelessIntegrationTests { return completionResponse; }; - var mcpServer = McpServer.sync(mcpStatelessServerTransport) + McpServer.sync(mcpStatelessServerTransport) .capabilities(ServerCapabilities.builder().completions().build()) .prompts(new McpStatelessServerFeatures.SyncPromptSpecification(Prompt.builder("code_review") .title("Code review") @@ -241,9 +226,6 @@ class HttpServletStatelessIntegrationTests { assertThat(completeRequest.get().argument().value()).isEqualTo("py"); assertThat(completeRequest.get().ref().type()).isEqualTo(PromptReference.TYPE); } - finally { - mcpServer.close(); - } } @Test @@ -265,7 +247,7 @@ class HttpServletStatelessIntegrationTests { .of(PromptArgument.builder("topic").title("Topic").description("string").required(false).build())) .build(); - var mcpServer = McpServer.sync(mcpStatelessServerTransport) + McpServer.sync(mcpStatelessServerTransport) .capabilities(ServerCapabilities.builder().completions().build()) .prompts( new McpStatelessServerFeatures.SyncPromptSpecification(prompt, @@ -291,9 +273,6 @@ class HttpServletStatelessIntegrationTests { assertThat(result.completion().total()).isZero(); assertThat(result.completion().hasMore()).isFalse(); } - finally { - mcpServer.close(); - } } @Test @@ -313,7 +292,7 @@ class HttpServletStatelessIntegrationTests { .mimeType("text/plain") .build(); - var mcpServer = McpServer.sync(mcpStatelessServerTransport) + McpServer.sync(mcpStatelessServerTransport) .capabilities(ServerCapabilities.builder().completions().build()) .resourceTemplates( new McpStatelessServerFeatures.SyncResourceTemplateSpecification(template, @@ -339,14 +318,11 @@ class HttpServletStatelessIntegrationTests { assertThat(result.completion().total()).isZero(); assertThat(result.completion().hasMore()).isFalse(); } - finally { - mcpServer.close(); - } } @Test void testCompletionForNonExistentPromptReturnsInvalidParams() { - var mcpServer = McpServer.sync(mcpStatelessServerTransport) + McpServer.sync(mcpStatelessServerTransport) .capabilities(ServerCapabilities.builder().completions().build()) .build(); @@ -364,14 +340,11 @@ class HttpServletStatelessIntegrationTests { .extracting(McpSchema.JSONRPCResponse.JSONRPCError::code) .isEqualTo(ErrorCodes.INVALID_PARAMS); } - finally { - mcpServer.close(); - } } @Test void testCompletionForNonExistentResourceReturnsResourceNotFound() { - var mcpServer = McpServer.sync(mcpStatelessServerTransport) + McpServer.sync(mcpStatelessServerTransport) .capabilities(ServerCapabilities.builder().completions().build()) .build(); @@ -390,9 +363,6 @@ class HttpServletStatelessIntegrationTests { .extracting(McpSchema.JSONRPCResponse.JSONRPCError::code) .isEqualTo(McpSchema.ErrorCodes.RESOURCE_NOT_FOUND); } - finally { - mcpServer.close(); - } } // --------------------------------------- @@ -421,7 +391,7 @@ class HttpServletStatelessIntegrationTests { .build(); }); - var mcpServer = McpServer.sync(mcpStatelessServerTransport) + McpServer.sync(mcpStatelessServerTransport) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) .tools(tool) @@ -459,9 +429,6 @@ class HttpServletStatelessIntegrationTests { .isEqualTo(json(""" {"result":5.0,"operation":"2 + 3","timestamp":"2024-01-01T10:00:00Z"}""")); } - finally { - mcpServer.close(); - } } @Test @@ -492,7 +459,7 @@ class HttpServletStatelessIntegrationTests { }) .build(); - var mcpServer = McpServer.sync(mcpStatelessServerTransport) + McpServer.sync(mcpStatelessServerTransport) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) .tools(tool) @@ -517,9 +484,6 @@ class HttpServletStatelessIntegrationTests { {"name":"John","age":30}"""), json(""" {"name":"Peter","age":25}""")); } - finally { - mcpServer.closeGracefully(); - } } @Test @@ -545,7 +509,7 @@ class HttpServletStatelessIntegrationTests { .build()) .build(); - var mcpServer = McpServer.sync(mcpStatelessServerTransport) + McpServer.sync(mcpStatelessServerTransport) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) .tools(tool) @@ -572,9 +536,6 @@ class HttpServletStatelessIntegrationTests { McpSchema.TextContent.builder("Error calling tool: Simulated in-handler error").build()); assertThat(response.structuredContent()).isNull(); } - finally { - mcpServer.closeGracefully(); - } } @Test @@ -599,7 +560,7 @@ class HttpServletStatelessIntegrationTests { .build(); }); - var mcpServer = McpServer.sync(mcpStatelessServerTransport) + McpServer.sync(mcpStatelessServerTransport) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) .tools(tool) @@ -621,9 +582,6 @@ class HttpServletStatelessIntegrationTests { String errorMessage = ((McpSchema.TextContent) response.content().get(0)).text(); assertThat(errorMessage).contains("Validation failed"); } - finally { - mcpServer.close(); - } } @Test @@ -643,7 +601,7 @@ class HttpServletStatelessIntegrationTests { return CallToolResult.builder().addTextContent("Calculation completed").build(); }); - var mcpServer = McpServer.sync(mcpStatelessServerTransport) + McpServer.sync(mcpStatelessServerTransport) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) .instructions("bla") @@ -667,9 +625,6 @@ class HttpServletStatelessIntegrationTests { assertThat(errorMessage).isEqualTo( "Response missing structured content which is expected when calling tool with non-empty outputSchema"); } - finally { - mcpServer.close(); - } } @Test @@ -738,9 +693,6 @@ class HttpServletStatelessIntegrationTests { .isEqualTo(json(""" {"count":3,"message":"Dynamic execution"}""")); } - finally { - mcpServer.close(); - } } @Test @@ -787,13 +739,11 @@ class HttpServletStatelessIntegrationTests { assertThat(jsonrpcResponse.error()).isNotNull(); assertThat(jsonrpcResponse.error().code()).isEqualTo(ErrorCodes.INTERNAL_ERROR); assertThat(jsonrpcResponse.error().message()).isEqualTo("testing"); - - mcpServer.close(); } @Test void testMissingHandlerReturnsMethodNotFoundError() { - var mcpServer = McpServer.sync(mcpStatelessServerTransport) + McpServer.sync(mcpStatelessServerTransport) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().build()) .build(); @@ -828,9 +778,6 @@ class HttpServletStatelessIntegrationTests { assertThat(response.get().error().code()).isEqualTo(McpSchema.ErrorCodes.METHOD_NOT_FOUND); assertThat(response.get().error().message()).isEqualTo("Method not found: foo/bar"); } - finally { - mcpServer.closeGracefully(); - } } @Test @@ -841,7 +788,7 @@ class HttpServletStatelessIntegrationTests { handlerLogger.addAppender(logAppender); try { - var mcpServer = McpServer.sync(mcpStatelessServerTransport) + McpServer.sync(mcpStatelessServerTransport) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().build()) .build(); @@ -849,9 +796,6 @@ class HttpServletStatelessIntegrationTests { try (var mcpClient = clientBuilder.build()) { mcpClient.initialize(); // automatically sends notifications/initialized } - finally { - mcpServer.close(); - } } finally { handlerLogger.detachAppender(logAppender); @@ -869,7 +813,7 @@ class HttpServletStatelessIntegrationTests { handlerLogger.addAppender(logAppender); try { - var mcpServer = McpServer.sync(mcpStatelessServerTransport) + McpServer.sync(mcpStatelessServerTransport) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().build()) .build(); @@ -878,9 +822,7 @@ class HttpServletStatelessIntegrationTests { mcpClient.initialize(); mcpClient.rootsListChangedNotification(); } - finally { - mcpServer.close(); - } + } finally { handlerLogger.detachAppender(logAppender); @@ -913,7 +855,7 @@ class HttpServletStatelessIntegrationTests { .build()) .build(); - var mcpServer = McpServer.sync(mcpStatelessServerTransport) + McpServer.sync(mcpStatelessServerTransport) .capabilities(ServerCapabilities.builder().tools(false).build()) .tools(tool1) .build(); @@ -929,57 +871,48 @@ class HttpServletStatelessIntegrationTests { .isInstanceOf(RuntimeException.class) .hasMessageContaining("413"); } - finally { - mcpServer.closeGracefully(); - } } @Test void rejectsWhenBodyBytesExceedLimitWithoutContentLengthHeader() throws Exception { - var mcpServer = McpServer.sync(mcpStatelessServerTransport).build(); + McpServer.sync(mcpStatelessServerTransport).build(); + var httpClient = HttpClient.newHttpClient(); - try { - var httpClient = HttpClient.newHttpClient(); + // A publisher with unknown content length forces chunked transfer + // encoding, bypassing the Content-Length header check and exercising the + // body byte count + byte[] oversizedBody = "a".repeat(MAX_REQUEST_SIZE + 1).getBytes(StandardCharsets.UTF_8); + HttpRequest.BodyPublisher chunkedPublisher = new HttpRequest.BodyPublisher() { + @Override + public long contentLength() { + return -1; + } - // A publisher with unknown content length forces chunked transfer - // encoding, bypassing the Content-Length header check and exercising the - // body byte count - byte[] oversizedBody = "a".repeat(MAX_REQUEST_SIZE + 1).getBytes(StandardCharsets.UTF_8); - HttpRequest.BodyPublisher chunkedPublisher = new HttpRequest.BodyPublisher() { - @Override - public long contentLength() { - return -1; - } + @Override + public void subscribe(java.util.concurrent.Flow.Subscriber subscriber) { + subscriber.onSubscribe(new java.util.concurrent.Flow.Subscription() { + @Override + public void request(long n) { + subscriber.onNext(ByteBuffer.wrap(oversizedBody)); + subscriber.onComplete(); + } - @Override - public void subscribe(java.util.concurrent.Flow.Subscriber subscriber) { - subscriber.onSubscribe(new java.util.concurrent.Flow.Subscription() { - @Override - public void request(long n) { - subscriber.onNext(ByteBuffer.wrap(oversizedBody)); - subscriber.onComplete(); - } + @Override + public void cancel() { + } + }); + } + }; - @Override - public void cancel() { - } - }); - } - }; + var request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + PORT + CUSTOM_MESSAGE_ENDPOINT)) + .header("Content-Type", "application/json") + .header("Accept", APPLICATION_JSON + ", " + TEXT_EVENT_STREAM) + .POST(chunkedPublisher) + .build(); - var request = HttpRequest.newBuilder() - .uri(URI.create("http://localhost:" + PORT + CUSTOM_MESSAGE_ENDPOINT)) - .header("Content-Type", "application/json") - .header("Accept", APPLICATION_JSON + ", " + TEXT_EVENT_STREAM) - .POST(chunkedPublisher) - .build(); - - var response = httpClient.send(request, HttpResponse.BodyHandlers.discarding()); - assertThat(response.statusCode()).isEqualTo(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE); - } - finally { - mcpServer.closeGracefully(); - } + var response = httpClient.send(request, HttpResponse.BodyHandlers.discarding()); + assertThat(response.statusCode()).isEqualTo(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE); } private double evaluateExpression(String expression) { diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessSharedIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessSharedIntegrationTests.java index 0751f69..452eac0 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessSharedIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessSharedIntegrationTests.java @@ -20,7 +20,9 @@ import jakarta.servlet.http.HttpServletRequest; import org.apache.catalina.LifecycleException; import org.apache.catalina.LifecycleState; import org.apache.catalina.startup.Tomcat; +import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Timeout; import org.junit.jupiter.params.provider.Arguments; @@ -40,29 +42,49 @@ class HttpServletStatelessSharedIntegrationTests extends AbstractStatelessIntegr static McpTransportContextExtractor TEST_CONTEXT_EXTRACTOR = (request) -> McpTransportContext .create(Map.of("important", "value")); - private HttpServletStatelessServerTransport mcpServerTransport; + // Tomcat is started once for the whole class; each test swaps in its own transport + private static final TomcatTestUtil.DelegatingServlet MCP_SERVLET = new TomcatTestUtil.DelegatingServlet(); - private Tomcat tomcat; + private static Tomcat tomcat; + + private HttpServletStatelessServerTransport mcpServerTransport; static Stream clientsForTesting() { return Stream.of(Arguments.of("httpclient")); } + @BeforeAll + public static void beforeAll() { + tomcat = TomcatTestUtil.createTomcatServer("", PORT, MCP_SERVLET); + try { + tomcat.start(); + assertThat(tomcat.getServer().getState()).isEqualTo(LifecycleState.STARTED); + } + catch (Exception e) { + throw new RuntimeException("Failed to start Tomcat", e); + } + } + + @AfterAll + public static void afterAll() { + if (tomcat != null) { + try { + tomcat.stop(); + tomcat.destroy(); + } + catch (LifecycleException e) { + throw new RuntimeException("Failed to stop Tomcat", e); + } + } + } + @BeforeEach public void before() { this.mcpServerTransport = HttpServletStatelessServerTransport.builder() .contextExtractor(TEST_CONTEXT_EXTRACTOR) .messageEndpoint(MESSAGE_ENDPOINT) .build(); - - this.tomcat = TomcatTestUtil.createTomcatServer("", PORT, this.mcpServerTransport); - try { - this.tomcat.start(); - assertThat(this.tomcat.getServer().getState()).isEqualTo(LifecycleState.STARTED); - } - catch (Exception e) { - throw new RuntimeException("Failed to start Tomcat", e); - } + MCP_SERVLET.setDelegate(this.mcpServerTransport); prepareClients(PORT, MESSAGE_ENDPOINT); } @@ -90,15 +112,6 @@ class HttpServletStatelessSharedIntegrationTests extends AbstractStatelessIntegr if (this.mcpServerTransport != null) { this.mcpServerTransport.closeGracefully().block(); } - if (this.tomcat != null) { - try { - this.tomcat.stop(); - this.tomcat.destroy(); - } - catch (LifecycleException e) { - throw new RuntimeException("Failed to stop Tomcat", e); - } - } } } diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java index 281f3e0..5bf0b51 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java @@ -31,7 +31,9 @@ import org.apache.catalina.LifecycleException; import org.apache.catalina.LifecycleState; import org.apache.catalina.startup.Tomcat; import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; @@ -48,14 +50,42 @@ class HttpServletStreamableIntegrationTests extends AbstractMcpClientServerInteg private static final String MESSAGE_ENDPOINT = "/mcp/message"; - private HttpServletStreamableServerTransportProvider mcpServerTransportProvider; + // Tomcat is started once for the whole class; each test swaps in its own transport + private static final TomcatTestUtil.DelegatingServlet MCP_SERVLET = new TomcatTestUtil.DelegatingServlet(); - private Tomcat tomcat; + private static Tomcat tomcat; + + private HttpServletStreamableServerTransportProvider mcpServerTransportProvider; static Stream clientsForTesting() { return Stream.of(Arguments.of("httpclient")); } + @BeforeAll + public static void beforeAll() { + tomcat = TomcatTestUtil.createTomcatServer("", PORT, MCP_SERVLET); + try { + tomcat.start(); + assertThat(tomcat.getServer().getState()).isEqualTo(LifecycleState.STARTED); + } + catch (Exception e) { + throw new RuntimeException("Failed to start Tomcat", e); + } + } + + @AfterAll + public static void afterAll() { + if (tomcat != null) { + try { + tomcat.stop(); + tomcat.destroy(); + } + catch (LifecycleException e) { + throw new RuntimeException("Failed to stop Tomcat", e); + } + } + } + @BeforeEach public void before() { // Create and configure the transport provider @@ -65,15 +95,7 @@ class HttpServletStreamableIntegrationTests extends AbstractMcpClientServerInteg .keepAliveInterval(Duration.ofSeconds(1)) .maxRequestSize(MAX_REQUEST_SIZE) .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); - } + MCP_SERVLET.setDelegate(mcpServerTransportProvider); clientBuilders .put("httpclient", @@ -97,15 +119,6 @@ class HttpServletStreamableIntegrationTests extends AbstractMcpClientServerInteg 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); - } - } } @Test diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java index 482085e..377d56c 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java @@ -13,8 +13,8 @@ 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 org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import io.modelcontextprotocol.client.McpClient; @@ -48,19 +48,20 @@ import static org.assertj.core.api.InstanceOfAssertFactories.type; */ class McpCompletionTests { - private HttpServletSseServerTransportProvider mcpServerTransportProvider; - private static final int PORT = TomcatTestUtil.findAvailablePort(); private static final String CUSTOM_MESSAGE_ENDPOINT = "/otherPath/mcp/message"; - McpClient.SyncSpec clientBuilder; + private static HttpServletSseServerTransportProvider mcpServerTransportProvider; - private Tomcat tomcat; + private static Tomcat tomcat; - @BeforeEach - public void before() { - // Create and con figure the transport provider + final McpClient.SyncSpec clientBuilder = McpClient + .sync(HttpClientSseClientTransport.builder("http://localhost:" + PORT).build()); + + @BeforeAll + public static void beforeAll() { + // Create and configure the transport provider mcpServerTransportProvider = HttpServletSseServerTransportProvider.builder() .messageEndpoint(CUSTOM_MESSAGE_ENDPOINT) .build(); @@ -73,12 +74,10 @@ class McpCompletionTests { catch (Exception e) { throw new RuntimeException("Failed to start Tomcat", e); } - - this.clientBuilder = McpClient.sync(HttpClientSseClientTransport.builder("http://localhost:" + PORT).build()); } - @AfterEach - public void after() { + @AfterAll + public static void afterAll() { if (mcpServerTransportProvider != null) { mcpServerTransportProvider.closeGracefully().block(); } @@ -88,7 +87,7 @@ class McpCompletionTests { tomcat.destroy(); } catch (LifecycleException e) { - e.printStackTrace(); + throw new RuntimeException("Failed to stop Tomcat", e); } } } @@ -109,7 +108,7 @@ class McpCompletionTests { .size(123L) .build(); - var mcpServer = McpServer.sync(mcpServerTransportProvider) + McpServer.sync(mcpServerTransportProvider) .capabilities(ServerCapabilities.builder().completions().build()) .resources(new McpServerFeatures.SyncResourceSpecification(resource, (exchange, req) -> ReadResourceResult.builder(List.of()).build())) @@ -135,8 +134,6 @@ class McpCompletionTests { assertThat(receivedRequest.get().context().arguments()).containsEntry("previous", "value"); assertThat(result.completion().values()).containsExactly("test-completion"); } - - mcpServer.close(); } @Test @@ -153,7 +150,7 @@ class McpCompletionTests { .arguments(List.of(PromptArgument.builder("arg").description("string").required(false).build())) .build(); - var mcpServer = McpServer.sync(mcpServerTransportProvider) + McpServer.sync(mcpServerTransportProvider) .capabilities(ServerCapabilities.builder().completions().build()) .prompts(new McpServerFeatures.SyncPromptSpecification(prompt, (mcpSyncServerExchange, getPromptRequest) -> null)) @@ -178,8 +175,6 @@ class McpCompletionTests { assertThat(contextWasNull.get()).isTrue(); assertThat(result.completion().values()).containsExactly("no-context-completion"); } - - mcpServer.close(); } @Test @@ -197,7 +192,7 @@ class McpCompletionTests { .arguments(List.of(PromptArgument.builder("topic").description("string").required(false).build())) .build(); - var mcpServer = McpServer.sync(mcpServerTransportProvider) + McpServer.sync(mcpServerTransportProvider) .capabilities(ServerCapabilities.builder().completions().build()) .prompts( new McpServerFeatures.SyncPromptSpecification(prompt, @@ -224,8 +219,6 @@ class McpCompletionTests { assertThat(result.completion().total()).isZero(); assertThat(result.completion().hasMore()).isFalse(); } - - mcpServer.close(); } @Test @@ -243,7 +236,7 @@ class McpCompletionTests { .mimeType("text/plain") .build(); - var mcpServer = McpServer.sync(mcpServerTransportProvider) + McpServer.sync(mcpServerTransportProvider) .capabilities(ServerCapabilities.builder().completions().build()) .resourceTemplates( new McpServerFeatures.SyncResourceTemplateSpecification(template, @@ -271,13 +264,11 @@ class McpCompletionTests { assertThat(result.completion().total()).isZero(); assertThat(result.completion().hasMore()).isFalse(); } - - mcpServer.close(); } @Test void testCompletionForNonExistentPromptReturnsInvalidParams() { - var mcpServer = McpServer.sync(mcpServerTransportProvider) + McpServer.sync(mcpServerTransportProvider) .capabilities(ServerCapabilities.builder().completions().build()) .build(); @@ -297,13 +288,11 @@ class McpCompletionTests { .extracting(McpSchema.JSONRPCResponse.JSONRPCError::code) .isEqualTo(ErrorCodes.INVALID_PARAMS); } - - mcpServer.close(); } @Test void testCompletionForNonExistentResourceReturnsResourceNotFound() { - var mcpServer = McpServer.sync(mcpServerTransportProvider) + McpServer.sync(mcpServerTransportProvider) .capabilities(ServerCapabilities.builder().completions().build()) .build(); @@ -324,8 +313,6 @@ class McpCompletionTests { .extracting(McpSchema.JSONRPCResponse.JSONRPCError::code) .isEqualTo(McpSchema.ErrorCodes.RESOURCE_NOT_FOUND); } - - mcpServer.close(); } @Test @@ -364,7 +351,7 @@ class McpCompletionTests { .size(456L) .build(); - var mcpServer = McpServer.sync(mcpServerTransportProvider) + McpServer.sync(mcpServerTransportProvider) .capabilities(ServerCapabilities.builder().completions().build()) .resources(new McpServerFeatures.SyncResourceSpecification(resource, (exchange, req) -> ReadResourceResult.builder(List.of()).build())) @@ -407,8 +394,6 @@ class McpCompletionTests { CompleteResult tableResult2 = mcpClient.completeCompletion(tableRequest2); assertThat(tableResult2.completion().values()).containsExactly("products", "categories", "inventory"); } - - mcpServer.close(); } @Test @@ -443,7 +428,7 @@ class McpCompletionTests { .size(456L) .build(); - var mcpServer = McpServer.sync(mcpServerTransportProvider) + McpServer.sync(mcpServerTransportProvider) .capabilities(ServerCapabilities.builder().completions().build()) .resources(new McpServerFeatures.SyncResourceSpecification(resource, (exchange, req) -> ReadResourceResult.builder(List.of()).build())) @@ -477,8 +462,6 @@ class McpCompletionTests { CompleteResult resultWithContext = mcpClient.completeCompletion(requestWithContext); assertThat(resultWithContext.completion().values()).containsExactly("users", "orders", "products"); } - - mcpServer.close(); } @Test @@ -488,7 +471,7 @@ class McpCompletionTests { McpSchema.Prompt prompt = Prompt.builder("test-prompt").description("this is a test prompt").build(); - var mcpServer = McpServer.sync(mcpServerTransportProvider) + McpServer.sync(mcpServerTransportProvider) .capabilities(ServerCapabilities.builder().completions().build()) .prompts(new McpServerFeatures.SyncPromptSpecification(prompt, (mcpSyncServerExchange, getPromptRequest) -> null)) @@ -510,8 +493,6 @@ class McpCompletionTests { CompleteResult completeResult = mcpClient.completeCompletion(request); assertThat(completeResult.completion().values()).isEmpty(); } - - mcpServer.close(); } } diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/TomcatTestUtil.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/TomcatTestUtil.java index 490e298..b7fca66 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/TomcatTestUtil.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/TomcatTestUtil.java @@ -10,6 +10,10 @@ import java.net.ServerSocket; import jakarta.servlet.Filter; import jakarta.servlet.Servlet; +import jakarta.servlet.ServletConfig; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; import org.apache.catalina.Context; import org.apache.catalina.startup.Tomcat; import org.apache.tomcat.util.descriptor.web.FilterDef; @@ -63,6 +67,57 @@ public class TomcatTestUtil { return tomcat; } + /** + * A servlet forwarding all requests to a delegate that can be swapped between tests. + * Register one with {@link #createTomcatServer} to start Tomcat once per test class + * while still handing each test a freshly built transport. + */ + public static class DelegatingServlet implements Servlet { + + private volatile ServletConfig servletConfig; + + private volatile Servlet delegate; + + /** + * Sets the servlet handling subsequent requests. The delegate is not + * {@link Servlet#init(ServletConfig) initialized}, since the MCP servlet + * transports do not rely on their {@link ServletConfig}. + */ + public void setDelegate(Servlet delegate) { + this.delegate = delegate; + } + + @Override + public void init(ServletConfig config) { + this.servletConfig = config; + } + + @Override + public ServletConfig getServletConfig() { + return this.servletConfig; + } + + @Override + public void service(ServletRequest request, ServletResponse response) throws ServletException, IOException { + var current = this.delegate; + if (current == null) { + throw new IllegalStateException("No delegate servlet has been set"); + } + current.service(request, response); + } + + @Override + public String getServletInfo() { + return DelegatingServlet.class.getSimpleName(); + } + + @Override + public void destroy() { + this.delegate = null; + } + + } + /** * Finds an available port on the local machine. * @return an available port number