Skip to content

Commit 5ec0d41

Browse files
authored
Merge pull request #1433 from jmartisk/issue-1414
MP Health check for MCP clients, plus configurable ping timeout
2 parents a1ec641 + eb23e1b commit 5ec0d41

File tree

12 files changed

+270
-0
lines changed

12 files changed

+270
-0
lines changed

docs/modules/ROOT/pages/includes/quarkus-langchain4j-mcp.adoc

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,27 @@ endif::add-copy-button-to-env-var[]
5151
|string
5252
|
5353

54+
a|icon:lock[title=Fixed at build time] [[quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-health-enabled]] [.property-path]##link:#quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-health-enabled[`quarkus.langchain4j.mcp.health.enabled`]##
55+
ifdef::add-copy-button-to-config-props[]
56+
config_property_copy_button:+++quarkus.langchain4j.mcp.health.enabled+++[]
57+
endif::add-copy-button-to-config-props[]
58+
59+
60+
[.description]
61+
--
62+
Whether the MCP extension should automatically register a health check for configured MCP clients. The default is true if at least one MCP client is configured, false otherwise.
63+
64+
65+
ifdef::add-copy-button-to-env-var[]
66+
Environment variable: env_var_with_copy_button:+++QUARKUS_LANGCHAIN4J_MCP_HEALTH_ENABLED+++[]
67+
endif::add-copy-button-to-env-var[]
68+
ifndef::add-copy-button-to-env-var[]
69+
Environment variable: `+++QUARKUS_LANGCHAIN4J_MCP_HEALTH_ENABLED+++`
70+
endif::add-copy-button-to-env-var[]
71+
--
72+
|boolean
73+
|`true`
74+
5475
h|[[quarkus-langchain4j-mcp_section_quarkus-langchain4j-mcp]] [.section-name.section-level0]##link:#quarkus-langchain4j-mcp_section_quarkus-langchain4j-mcp[Configured MCP clients]##
5576
h|Type
5677
h|Default
@@ -223,6 +244,27 @@ endif::add-copy-button-to-env-var[]
223244
|link:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html[Duration] link:#duration-note-anchor-quarkus-langchain4j-mcp_quarkus-langchain4j[icon:question-circle[title=More information about the Duration format]]
224245
|`60S`
225246

247+
a| [[quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-client-name-ping-timeout]] [.property-path]##link:#quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-client-name-ping-timeout[`quarkus.langchain4j.mcp."client-name".ping-timeout`]##
248+
ifdef::add-copy-button-to-config-props[]
249+
config_property_copy_button:+++quarkus.langchain4j.mcp."client-name".ping-timeout+++[]
250+
endif::add-copy-button-to-config-props[]
251+
252+
253+
[.description]
254+
--
255+
Timeout for pinging the MCP server process to check if it's still alive. If a ping times out, the client's health check will start failing.
256+
257+
258+
ifdef::add-copy-button-to-env-var[]
259+
Environment variable: env_var_with_copy_button:+++QUARKUS_LANGCHAIN4J_MCP__CLIENT_NAME__PING_TIMEOUT+++[]
260+
endif::add-copy-button-to-env-var[]
261+
ifndef::add-copy-button-to-env-var[]
262+
Environment variable: `+++QUARKUS_LANGCHAIN4J_MCP__CLIENT_NAME__PING_TIMEOUT+++`
263+
endif::add-copy-button-to-env-var[]
264+
--
265+
|link:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html[Duration] link:#duration-note-anchor-quarkus-langchain4j-mcp_quarkus-langchain4j[icon:question-circle[title=More information about the Duration format]]
266+
|`10S`
267+
226268

227269
|===
228270

docs/modules/ROOT/pages/includes/quarkus-langchain4j-mcp_quarkus.langchain4j.adoc

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,27 @@ endif::add-copy-button-to-env-var[]
5151
|string
5252
|
5353

54+
a|icon:lock[title=Fixed at build time] [[quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-health-enabled]] [.property-path]##link:#quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-health-enabled[`quarkus.langchain4j.mcp.health.enabled`]##
55+
ifdef::add-copy-button-to-config-props[]
56+
config_property_copy_button:+++quarkus.langchain4j.mcp.health.enabled+++[]
57+
endif::add-copy-button-to-config-props[]
58+
59+
60+
[.description]
61+
--
62+
Whether the MCP extension should automatically register a health check for configured MCP clients. The default is true if at least one MCP client is configured, false otherwise.
63+
64+
65+
ifdef::add-copy-button-to-env-var[]
66+
Environment variable: env_var_with_copy_button:+++QUARKUS_LANGCHAIN4J_MCP_HEALTH_ENABLED+++[]
67+
endif::add-copy-button-to-env-var[]
68+
ifndef::add-copy-button-to-env-var[]
69+
Environment variable: `+++QUARKUS_LANGCHAIN4J_MCP_HEALTH_ENABLED+++`
70+
endif::add-copy-button-to-env-var[]
71+
--
72+
|boolean
73+
|`true`
74+
5475
h|[[quarkus-langchain4j-mcp_section_quarkus-langchain4j-mcp]] [.section-name.section-level0]##link:#quarkus-langchain4j-mcp_section_quarkus-langchain4j-mcp[Configured MCP clients]##
5576
h|Type
5677
h|Default
@@ -223,6 +244,27 @@ endif::add-copy-button-to-env-var[]
223244
|link:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html[Duration] link:#duration-note-anchor-quarkus-langchain4j-mcp_quarkus-langchain4j[icon:question-circle[title=More information about the Duration format]]
224245
|`60S`
225246

247+
a| [[quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-client-name-ping-timeout]] [.property-path]##link:#quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-client-name-ping-timeout[`quarkus.langchain4j.mcp."client-name".ping-timeout`]##
248+
ifdef::add-copy-button-to-config-props[]
249+
config_property_copy_button:+++quarkus.langchain4j.mcp."client-name".ping-timeout+++[]
250+
endif::add-copy-button-to-config-props[]
251+
252+
253+
[.description]
254+
--
255+
Timeout for pinging the MCP server process to check if it's still alive. If a ping times out, the client's health check will start failing.
256+
257+
258+
ifdef::add-copy-button-to-env-var[]
259+
Environment variable: env_var_with_copy_button:+++QUARKUS_LANGCHAIN4J_MCP__CLIENT_NAME__PING_TIMEOUT+++[]
260+
endif::add-copy-button-to-env-var[]
261+
ifndef::add-copy-button-to-env-var[]
262+
Environment variable: `+++QUARKUS_LANGCHAIN4J_MCP__CLIENT_NAME__PING_TIMEOUT+++`
263+
endif::add-copy-button-to-env-var[]
264+
--
265+
|link:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html[Duration] link:#duration-note-anchor-quarkus-langchain4j-mcp_quarkus-langchain4j[icon:question-circle[title=More information about the Duration format]]
266+
|`10S`
267+
226268

227269
|===
228270

mcp/deployment/pom.xml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@
2323
<groupId>io.quarkus</groupId>
2424
<artifactId>quarkus-rest-client-jackson-deployment</artifactId>
2525
</dependency>
26+
<dependency>
27+
<groupId>io.quarkus</groupId>
28+
<artifactId>quarkus-smallrye-health-spi</artifactId>
29+
</dependency>
30+
<dependency>
31+
<groupId>io.quarkus</groupId>
32+
<artifactId>quarkus-smallrye-health-deployment</artifactId>
33+
<optional>true</optional>
34+
</dependency>
2635
<dependency>
2736
<groupId>io.quarkus</groupId>
2837
<artifactId>quarkus-rest-jackson</artifactId>

mcp/deployment/src/main/java/io/quarkiverse/langchain4j/mcp/deployment/McpProcessor.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import io.quarkus.deployment.builditem.CombinedIndexBuildItem;
3838
import io.quarkus.deployment.builditem.IndexDependencyBuildItem;
3939
import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem;
40+
import io.quarkus.smallrye.health.deployment.spi.HealthBuildItem;
4041

4142
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
4243
public class McpProcessor {
@@ -108,6 +109,7 @@ public void registerMcpClients(McpBuildTimeConfiguration mcpBuildTimeConfigurati
108109
McpRuntimeConfiguration mcpRuntimeConfiguration,
109110
Optional<McpConfigFileContentsBuildItem> maybeMcpConfigFileContents,
110111
BuildProducer<SyntheticBeanBuildItem> beanProducer,
112+
BuildProducer<HealthBuildItem> healthBuildItems,
111113
McpRecorder recorder) {
112114
Map<String, McpTransportType> clients = new HashMap<>();
113115
if (mcpBuildTimeConfiguration.clients() != null && !mcpBuildTimeConfiguration.clients().isEmpty()) {
@@ -153,6 +155,11 @@ public void registerMcpClients(McpBuildTimeConfiguration mcpBuildTimeConfigurati
153155
}
154156
beanProducer.produce(configurator.done());
155157
}
158+
// generate a health check
159+
if (mcpBuildTimeConfiguration.mpHealthEnabled()) {
160+
healthBuildItems.produce(new HealthBuildItem("io.quarkiverse.langchain4j.mcp.runtime.McpClientHealthCheck",
161+
true));
162+
}
156163
}
157164
}
158165

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package io.quarkiverse.langchain4j.mcp.test;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
5+
import jakarta.inject.Inject;
6+
7+
import org.eclipse.microprofile.health.HealthCheckResponse;
8+
import org.eclipse.microprofile.health.Readiness;
9+
import org.jboss.shrinkwrap.api.ShrinkWrap;
10+
import org.jboss.shrinkwrap.api.asset.StringAsset;
11+
import org.jboss.shrinkwrap.api.spec.JavaArchive;
12+
import org.junit.jupiter.api.Test;
13+
import org.junit.jupiter.api.extension.RegisterExtension;
14+
15+
import io.quarkiverse.langchain4j.mcp.runtime.McpClientHealthCheck;
16+
import io.quarkus.test.QuarkusUnitTest;
17+
18+
public class McpHealthCheckTest {
19+
20+
@RegisterExtension
21+
static QuarkusUnitTest unitTest = new QuarkusUnitTest()
22+
.setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class)
23+
.addClass(MockHttpMcpServer.class)
24+
.addAsResource(
25+
new StringAsset(
26+
"""
27+
quarkus.langchain4j.mcp.client1.transport-type=http
28+
quarkus.langchain4j.mcp.client1.url=http://localhost:8081/mock-mcp/sse
29+
quarkus.langchain4j.mcp.client1.log-requests=true
30+
quarkus.langchain4j.mcp.client1.log-responses=true
31+
# short timeout so we can quickly test the health check
32+
quarkus.langchain4j.mcp.client1.ping-timeout=2s
33+
quarkus.log.category."dev.langchain4j".level=DEBUG
34+
quarkus.log.category."io.quarkiverse".level=DEBUG
35+
"""),
36+
"application.properties"));
37+
38+
@Inject
39+
@Readiness
40+
McpClientHealthCheck healthCheck;
41+
42+
@Inject
43+
MockHttpMcpServer server;
44+
45+
@Test
46+
public void test() {
47+
HealthCheckResponse response = healthCheck.call();
48+
assertEquals(HealthCheckResponse.Status.UP, response.getStatus());
49+
50+
server.stopRespondingToPings();
51+
52+
response = healthCheck.call();
53+
assertEquals(HealthCheckResponse.Status.DOWN, response.getStatus());
54+
}
55+
}

mcp/deployment/src/test/java/io/quarkiverse/langchain4j/mcp/test/MockHttpMcpServer.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import jakarta.ws.rs.sse.Sse;
2020
import jakarta.ws.rs.sse.SseEventSink;
2121

22+
import org.jboss.logging.Logger;
2223
import org.jboss.resteasy.reactive.RestStreamElementType;
2324

2425
import com.fasterxml.jackson.databind.JsonNode;
@@ -33,6 +34,10 @@ public class MockHttpMcpServer {
3334

3435
private final AtomicLong ID_GENERATOR = new AtomicLong(new Random().nextLong(1000, 5000));
3536

37+
private static Logger logger = Logger.getLogger(MockHttpMcpServer.class);
38+
39+
private volatile boolean shouldRespondToPing = true;
40+
3641
// key = operation ID of the ping
3742
// value = future that will be completed when the ping response for that ID is received
3843
final Map<Long, CompletableFuture<Void>> pendingPings = new ConcurrentHashMap<>();
@@ -159,6 +164,17 @@ public Response post(JsonNode message) {
159164
} else {
160165
return Response.serverError().entity("Unknown operation").build();
161166
}
167+
} else if (method.equals("ping")) {
168+
if (shouldRespondToPing) {
169+
ObjectNode result = buildPongMessage(operationId);
170+
sink.send(sse.newEventBuilder()
171+
.name("message")
172+
.data(result)
173+
.build());
174+
} else {
175+
logger.info("Ignoring ping request");
176+
}
177+
return Response.accepted().build();
162178
}
163179
} else {
164180
// if 'method' is null, the message is probably a ping response
@@ -173,6 +189,14 @@ public Response post(JsonNode message) {
173189
return Response.accepted().build();
174190
}
175191

192+
private ObjectNode buildPongMessage(String operationId) {
193+
ObjectNode pong = objectMapper.createObjectNode();
194+
pong.put("jsonrpc", "2.0");
195+
pong.put("id", operationId);
196+
pong.put("result", objectMapper.createObjectNode());
197+
return pong;
198+
}
199+
176200
private void executeLoggingOperation(JsonNode message, String operationId) {
177201
ObjectNode logData = objectMapper.createObjectNode();
178202
logData.put("message", "This is a log message");
@@ -278,4 +302,8 @@ long sendPing() {
278302
return id;
279303
}
280304

305+
void stopRespondingToPings() {
306+
shouldRespondToPing = false;
307+
}
308+
281309
}

mcp/runtime/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@
2121
<groupId>dev.langchain4j</groupId>
2222
<artifactId>langchain4j-mcp</artifactId>
2323
</dependency>
24+
<dependency>
25+
<groupId>io.quarkus</groupId>
26+
<artifactId>quarkus-smallrye-health</artifactId>
27+
<optional>true</optional>
28+
</dependency>
2429
<dependency>
2530
<groupId>io.quarkiverse.langchain4j</groupId>
2631
<artifactId>quarkus-langchain4j-core</artifactId>
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package io.quarkiverse.langchain4j.mcp.runtime;
2+
3+
import java.lang.annotation.Annotation;
4+
import java.util.HashMap;
5+
import java.util.Map;
6+
7+
import jakarta.enterprise.context.ApplicationScoped;
8+
import jakarta.enterprise.inject.Any;
9+
10+
import org.eclipse.microprofile.health.HealthCheck;
11+
import org.eclipse.microprofile.health.HealthCheckResponse;
12+
import org.eclipse.microprofile.health.HealthCheckResponseBuilder;
13+
import org.eclipse.microprofile.health.Readiness;
14+
15+
import dev.langchain4j.mcp.client.McpClient;
16+
import io.quarkus.arc.Arc;
17+
import io.quarkus.arc.InjectableBean;
18+
import io.quarkus.arc.InstanceHandle;
19+
20+
@ApplicationScoped
21+
@Readiness
22+
public class McpClientHealthCheck implements HealthCheck {
23+
24+
private Map<String, McpClient> clientMap;
25+
26+
public McpClientHealthCheck() {
27+
clientMap = getClientMap();
28+
}
29+
30+
@Override
31+
public HealthCheckResponse call() {
32+
HealthCheckResponseBuilder builder = HealthCheckResponse.builder()
33+
.name("MCP clients health check")
34+
.up();
35+
for (String name : clientMap.keySet()) {
36+
McpClient client = clientMap.get(name);
37+
try {
38+
client.checkHealth();
39+
builder.withData(name, "OK");
40+
} catch (Exception e) {
41+
builder.down().withData(name, e.getMessage());
42+
}
43+
}
44+
return builder.build();
45+
}
46+
47+
private Map<String, McpClient> getClientMap() {
48+
Map<String, McpClient> map = new HashMap<>();
49+
for (InstanceHandle<McpClient> handle : Arc.container().select(McpClient.class, Any.Literal.INSTANCE).handles()) {
50+
InjectableBean<McpClient> bean = handle.getBean();
51+
for (Annotation qualifier : bean.getQualifiers()) {
52+
if (qualifier instanceof McpClientName q) {
53+
String name = q.value() != null && !q.value().isEmpty() ? q.value() : "<default>";
54+
map.put(name, handle.get());
55+
}
56+
}
57+
}
58+
return map;
59+
}
60+
}

mcp/runtime/src/main/java/io/quarkiverse/langchain4j/mcp/runtime/McpRecorder.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ public McpClient get() {
6666
.transport(transport)
6767
.toolExecutionTimeout(runtimeConfig.toolExecutionTimeout())
6868
.resourcesTimeout(runtimeConfig.resourcesTimeout())
69+
.pingTimeout(runtimeConfig.pingTimeout())
6970
// TODO: it should be possible to choose a log handler class via configuration
7071
.logHandler(new QuarkusDefaultMcpLogHandler(key))
7172
.build();

mcp/runtime/src/main/java/io/quarkiverse/langchain4j/mcp/runtime/config/McpBuildTimeConfiguration.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
import io.quarkus.runtime.annotations.ConfigPhase;
1010
import io.quarkus.runtime.annotations.ConfigRoot;
1111
import io.smallrye.config.ConfigMapping;
12+
import io.smallrye.config.WithDefault;
13+
import io.smallrye.config.WithName;
1214
import io.smallrye.config.WithParentName;
1315

1416
@ConfigRoot(phase = ConfigPhase.BUILD_AND_RUN_TIME_FIXED)
@@ -39,4 +41,12 @@ public interface McpBuildTimeConfiguration {
3941
* is determined at build time. However, specific configuration of each MCP server can be overridden at runtime.
4042
*/
4143
Optional<String> configFile();
44+
45+
/**
46+
* Whether the MCP extension should automatically register a health check for configured MCP clients.
47+
* The default is true if at least one MCP client is configured, false otherwise.
48+
*/
49+
@WithName("health.enabled")
50+
@WithDefault("true")
51+
boolean mpHealthEnabled();
4252
}

0 commit comments

Comments
 (0)