From 67da267a082a5f0fde3c6decad524dcc137d858b Mon Sep 17 00:00:00 2001 From: adamw7 Date: Mon, 1 Jun 2026 19:54:04 +0200 Subject: [PATCH 1/4] feat(mcp): add streamable HTTP transport support Add configurable transport mode to the MCP server, supporting both stdio (default) and streamable-http transports. Refactor Main to resolve transport mode from --transport.mode= CLI arg, and extract shared tool registration into McpConfiguration.registerTools(). Co-Authored-By: Claude Sonnet 4.6 --- .../tools/data/uniqueness/mcp/Main.java | 17 +++- .../data/uniqueness/mcp/McpConfiguration.java | 80 +++++++++++++------ .../uniqueness/mcp/McpConfigurationTest.java | 24 ++++++ 3 files changed, 95 insertions(+), 26 deletions(-) diff --git a/data/src/main/java/io/github/adamw7/tools/data/uniqueness/mcp/Main.java b/data/src/main/java/io/github/adamw7/tools/data/uniqueness/mcp/Main.java index b5ff0beb..53b44fc5 100644 --- a/data/src/main/java/io/github/adamw7/tools/data/uniqueness/mcp/Main.java +++ b/data/src/main/java/io/github/adamw7/tools/data/uniqueness/mcp/Main.java @@ -7,10 +7,23 @@ public class Main { public static void main(String[] args) { - System.setProperty("transport.mode", "stdio"); - System.setProperty("spring.main.web-application-type", "none"); + String transportMode = resolveTransportMode(args); + System.setProperty("transport.mode", transportMode); + if ("stdio".equals(transportMode)) { + System.setProperty("spring.main.web-application-type", "none"); + } System.setProperty("banner-mode", "off"); SpringApplication.run(Main.class, args); } + private static String resolveTransportMode(String[] args) { + String prefix = "--transport.mode="; + for (String arg : args) { + if (arg.startsWith(prefix)) { + return arg.substring(prefix.length()); + } + } + return "stdio"; + } + } diff --git a/data/src/main/java/io/github/adamw7/tools/data/uniqueness/mcp/McpConfiguration.java b/data/src/main/java/io/github/adamw7/tools/data/uniqueness/mcp/McpConfiguration.java index 12559be6..9edfad84 100644 --- a/data/src/main/java/io/github/adamw7/tools/data/uniqueness/mcp/McpConfiguration.java +++ b/data/src/main/java/io/github/adamw7/tools/data/uniqueness/mcp/McpConfiguration.java @@ -1,60 +1,92 @@ package io.github.adamw7.tools.data.uniqueness.mcp; import io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification; -import io.modelcontextprotocol.server.McpSyncServerExchange; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.web.servlet.ServletRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import io.modelcontextprotocol.server.McpServer; import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider; import io.modelcontextprotocol.server.transport.StdioServerTransportProvider; import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpServerTransportProvider; - -import java.util.function.BiFunction; +import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider; @Configuration public class McpConfiguration { - private final static Logger log = LogManager.getLogger(McpConfiguration.class.getName()); + private static final Logger log = LogManager.getLogger(McpConfiguration.class.getName()); @Bean public ObjectMapper objectMapper() { return new ObjectMapper(); } - @Bean - @ConditionalOnProperty(prefix = "transport", name = "mode", havingValue = "stdio", matchIfMissing = true) - public StdioServerTransportProvider stdioServerTransport() { - log.info("Creating StdioServerTransport"); - return new StdioServerTransportProvider(new JacksonMcpJsonMapper(objectMapper())); - } + @Bean + @ConditionalOnProperty(prefix = "transport", name = "mode", havingValue = "stdio", matchIfMissing = true) + public StdioServerTransportProvider stdioServerTransport() { + log.info("Creating StdioServerTransport"); + return new StdioServerTransportProvider(new JacksonMcpJsonMapper(objectMapper())); + } + + @Bean + @ConditionalOnProperty(prefix = "transport", name = "mode", havingValue = "streamable-http") + public HttpServletStreamableServerTransportProvider streamableServerTransport() { + log.info("Creating HttpServletStreamableServerTransport"); + return HttpServletStreamableServerTransportProvider.builder() + .jsonMapper(new JacksonMcpJsonMapper(objectMapper())) + .mcpEndpoint("/mcp") + .build(); + } + + @Bean + @ConditionalOnProperty(prefix = "transport", name = "mode", havingValue = "streamable-http") + public ServletRegistrationBean streamableServletRegistration( + HttpServletStreamableServerTransportProvider transport) { + ServletRegistrationBean registration = + new ServletRegistrationBean<>(transport, "/mcp"); + registration.setAsyncSupported(true); + return registration; + } @Bean(destroyMethod = "close") + @ConditionalOnBean(McpServerTransportProvider.class) public McpSyncServer mcpSyncServer(McpServerTransportProvider transport) { log.info("Initializing McpSyncServer with transport: {}", transport); + McpSyncServer syncServer = McpServer.sync(transport) + .serverInfo("custom-server", "0.0.1") + .capabilities(McpSchema.ServerCapabilities.builder().tools(true).resources(false, false).prompts(false).build()) + .build(); + registerTools(syncServer); + return syncServer; + } - McpSyncServer syncServer = McpServer.sync(transport).serverInfo("custom-server", "0.0.1").capabilities( - McpSchema.ServerCapabilities.builder().tools(true).resources(false, false).prompts(false).build()) + @Bean(destroyMethod = "close") + @ConditionalOnProperty(prefix = "transport", name = "mode", havingValue = "streamable-http") + public McpSyncServer mcpSyncServerStreamable(McpStreamableServerTransportProvider transport) { + log.info("Initializing McpSyncServer with streamable transport: {}", transport); + McpSyncServer syncServer = McpServer.sync(transport) + .serverInfo("custom-server", "0.0.1") + .capabilities(McpSchema.ServerCapabilities.builder().tools(true).resources(false, false).prompts(false).build()) .build(); + registerTools(syncServer); + return syncServer; + } + private void registerTools(McpSyncServer server) { UniquenessTool uniquenessTool = new UniquenessTool(); - - SyncToolSpecification.Builder tool = SyncToolSpecification.builder().tool(uniquenessTool.getToolDefinition()); - tool.callHandler(new BiFunction() { - @Override - public McpSchema.CallToolResult apply(McpSyncServerExchange mcpSyncServerExchange, McpSchema.CallToolRequest callToolRequest) { - return uniquenessTool.apply(callToolRequest.arguments()); - } - }); - syncServer.addTool(tool.build()); - - return syncServer; + SyncToolSpecification toolSpec = SyncToolSpecification.builder() + .tool(uniquenessTool.getToolDefinition()) + .callHandler((exchange, request) -> uniquenessTool.apply(request.arguments())) + .build(); + server.addTool(toolSpec); } -} \ No newline at end of file +} diff --git a/data/src/test/java/io/github/adamw7/tools/data/uniqueness/mcp/McpConfigurationTest.java b/data/src/test/java/io/github/adamw7/tools/data/uniqueness/mcp/McpConfigurationTest.java index 4ca8b264..e2e340e4 100644 --- a/data/src/test/java/io/github/adamw7/tools/data/uniqueness/mcp/McpConfigurationTest.java +++ b/data/src/test/java/io/github/adamw7/tools/data/uniqueness/mcp/McpConfigurationTest.java @@ -1,12 +1,14 @@ package io.github.adamw7.tools.data.uniqueness.mcp; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import org.junit.jupiter.api.Test; import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper; import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider; import io.modelcontextprotocol.server.transport.StdioServerTransportProvider; public class McpConfigurationTest { @@ -25,4 +27,26 @@ public void stdioTransportIsNotNull() { McpConfiguration config = new McpConfiguration(); assertFalse(config.stdioServerTransport() == null); } + + @Test + public void streamableTransportIsNotNull() { + McpConfiguration config = new McpConfiguration(); + assertNotNull(config.streamableServerTransport()); + } + + @Test + public void streamableServletRegistrationIsNotNull() { + McpConfiguration config = new McpConfiguration(); + HttpServletStreamableServerTransportProvider transport = config.streamableServerTransport(); + assertNotNull(config.streamableServletRegistration(transport)); + } + + @Test + public void mcpSyncServerStreamableHasTools() { + McpConfiguration config = new McpConfiguration(); + HttpServletStreamableServerTransportProvider transport = config.streamableServerTransport(); + McpSyncServer server = config.mcpSyncServerStreamable(transport); + assertNotNull(server.getServerCapabilities().tools()); + server.close(); + } } From 1186841dde6131a343e778aaf4c043518a096c45 Mon Sep 17 00:00:00 2001 From: adamw7 Date: Tue, 2 Jun 2026 10:17:20 +0200 Subject: [PATCH 2/4] test(mcp): add streamable HTTP integration test with separate CI workflow Add McpStreamableHttpIT that starts the MCP server via @SpringBootTest with streamable-http transport on a random port, creates an HttpClientStreamableHttpTransport client, and verifies the uniqueness_check tool returns correct results over HTTP. The test is excluded from mvn install and only runs when the integration-tests Maven profile is activated. A dedicated GitHub Actions workflow (.github/workflows/integration-tests.yml) runs mvn verify -P integration-tests on push and PRs. Also adds maven-failsafe-plugin to root pom pluginManagement. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/integration-tests.yml | 22 ++++++ data/pom.xml | 24 +++++++ .../uniqueness/mcp/McpStreamableHttpIT.java | 72 +++++++++++++++++++ pom.xml | 5 ++ 4 files changed, 123 insertions(+) create mode 100644 .github/workflows/integration-tests.yml create mode 100644 data/src/test/java/io/github/adamw7/tools/data/uniqueness/mcp/McpStreamableHttpIT.java diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 00000000..cccaedd9 --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,22 @@ +name: MCP Integration Tests + +on: + push: + pull_request: + branches: [ "main" ] + +jobs: + integration-tests: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + - name: Set up JDK 25 + uses: actions/setup-java@v5 + with: + java-version: '25' + distribution: 'temurin' + cache: maven + - name: Run MCP streamable HTTP integration tests + run: mvn -B verify -P integration-tests --file pom.xml diff --git a/data/pom.xml b/data/pom.xml index fc253af1..3a32beff 100644 --- a/data/pom.xml +++ b/data/pom.xml @@ -41,6 +41,30 @@ + + + integration-tests + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + + integration-test + verify + + + + + --add-reads datamodule=ALL-UNNAMED + + + + + + diff --git a/data/src/test/java/io/github/adamw7/tools/data/uniqueness/mcp/McpStreamableHttpIT.java b/data/src/test/java/io/github/adamw7/tools/data/uniqueness/mcp/McpStreamableHttpIT.java new file mode 100644 index 00000000..2bf0c079 --- /dev/null +++ b/data/src/test/java/io/github/adamw7/tools/data/uniqueness/mcp/McpStreamableHttpIT.java @@ -0,0 +1,72 @@ +package io.github.adamw7.tools.data.uniqueness.mcp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import java.util.Map; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; + +import io.github.adamw7.tools.data.Utils; +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; +import io.modelcontextprotocol.spec.McpSchema; + +@SpringBootTest( + classes = Main.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { "transport.mode=streamable-http", "spring.main.banner-mode=off" }) +public class McpStreamableHttpIT { + + @LocalServerPort + private int port; + + private McpSyncClient client; + + @BeforeEach + void setUp() { + HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport + .builder("http://localhost:" + port) + .build(); + client = McpClient.sync(transport) + .clientInfo(McpSchema.Implementation.builder("integration-test-client", "1.0").build()) + .build(); + client.initialize(); + } + + @AfterEach + void tearDown() { + client.close(); + } + + @Test + void nonUniqueColumnReturnsFalse() { + McpSchema.CallToolRequest request = McpSchema.CallToolRequest.builder("uniqueness_check") + .arguments(Map.of("file", Utils.getHouseholdFile(), "columns_row", 1, "columns_name", "year1")) + .build(); + + McpSchema.CallToolResult result = client.callTool(request); + + assertFalse(result.isError()); + McpSchema.TextContent content = (McpSchema.TextContent) result.content().getFirst(); + assertEquals("false", content.text()); + } + + @Test + void uniqueColumnReturnsTrue() { + McpSchema.CallToolRequest request = McpSchema.CallToolRequest.builder("uniqueness_check") + .arguments(Map.of("file", Utils.getHouseholdFile(), "columns_row", 1, "columns_name", "income")) + .build(); + + McpSchema.CallToolResult result = client.callTool(request); + + assertFalse(result.isError()); + McpSchema.TextContent content = (McpSchema.TextContent) result.content().getFirst(); + assertEquals("true", content.text()); + } +} diff --git a/pom.xml b/pom.xml index f260848d..1fd1e7e9 100644 --- a/pom.xml +++ b/pom.xml @@ -155,6 +155,11 @@ maven-surefire-plugin 3.5.6 + + org.apache.maven.plugins + maven-failsafe-plugin + 3.5.6 + org.apache.maven.plugins maven-plugin-plugin From 7ac156d2e06ae7c30dcf8e406fbfae0cdca016c8 Mon Sep 17 00:00:00 2001 From: adamw7 Date: Tue, 2 Jun 2026 10:20:39 +0200 Subject: [PATCH 3/4] ci(integration-tests): run nightly at midnight only Replace push/PR triggers with a scheduled cron trigger to reduce CI load and run integration tests once per day. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/integration-tests.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index cccaedd9..74dfc7f1 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -1,9 +1,8 @@ name: MCP Integration Tests on: - push: - pull_request: - branches: [ "main" ] + schedule: + - cron: '0 0 * * *' jobs: integration-tests: From 7800ef8ebd4d78812c3321bad25258e2ce62cac7 Mon Sep 17 00:00:00 2001 From: adamw7 Date: Tue, 2 Jun 2026 10:38:41 +0200 Subject: [PATCH 4/4] fix(uniqueness): close data source when duplicate is found NoMemoryUniquenessCheck.exec returned early on the non-unique path without closing the data source, leaking the open scanner/result set. Close it before returning, matching the successful-check path. Add a test that wraps the source in a close-tracking spy and asserts it is closed when a duplicate is detected. Co-Authored-By: Claude Opus 4.8 --- .../uniqueness/NoMemoryUniquenessCheck.java | 3 +- .../data/uniqueness/UniquenessCheckTest.java | 58 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/data/src/main/java/io/github/adamw7/tools/data/uniqueness/NoMemoryUniquenessCheck.java b/data/src/main/java/io/github/adamw7/tools/data/uniqueness/NoMemoryUniquenessCheck.java index 4c4da699..effc3e85 100644 --- a/data/src/main/java/io/github/adamw7/tools/data/uniqueness/NoMemoryUniquenessCheck.java +++ b/data/src/main/java/io/github/adamw7/tools/data/uniqueness/NoMemoryUniquenessCheck.java @@ -23,10 +23,11 @@ public Result exec(String... keyCandidates) { while (dataSource.hasMoreData()) { String[] row = dataSource.nextRow(); if (finder.found(row)) { + close(dataSource); return new Result(false, keyCandidates, row); } } - + Result result = handleSuccessfulCheck(keyCandidates); close(dataSource); return result; diff --git a/data/src/test/java/io/github/adamw7/tools/data/uniqueness/UniquenessCheckTest.java b/data/src/test/java/io/github/adamw7/tools/data/uniqueness/UniquenessCheckTest.java index b2274024..a5c86177 100644 --- a/data/src/test/java/io/github/adamw7/tools/data/uniqueness/UniquenessCheckTest.java +++ b/data/src/test/java/io/github/adamw7/tools/data/uniqueness/UniquenessCheckTest.java @@ -136,6 +136,64 @@ void negativeDuplicatesInInputArray(Class uniquenessClass, I assertEquals("Duplicate in input: year1", thrown.getMessage()); } + @Test + public void notUniquePathClosesDataSource() throws Exception { + ClosingSpyDataSource source = new ClosingSpyDataSource( + Utils.createDataSource(Utils.getHouseholdFile(), COLUMNS_ROW)); + NoMemoryUniquenessCheck uniqueness = new NoMemoryUniquenessCheck(); + uniqueness.setDataSource(source); + + Result result = uniqueness.exec(NOT_UNIQUE_COLUMNS); + + assertFalse(result.isUnique()); + assertTrue(source.isClosed(), "Data source must be closed when a duplicate is found"); + } + + private static final class ClosingSpyDataSource implements IterableDataSource { + + private final IterableDataSource delegate; + private boolean closed = false; + + ClosingSpyDataSource(IterableDataSource delegate) { + this.delegate = delegate; + } + + boolean isClosed() { + return closed; + } + + @Override + public void close() throws java.io.IOException { + closed = true; + delegate.close(); + } + + @Override + public String[] getColumnNames() { + return delegate.getColumnNames(); + } + + @Override + public void open() { + delegate.open(); + } + + @Override + public String[] nextRow() { + return delegate.nextRow(); + } + + @Override + public boolean hasMoreData() { + return delegate.hasMoreData(); + } + + @Override + public void reset() { + delegate.reset(); + } + } + @Test public void negativeInMemorySourceVsNoMemoryCheck() { AbstractUniqueness uniqueness = new InMemoryUniquenessCheck();