This is an automated email from the ASF dual-hosted git repository. rombert pushed a commit to branch issue/integration-test in repository https://gitbox.apache.org/repos/asf/sling-org-apache-sling-mcp-server.git
commit 70f3760889bd35c248bbec12044fbdc16ce0724a Author: Robert Munteanu <[email protected]> AuthorDate: Fri Mar 13 16:39:27 2026 +0100 chore: add an integration test --- pom.xml | 100 ++++++++++++++++- .../org/apache/sling/mcp/server/McpServerIT.java | 124 +++++++++++++++++++++ .../mcp/server/itbundle/ItToolContribution.java | 52 +++++++++ .../mcp/server/itbundle/McpItSupportBundle.java | 81 ++++++++++++++ 4 files changed, 352 insertions(+), 5 deletions(-) diff --git a/pom.xml b/pom.xml index c76aa7b..ee402d7 100644 --- a/pom.xml +++ b/pom.xml @@ -45,6 +45,8 @@ <slingfeature.app.vmOption /> <!-- Directory for legacy variant classes --> <legacy.classes.directory>${project.build.directory}/classes-legacy</legacy.classes.directory> + <!-- Integration test start timeout --> + <it.startTimeoutSeconds>120</it.startTimeoutSeconds> </properties> <dependencies> @@ -174,6 +176,45 @@ <version>3.27.6</version> <scope>test</scope> </dependency> + + <!-- integration test dependencies --> + <dependency> + <groupId>org.apache.sling</groupId> + <artifactId>org.apache.sling.testing.clients</artifactId> + <version>3.1.0</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.ops4j.pax.tinybundles</groupId> + <artifactId>tinybundles</artifactId> + <version>4.0.0</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>biz.aQute.bnd</groupId> + <artifactId>biz.aQute.bndlib</artifactId> + <version>${bnd.version}</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.awaitility</groupId> + <artifactId>awaitility</artifactId> + <version>4.2.2</version> + <scope>test</scope> + </dependency> + <!-- slf4j simple for IT logging --> + <dependency> + <groupId>org.slf4j</groupId> + <artifactId>slf4j-simple</artifactId> + <scope>test</scope> + </dependency> + <!-- McpJsonMapperSupplier SPI implementation (Jackson2) needed by MCP client at test runtime --> + <dependency> + <groupId>io.modelcontextprotocol.sdk</groupId> + <artifactId>mcp-json-jackson2</artifactId> + <version>1.0.0</version> + <scope>test</scope> + </dependency> </dependencies> <build> @@ -272,23 +313,72 @@ <failOnMissing>false</failOnMissing> </configuration> </plugin> - <!-- Added: Feature Launcher plugin for manually starting the aggregated 'app' feature --> + <!-- Reserve a random port for integration tests --> + <plugin> + <groupId>org.codehaus.mojo</groupId> + <artifactId>build-helper-maven-plugin</artifactId> + <executions> + <execution> + <id>reserve-network-port</id> + <goals> + <goal>reserve-network-port</goal> + </goals> + <phase>pre-integration-test</phase> + <configuration> + <portNames> + <portName>http.port</portName> + </portNames> + </configuration> + </execution> + </executions> + </plugin> + <!-- Feature Launcher plugin: manual start via 'mvn feature-launcher:start', and lifecycle-bound IT start/stop --> <plugin> <groupId>org.apache.sling</groupId> <artifactId>feature-launcher-maven-plugin</artifactId> + <version>0.1.8</version> <configuration> <launches> <launch> - <id>app</id> + <id>sling-starter-oak-tar</id> + <skip>${skipITs}</skip> <featureFile>${project.slingfeature.outputDirectory}/feature-app.json</featureFile> + <repositoryUrls> + <repositoryUrl>file://${project.build.directory}/artifacts</repositoryUrl> + </repositoryUrls> <launcherArguments> - <vmOptions> - <vmOption>${slingfeature.app.vmOption}</vmOption> - </vmOptions> + <frameworkProperties> + <org.osgi.service.http.port>${http.port}</org.osgi.service.http.port> + </frameworkProperties> </launcherArguments> + <startTimeoutSeconds>${it.startTimeoutSeconds}</startTimeoutSeconds> </launch> </launches> </configuration> + <executions> + <execution> + <goals> + <goal>start</goal> + <goal>stop</goal> + </goals> + </execution> + </executions> + </plugin> + <plugin> + <artifactId>maven-failsafe-plugin</artifactId> + <configuration> + <systemPropertyVariables> + <sling.http.port>${http.port}</sling.http.port> + </systemPropertyVariables> + </configuration> + <executions> + <execution> + <goals> + <goal>integration-test</goal> + <goal>verify</goal> + </goals> + </execution> + </executions> </plugin> <plugin> <groupId>org.apache.rat</groupId> diff --git a/src/test/java/org/apache/sling/mcp/server/McpServerIT.java b/src/test/java/org/apache/sling/mcp/server/McpServerIT.java new file mode 100644 index 0000000..b3b7b10 --- /dev/null +++ b/src/test/java/org/apache/sling/mcp/server/McpServerIT.java @@ -0,0 +1,124 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.sling.mcp.server; + +import java.net.URI; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Base64; +import java.util.Map; +import java.util.concurrent.TimeoutException; + +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; +import io.modelcontextprotocol.spec.McpSchema; +import org.apache.sling.mcp.server.itbundle.ItToolContribution; +import org.apache.sling.mcp.server.itbundle.McpItSupportBundle; +import org.apache.sling.testing.clients.ClientException; +import org.apache.sling.testing.clients.SlingClient; +import org.apache.sling.testing.clients.osgi.OsgiConsoleClient; +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.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class McpServerIT { + + private static final Duration AWAIT_TIMEOUT = Duration.ofSeconds(30); + private static final Duration AWAIT_POLL_INTERVAL = Duration.ofMillis(500); + + private static int slingPort; + private static McpItSupportBundle supportBundle; + + private SlingClient sling; + private McpSyncClient mcpClient; + + @BeforeAll + static void buildSupportBundle(@TempDir Path tempDir) throws Exception { + slingPort = Integer.getInteger("sling.http.port", 8080); + supportBundle = new McpItSupportBundle(tempDir); + supportBundle.generate(); + } + + @BeforeEach + void setup() throws ClientException, InterruptedException, TimeoutException { + sling = SlingClient.Builder.create(URI.create("http://localhost:" + slingPort), "admin", "admin") + .build(); + + // deploy the IT tool contribution bundle. + supportBundle.install(sling.adaptTo(OsgiConsoleClient.class)); + + // build the MCP sync client with HTTP Basic Auth for admin access. + String basicAuthHeader = "Basic " + Base64.getEncoder().encodeToString("admin:admin".getBytes()); + HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport.builder( + "http://localhost:" + slingPort) + .endpoint("/bin/mcp") + .customizeRequest(rb -> rb.header("Authorization", basicAuthHeader)) + .build(); + + mcpClient = McpClient.sync(transport) + .clientInfo(new McpSchema.Implementation("mcp-server-it", "1.0")) + .requestTimeout(Duration.ofSeconds(30)) + .build(); + mcpClient.initialize(); + } + + @AfterEach + void teardown() { + if (mcpClient != null) { + mcpClient.close(); + } + if (sling != null) { + try { + supportBundle.uninstall(sling.adaptTo(OsgiConsoleClient.class)); + } catch (ClientException e) { + // ignore + } + } + } + + @Test + void toolIsRegisteredAndCanBeInvoked() { + // wait for the tool to be registered + await("it-hello tool appears in tool listing") + .atMost(AWAIT_TIMEOUT) + .pollInterval(AWAIT_POLL_INTERVAL) + .until(() -> mcpClient.listTools().tools().stream() + .anyMatch(t -> ItToolContribution.TOOL_NAME.equals(t.name()))); + + // call the tool and verify the response. + McpSchema.CallToolResult result = + mcpClient.callTool(new McpSchema.CallToolRequest(ItToolContribution.TOOL_NAME, Map.of())); + + assertThat(result.isError()) + .as("tool result should not indicate an error") + .isNotEqualTo(Boolean.TRUE); + assertThat(result.content()) + .as("tool result content") + .hasSize(1) + .first() + .isInstanceOfSatisfying(McpSchema.TextContent.class, tc -> assertThat(tc.text()) + .isEqualTo(ItToolContribution.TOOL_RESPONSE)); + } +} diff --git a/src/test/java/org/apache/sling/mcp/server/itbundle/ItToolContribution.java b/src/test/java/org/apache/sling/mcp/server/itbundle/ItToolContribution.java new file mode 100644 index 0000000..c71878c --- /dev/null +++ b/src/test/java/org/apache/sling/mcp/server/itbundle/ItToolContribution.java @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.sling.mcp.server.itbundle; + +import java.util.List; +import java.util.Map; + +import io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncToolSpecification; +import io.modelcontextprotocol.spec.McpSchema; +import org.apache.sling.mcp.server.spi.McpServerContribution; +import org.osgi.service.component.annotations.Component; + +/** + * A minimal {@link McpServerContribution} deployed via TinyBundles during integration tests. + * Registers a single tool named {@value #TOOL_NAME} that returns a fixed text response. + */ +@Component(service = McpServerContribution.class) +public class ItToolContribution implements McpServerContribution { + + public static final String TOOL_NAME = "it-hello"; + public static final String TOOL_RESPONSE = "Hello from IT"; + + @Override + public List<SyncToolSpecification> getSyncToolSpecification() { + McpSchema.Tool tool = McpSchema.Tool.builder() + .name(TOOL_NAME) + .description("Integration test tool that returns a fixed greeting") + .inputSchema(new McpSchema.JsonSchema("object", Map.of(), null, null, null, null)) + .build(); + + return List.of(new SyncToolSpecification( + tool, + (ctx, req) -> new McpSchema.CallToolResult( + List.of(new McpSchema.TextContent(TOOL_RESPONSE)), Boolean.FALSE, null, null))); + } +} diff --git a/src/test/java/org/apache/sling/mcp/server/itbundle/McpItSupportBundle.java b/src/test/java/org/apache/sling/mcp/server/itbundle/McpItSupportBundle.java new file mode 100644 index 0000000..40bb782 --- /dev/null +++ b/src/test/java/org/apache/sling/mcp/server/itbundle/McpItSupportBundle.java @@ -0,0 +1,81 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.sling.mcp.server.itbundle; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.apache.sling.testing.clients.ClientException; +import org.apache.sling.testing.clients.osgi.OsgiConsoleClient; +import org.ops4j.pax.tinybundles.TinyBundles; +import org.osgi.framework.Constants; + +/** + * Builds and manages the IT support bundle containing {@link ItToolContribution}. + * + * <p>The bundle is assembled at test time using TinyBundles + bnd so it gets correct + * OSGi {@code Import-Package} headers for all MCP SDK and Sling SPI packages.</p> + */ +public class McpItSupportBundle { + + public static final String BUNDLE_SYMBOLIC_NAME = "org.apache.sling.mcp.server.itbundle"; + + private final Path parentDirectory; + private Path bundlePath; + + public McpItSupportBundle(Path parentDirectory) { + this.parentDirectory = parentDirectory; + } + + /** + * Builds the support bundle JAR and returns its path. + */ + public Path generate() throws IOException { + InputStream bundleStream = TinyBundles.bundle() + .setHeader(Constants.BUNDLE_SYMBOLICNAME, BUNDLE_SYMBOLIC_NAME) + .setHeader(Constants.BUNDLE_VERSION, "1.0.0.SNAPSHOT") + .addClass(ItToolContribution.class) + .build(TinyBundles.bndBuilder()); + + bundlePath = parentDirectory.resolve("mcp-it-support-bundle.jar"); + Files.copy(bundleStream, bundlePath); + return bundlePath; + } + + /** + * Installs the support bundle into the running Sling instance and waits until it is active. + */ + public void install(OsgiConsoleClient client) throws ClientException, InterruptedException, TimeoutException { + if (bundlePath == null) { + throw new IllegalStateException("Bundle not generated; call generate() first"); + } + client.waitInstallBundle(bundlePath.toFile(), true, 10, TimeUnit.SECONDS.toMillis(10), 500); + } + + /** + * Uninstalls the support bundle from the running Sling instance. + */ + public void uninstall(OsgiConsoleClient client) throws ClientException { + client.uninstallBundle(BUNDLE_SYMBOLIC_NAME); + } +}
