Skip to content

Commit 7b4700a

Browse files
sberyozkinjmartisk
authored andcommitted
Support MicroProfile Health Checks for MCP Clients
1 parent 0358aa3 commit 7b4700a

File tree

13 files changed

+572
-5
lines changed

13 files changed

+572
-5
lines changed

integration-tests/pom.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
<module>easy-rag</module>
3535
<module>tools</module>
3636
<module>mcp</module>
37+
<module>secure-mcp</module>
3738
</modules>
3839

3940
<profiles>
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
3+
<modelVersion>4.0.0</modelVersion>
4+
<parent>
5+
<groupId>io.quarkiverse.langchain4j</groupId>
6+
<artifactId>quarkus-langchain4j-integration-tests-parent</artifactId>
7+
<version>999-SNAPSHOT</version>
8+
</parent>
9+
<artifactId>quarkus-langchain4j-integration-test-secure-mcp</artifactId>
10+
<name>Quarkus LangChain4j - Integration Tests - Secure MCP</name>
11+
12+
<dependencies>
13+
<dependency>
14+
<groupId>io.quarkus</groupId>
15+
<artifactId>quarkus-arc-deployment</artifactId>
16+
</dependency>
17+
<dependency>
18+
<groupId>io.quarkus</groupId>
19+
<artifactId>quarkus-smallrye-health</artifactId>
20+
<scope>test</scope>
21+
</dependency>
22+
<dependency>
23+
<groupId>io.quarkus</groupId>
24+
<artifactId>quarkus-smallrye-jwt-build</artifactId>
25+
<scope>test</scope>
26+
</dependency>
27+
<dependency>
28+
<groupId>io.quarkiverse.langchain4j</groupId>
29+
<artifactId>quarkus-langchain4j-mcp</artifactId>
30+
</dependency>
31+
<dependency>
32+
<groupId>io.quarkus</groupId>
33+
<artifactId>quarkus-junit5-internal</artifactId>
34+
<scope>test</scope>
35+
</dependency>
36+
<dependency>
37+
<groupId>io.rest-assured</groupId>
38+
<artifactId>rest-assured</artifactId>
39+
<scope>test</scope>
40+
</dependency>
41+
<dependency>
42+
<groupId>org.assertj</groupId>
43+
<artifactId>assertj-core</artifactId>
44+
<scope>test</scope>
45+
</dependency>
46+
47+
<!-- Make sure the deployment artifact is built before executing this module -->
48+
<dependency>
49+
<groupId>io.quarkiverse.langchain4j</groupId>
50+
<artifactId>quarkus-langchain4j-mcp-deployment</artifactId>
51+
<version>999-SNAPSHOT</version>
52+
<type>pom</type>
53+
<scope>test</scope>
54+
<exclusions>
55+
<exclusion>
56+
<groupId>*</groupId>
57+
<artifactId>*</artifactId>
58+
</exclusion>
59+
</exclusions>
60+
</dependency>
61+
</dependencies>
62+
<build>
63+
<plugins>
64+
<plugin>
65+
<groupId>io.quarkus</groupId>
66+
<artifactId>quarkus-maven-plugin</artifactId>
67+
<executions>
68+
<execution>
69+
<goals>
70+
<goal>build</goal>
71+
</goals>
72+
</execution>
73+
</executions>
74+
</plugin>
75+
<plugin>
76+
<artifactId>maven-failsafe-plugin</artifactId>
77+
<executions>
78+
<execution>
79+
<goals>
80+
<goal>integration-test</goal>
81+
<goal>verify</goal>
82+
</goals>
83+
<configuration>
84+
<systemPropertyVariables>
85+
<native.image.path>${project.build.directory}/${project.build.finalName}-runner</native.image.path>
86+
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
87+
<maven.home>${maven.home}</maven.home>
88+
</systemPropertyVariables>
89+
</configuration>
90+
</execution>
91+
</executions>
92+
</plugin>
93+
</plugins>
94+
</build>
95+
<profiles>
96+
<profile>
97+
<id>native-image</id>
98+
<activation>
99+
<property>
100+
<name>native</name>
101+
</property>
102+
</activation>
103+
<build>
104+
<plugins>
105+
<plugin>
106+
<artifactId>maven-surefire-plugin</artifactId>
107+
<configuration>
108+
<skipTests>${native.surefire.skip}</skipTests>
109+
</configuration>
110+
</plugin>
111+
</plugins>
112+
</build>
113+
<properties>
114+
<skipITs>false</skipITs>
115+
<quarkus.package.type>native</quarkus.package.type>
116+
</properties>
117+
</profile>
118+
</profiles>
119+
</project>
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
package io.quarkiverse.langchain4j.mcp.test;
2+
3+
import static org.junit.jupiter.api.Assumptions.assumeTrue;
4+
5+
import java.io.BufferedInputStream;
6+
import java.io.File;
7+
import java.io.IOException;
8+
import java.io.InputStream;
9+
import java.nio.file.Path;
10+
import java.util.ArrayList;
11+
import java.util.Arrays;
12+
import java.util.List;
13+
import java.util.concurrent.TimeUnit;
14+
import java.util.concurrent.TimeoutException;
15+
16+
import org.apache.commons.io.FileUtils;
17+
import org.assertj.core.util.Files;
18+
import org.slf4j.Logger;
19+
import org.slf4j.LoggerFactory;
20+
21+
import io.vertx.core.Vertx;
22+
import io.vertx.core.buffer.Buffer;
23+
import io.vertx.ext.web.client.HttpResponse;
24+
import io.vertx.ext.web.client.WebClient;
25+
26+
public class McpServerHelper {
27+
28+
private static final Logger log = LoggerFactory.getLogger(McpServerHelper.class);
29+
30+
public static Process startServerHttp(String scriptName) throws Exception {
31+
return startServerHttp(scriptName, 8082);
32+
}
33+
34+
public static Process startServerHttp(String scriptName, int port) throws Exception {
35+
return startServerHttp(scriptName, port, port + 1000, new String[] {});
36+
}
37+
38+
public static Process startServerHttp(String scriptName, int port, int sslPort, String[] extraArgs) throws Exception {
39+
skipTestsIfJbangNotAvailable();
40+
String path = getPathToScript(scriptName);
41+
List<String> command = new ArrayList<>();
42+
command.add(getJBangCommand());
43+
command.add("--quiet");
44+
command.add("--fresh");
45+
command.addAll(Arrays.asList(extraArgs));
46+
command.add("-Dquarkus.http.ssl-port=" + sslPort);
47+
command.add("-Dquarkus.http.port=" + port);
48+
command.add(path);
49+
log.info("Starting the MCP server using command: " + command);
50+
Process process = new ProcessBuilder().command(command).inheritIO().start();
51+
waitForPort(port, 120);
52+
log.info("MCP server has started");
53+
return process;
54+
}
55+
56+
static String getPathToScript(String script) {
57+
InputStream scriptAsStream = ClassLoader.getSystemResourceAsStream(script);
58+
if (scriptAsStream == null) {
59+
throw new RuntimeException("Unable to find script " + script);
60+
} else if (scriptAsStream instanceof BufferedInputStream) {
61+
// the script path points at a regular file,
62+
// so just return its full path
63+
return ClassLoader.getSystemResource(script)
64+
.getFile()
65+
.substring(isWindows() ? 1 : 0)
66+
.replace("/", File.separator);
67+
} else {
68+
// the script path points at a file that is inside a JAR
69+
// so we unzip it into a temporary file
70+
File folder = Files.newTemporaryFolder();
71+
folder.deleteOnExit();
72+
Path tmpFilePath = Path.of(folder.getAbsolutePath(), script);
73+
try {
74+
log.info("Temporarily copying " + ClassLoader.getSystemResource(script) + " to " + tmpFilePath);
75+
java.nio.file.Files.copy(scriptAsStream, tmpFilePath);
76+
} catch (IOException e) {
77+
throw new RuntimeException(e);
78+
}
79+
return tmpFilePath.toString();
80+
}
81+
82+
}
83+
84+
static String getJBangCommand() {
85+
String command = System.getProperty("jbang.command");
86+
if (command == null || command.isEmpty()) {
87+
command = isWindows() ? "jbang.cmd" : "jbang";
88+
}
89+
return command;
90+
}
91+
92+
public static void skipTestsIfJbangNotAvailable() {
93+
String command = getJBangCommand();
94+
try {
95+
new ProcessBuilder().command(command, "--version").start().waitFor();
96+
} catch (Exception e) {
97+
String message = "jbang is not available (could not execute command '" + command
98+
+ "', MCP integration tests will be skipped. "
99+
+ "The command may be overridden via the system property 'jbang.command'";
100+
log.warn(message, e);
101+
assumeTrue(false, message);
102+
}
103+
}
104+
105+
/**
106+
* This is a hacky way to get the tests working in the Platform CI where the JBang scripts
107+
* are not in 'src/test/resources' as the test expects them, but rather inside the test suite's
108+
* test-jar artifact, so this method temporarily copies them to src/test/resources.
109+
*/
110+
static void copyMcpServerScriptToSrcTestResourcesIfItsNotThereAlready(String script) {
111+
Path path = Path.of("src", "test", "resources", script);
112+
if (!java.nio.file.Files.exists(path)) {
113+
InputStream scriptAsStream = ClassLoader.getSystemResourceAsStream(script);
114+
if (scriptAsStream == null) {
115+
throw new RuntimeException("Unable to find script " + script);
116+
}
117+
try {
118+
log.info("Temporarily copying " + ClassLoader.getSystemResource(script) + " to " + path);
119+
FileUtils.forceMkdirParent(path.toFile());
120+
java.nio.file.Files.copy(scriptAsStream, path);
121+
path.toFile().deleteOnExit();
122+
} catch (IOException e) {
123+
throw new RuntimeException(e);
124+
}
125+
}
126+
}
127+
128+
private static void waitForPort(int port, int timeoutSeconds) throws Exception {
129+
Vertx vertx = Vertx.vertx();
130+
try {
131+
WebClient webClient = WebClient.create(vertx);
132+
try {
133+
long start = System.currentTimeMillis();
134+
while (System.currentTimeMillis() - start < timeoutSeconds * 1000) {
135+
try {
136+
HttpResponse<Buffer> result = webClient.get(port, "localhost", "/").send().toCompletionStage()
137+
.toCompletableFuture().get();
138+
if (result != null) {
139+
return;
140+
}
141+
} catch (Exception e) {
142+
log.info("MCP server not started yet...");
143+
TimeUnit.SECONDS.sleep(1);
144+
}
145+
}
146+
throw new TimeoutException("Port " + port + " did not open within " + timeoutSeconds + " seconds");
147+
} finally {
148+
webClient.close();
149+
}
150+
} finally {
151+
vertx.close();
152+
}
153+
}
154+
155+
private static boolean isWindows() {
156+
return System.getProperty("os.name").toLowerCase().contains("windows");
157+
}
158+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package io.quarkiverse.langchain4j.mcp.test;
2+
3+
import static io.quarkiverse.langchain4j.mcp.test.McpServerHelper.skipTestsIfJbangNotAvailable;
4+
import static io.quarkiverse.langchain4j.mcp.test.McpServerHelper.startServerHttp;
5+
import static org.junit.jupiter.api.Assertions.assertEquals;
6+
7+
import jakarta.enterprise.context.ApplicationScoped;
8+
import jakarta.inject.Inject;
9+
10+
import org.assertj.core.api.Assertions;
11+
import org.jboss.shrinkwrap.api.ShrinkWrap;
12+
import org.jboss.shrinkwrap.api.spec.JavaArchive;
13+
import org.junit.jupiter.api.AfterAll;
14+
import org.junit.jupiter.api.BeforeAll;
15+
import org.junit.jupiter.api.Test;
16+
import org.junit.jupiter.api.extension.RegisterExtension;
17+
18+
import dev.langchain4j.agent.tool.ToolExecutionRequest;
19+
import dev.langchain4j.mcp.client.McpClient;
20+
import dev.langchain4j.service.tool.ToolExecutionResult;
21+
import io.quarkiverse.langchain4j.mcp.auth.McpClientAuthProvider;
22+
import io.quarkiverse.langchain4j.mcp.runtime.McpClientName;
23+
import io.quarkus.test.QuarkusUnitTest;
24+
import io.restassured.RestAssured;
25+
import io.restassured.response.Response;
26+
import io.smallrye.jwt.build.Jwt;
27+
import io.vertx.core.json.JsonObject;
28+
29+
class SecureMcpWithMicroprofileHealthTest {
30+
31+
private static Process process;
32+
33+
@RegisterExtension
34+
static QuarkusUnitTest unitTest = new QuarkusUnitTest()
35+
.setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class)
36+
.addClasses(McpServerHelper.class)
37+
.addAsResource("privateKey.pem"))
38+
.overrideConfigKey("quarkus.langchain4j.mcp.client1.transport-type", "http")
39+
.overrideConfigKey("quarkus.langchain4j.mcp.client1.url", "http://localhost:8082/mcp/sse")
40+
.overrideConfigKey("quarkus.langchain4j.mcp.client1.microprofile-health-check", "true")
41+
.overrideConfigKey("quarkus.langchain4j.mcp.client1.log-requests", "true")
42+
.overrideConfigKey("quarkus.langchain4j.mcp.client1.log-responses", "true")
43+
.overrideConfigKey("quarkus.langchain4j.mcp.client1.tool-execution-timeout", "5s");
44+
45+
@BeforeAll
46+
static void setup() throws Exception {
47+
skipTestsIfJbangNotAvailable();
48+
process = startServerHttp("mcp_server_microprofile_health.java");
49+
}
50+
51+
@AfterAll
52+
static void teardown() throws Exception {
53+
if (process != null && process.isAlive()) {
54+
process.destroyForcibly();
55+
}
56+
}
57+
58+
@Inject
59+
@McpClientName("client1")
60+
McpClient mcpClient;
61+
62+
@Test
63+
void testMicroprofileHealth() throws Exception {
64+
Response healthReadyResponse = RestAssured.when().get("http://localhost:8081/q/health/ready");
65+
JsonObject jsonHealth = new JsonObject(healthReadyResponse.asString());
66+
JsonObject mcpCheck = jsonHealth.getJsonArray("checks").getJsonObject(0);
67+
assertEquals("UP", mcpCheck.getString("status"));
68+
assertEquals("MCP clients health check", mcpCheck.getString("name"));
69+
70+
JsonObject data = mcpCheck.getJsonObject("data");
71+
assertEquals("OK", data.getString("client1"));
72+
}
73+
74+
@Test
75+
void testAuthenticationSuccessful() throws Exception {
76+
ToolExecutionRequest toolExecutionRequest = ToolExecutionRequest.builder()
77+
.name("getUserName")
78+
.build();
79+
Assertions.assertThat(mcpClient.executeTool(toolExecutionRequest))
80+
.isEqualTo(ToolExecutionResult.builder().resultText("alice").isError(false).build());
81+
}
82+
83+
@Test
84+
void testAuthenticationSuccessfulWithRestAssured() throws Exception {
85+
RestAssured.given().auth().oauth2(getJwt())
86+
.when().get("http://localhost:8082/mcp/sse")
87+
.then()
88+
.statusCode(200);
89+
}
90+
91+
@Test
92+
void testAuthenticationFailedWithRestAssured() throws Exception {
93+
RestAssured.when().get("http://localhost:8082/mcp/sse")
94+
.then()
95+
.statusCode(401);
96+
}
97+
98+
@ApplicationScoped
99+
public static class DummyMcpClientAuthProvider implements McpClientAuthProvider {
100+
101+
@Override
102+
public String getAuthorization(Input input) {
103+
return "Bearer " + getJwt();
104+
}
105+
}
106+
107+
static String getJwt() {
108+
return Jwt.preferredUserName("alice").sign("privateKey.pem");
109+
}
110+
}

0 commit comments

Comments
 (0)