Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/reclaim-disk-space.sh
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ sudo rm -rf /opt/ghc \
rm -rf /usr/local/.ghcup \
rm -rf /usr/local/go \
rm -rf /usr/local/lib/android \
rm -rf /usr/local/lib/node_modules \
rm -rf /usr/local/lib/node_modules/parcel \
rm -rf /usr/local/share/boost \
rm -rf /usr/local/share/powershell \
rm -rf /usr/share/dotnet \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,16 @@
*/
package org.apache.camel.quarkus.component.langchain4j.agent.deployment;

import java.util.Set;
import java.util.stream.Collectors;

import io.quarkus.deployment.annotations.BuildStep;
import io.quarkus.deployment.builditem.CombinedIndexBuildItem;
import io.quarkus.deployment.builditem.FeatureBuildItem;
import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem;
import io.quarkus.deployment.pkg.steps.NativeOrNativeSourcesBuild;
import org.jboss.jandex.ClassInfo;
import org.jboss.jandex.DotName;

class Langchain4jAgentProcessor {
private static final String FEATURE = "camel-langchain4j-agent";
Expand All @@ -26,4 +34,21 @@ class Langchain4jAgentProcessor {
FeatureBuildItem feature() {
return new FeatureBuildItem(FEATURE);
}

@BuildStep(onlyIf = NativeOrNativeSourcesBuild.class)
ReflectiveClassBuildItem registerForReflection(CombinedIndexBuildItem combinedIndex) {
Set<String> mcpProtocolClasses = combinedIndex.getIndex()
.getClassesInPackage("dev.langchain4j.mcp.client.protocol")
.stream()
.map(ClassInfo::asClass)
.map(ClassInfo::name)
.map(DotName::toString)
.collect(Collectors.toSet());

return ReflectiveClassBuildItem
.builder(mcpProtocolClasses.toArray(new String[0]))
.fields(true)
.methods(true)
.build();
}
}
2 changes: 2 additions & 0 deletions integration-tests/langchain4j-agent/README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ By default, the Langchain4j-agent integration tests use WireMock to stub Ollama

To run the `camel-quarkus-langchain4j-agent` integration tests against the real API, you need a Ollama instance running with the `orca-mini` & `granite4:3b` models downloaded.

The MCP client tests require https://nodejs.org/[Node.js] to be installed on the test host.

When Ollama is running, set the following environment variables:

[source,shell]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
import dev.langchain4j.data.document.splitter.DocumentSplitters;
import dev.langchain4j.data.embedding.Embedding;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.mcp.client.DefaultMcpClient;
import dev.langchain4j.mcp.client.transport.stdio.StdioMcpTransport;
import dev.langchain4j.memory.chat.ChatMemoryProvider;
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
import dev.langchain4j.model.chat.ChatModel;
Expand Down Expand Up @@ -54,6 +56,7 @@
import org.apache.camel.quarkus.component.langchain4j.agent.it.service.TestPojoAiAgent;
import org.apache.camel.quarkus.component.langchain4j.agent.it.tool.AdditionTool;
import org.apache.camel.quarkus.component.langchain4j.agent.it.util.PersistentChatMemoryStore;
import org.apache.camel.quarkus.component.langchain4j.agent.it.util.ProcessUtils;
import org.eclipse.microprofile.config.inject.ConfigProperty;

import static java.time.Duration.ofSeconds;
Expand All @@ -63,6 +66,9 @@ public class AgentProducers {
@ConfigProperty(name = "langchain4j.ollama.base-url")
String baseUrl;

@ConfigProperty(name = "nodejs.installed")
boolean isNodeJSInstaled;

@Produces
@Identifier("ollamaOrcaMiniModel")
ChatModel ollamaOrcaMiniModel() {
Expand Down Expand Up @@ -221,4 +227,25 @@ Agent agentWithCustomTools(@Identifier("granite4Model") ChatModel chatModel) {
.withChatModel(chatModel)
.withCustomTools(List.of(new AdditionTool())));
}

@Produces
@Identifier("agentWithMcpClient")
Agent agentWithMcpClient(@Identifier("granite4Model") ChatModel chatModel) {
if (isNodeJSInstaled) {
return new AgentWithoutMemory(new AgentConfiguration()
.withChatModel(chatModel)
.withMcpClient(new DefaultMcpClient.Builder()
.transport(new StdioMcpTransport.Builder()
.command(List.of(ProcessUtils.getNpxExecutable(), "-y",
"@modelcontextprotocol/server-everything"))
.logEvents(true)
.build())
.build())
.withMcpToolProviderFilter((mcpClient, toolSpecification) -> {
String toolName = toolSpecification.name().toLowerCase();
return toolName.contains("add") || toolName.contains("echo") || toolName.contains("long");
}));
}
return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.apache.camel.Exchange;
Expand Down Expand Up @@ -225,4 +226,20 @@ public Response chatWithCustomTools(String userMessage) {
AdditionTool.reset();
}
}

@Path("/mcp/client")
@POST
@Consumes(MediaType.TEXT_PLAIN)
@Produces(value = { MediaType.TEXT_PLAIN, MediaType.APPLICATION_JSON })
public Response chatWithMcpClient(String userMessage) {
String result = producerTemplate.to("direct:agent-with-mcp-client")
.withBody(userMessage)
.request(String.class);

String contentType = userMessage.contains("JSON") ? MediaType.APPLICATION_JSON : MediaType.TEXT_PLAIN;

return Response.ok(result.trim())
.header(HttpHeaders.CONTENT_TYPE, contentType)
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,17 @@
*/
package org.apache.camel.quarkus.component.langchain4j.agent.it;

import jakarta.enterprise.context.ApplicationScoped;
import org.apache.camel.builder.RouteBuilder;
import org.eclipse.microprofile.config.inject.ConfigProperty;

@ApplicationScoped
public class Langchain4jAgentRoutes extends RouteBuilder {
public static final String USER_JOHN = "John Doe";

@ConfigProperty(name = "nodejs.installed")
boolean isNodeJSInstaled;

@Override
public void configure() throws Exception {
from("direct:simple-agent")
Expand Down Expand Up @@ -58,5 +64,10 @@ public void configure() throws Exception {

from("direct:agent-with-custom-tools")
.to("langchain4j-agent:test-agent-custom-tools?agent=#agentWithCustomTools");

if (isNodeJSInstaled) {
from("direct:agent-with-mcp-client")
.to("langchain4j-agent:test-agent-with-mcp-client?agent=#agentWithMcpClient");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* 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.camel.quarkus.component.langchain4j.agent.it.util;

import io.smallrye.common.os.OS;

public final class ProcessUtils {
private ProcessUtils() {
// Utility class
}

public static String getNpxExecutable() {
return OS.current().equals(OS.WINDOWS) ? "npx.cmd" : "npx";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@
## See the License for the specific language governing permissions and
## limitations under the License.
## ---------------------------------------------------------------------------
quarkus.native.resources.includes=rag/*
quarkus.native.resources.includes=rag/*
quarkus.http.test-timeout=120S
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,13 @@
import io.restassured.RestAssured;
import org.apache.camel.quarkus.component.langchain4j.agent.it.guardrail.ValidationFailureInputGuardrail;
import org.apache.camel.quarkus.component.langchain4j.agent.it.guardrail.ValidationFailureOutputGuardrail;
import org.eclipse.microprofile.config.ConfigProvider;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

import static org.apache.camel.quarkus.component.langchain4j.agent.it.Langchain4jAgentRoutes.USER_JOHN;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.containsStringIgnoringCase;
import static org.hamcrest.Matchers.is;
Expand All @@ -33,7 +36,7 @@
import static org.hamcrest.Matchers.startsWith;

@ExtendWith(Langchain4jTestWatcher.class)
@QuarkusTestResource(OllamaTestResource.class)
@QuarkusTestResource(Langchain4jAgentTestResource.class)
@QuarkusTest
class Langchain4jAgentTest {
static final String TEST_USER_MESSAGE_SIMPLE = "What is Apache Camel?";
Expand Down Expand Up @@ -223,4 +226,25 @@ void agentWithCustomTools() {
"result", containsStringIgnoringCase("15"),
"toolWasInvoked", is(true));
}

@Test
void agentWithMcpClient() {
boolean isNodeJSInstalled = ConfigProvider.getConfig().getValue("nodejs.installed", boolean.class);
Assumptions.assumeTrue(isNodeJSInstalled, "Node.js is not installed");

RestAssured.given()
.body("Please list your available tools. You MUST respond using ONLY valid JSON with tool names as an array. DO NOT add explanations. DO NOT add comments. DO NOT wrap in markdown.")
.post("/langchain4j-agent/mcp/client")
.then()
.statusCode(200)
.body(".", containsInAnyOrder("add", "echo", "longRunningOperation"));

RestAssured.given()
.body("Use your available tools to perform a long running operation for 2 seconds with 2 steps. DO NOT use any markdown formatting in the response.")
.post("/langchain4j-agent/mcp/client")
.then()
.statusCode(200)
.body(containsStringIgnoringCase(
"operation was executed successfully for a duration of 2 seconds divided into 2 steps"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,14 @@
import java.nio.file.Paths;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;

import com.github.tomakehurst.wiremock.stubbing.StubMapping;
import org.apache.camel.quarkus.component.langchain4j.agent.it.util.ProcessUtils;
import org.apache.camel.quarkus.test.wiremock.WireMockTestResourceLifecycleManager;
import org.junit.jupiter.api.condition.OS;

public class OllamaTestResource extends WireMockTestResourceLifecycleManager {
public class Langchain4jAgentTestResource extends WireMockTestResourceLifecycleManager {
private static final String OLLAMA_ENV_URL = "LANGCHAIN4J_OLLAMA_BASE_URL";

@Override
Expand All @@ -35,6 +38,7 @@ public Map<String, String> start() {
String wiremockUrl = properties.get("wiremock.url");
String url = wiremockUrl != null ? wiremockUrl : getRecordTargetBaseUrl();
properties.put("langchain4j.ollama.base-url", url);
properties.put("nodejs.installed", isNodeJSInstallationExists().toString());
return properties;
}

Expand Down Expand Up @@ -86,4 +90,23 @@ public void stop() {
Langchain4jTestWatcher.reset();
}
}

private Boolean isNodeJSInstallationExists() {
try {
// TODO: Suppress MCP tests in GitHub Actions for windows - https://github.com/apache/camel-quarkus/issues/8007
if (OS.current().equals(OS.WINDOWS) && System.getenv("CI") != null) {
return false;
}

Process process = new ProcessBuilder()
.command(ProcessUtils.getNpxExecutable(), "--version")
.start();

process.waitFor(10, TimeUnit.SECONDS);
return process.exitValue() == 0;
} catch (Exception e) {
LOG.error("Failed detecting Node.js", e);
}
return false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"id" : "31a8eb4e-6382-41f3-af6e-d0bd0c223779",
"name" : "api_chat",
"request" : {
"url" : "/api/chat",
"method" : "POST",
"bodyPatterns" : [ {
"equalToJson" : "{\n \"model\" : \"granite4:3b\",\n \"messages\" : [ {\n \"role\" : \"user\",\n \"content\" : \"Use your available tools to perform a long running operation for 2 seconds with 2 steps. DO NOT use any markdown formatting in the response.\"\n }, {\n \"role\" : \"assistant\",\n \"tool_calls\" : [ {\n \"function\" : {\n \"name\" : \"longRunningOperation\",\n \"arguments\" : {\n \"duration\" : 2,\n \"steps\" : 2\n }\n }\n } ]\n }, {\n \"role\" : \"tool\",\n \"content\" : \"Long running operation completed. Duration: 2 seconds, Steps: 2.\"\n } ],\n \"options\" : {\n \"temperature\" : 0.3,\n \"stop\" : [ ]\n },\n \"stream\" : false,\n \"tools\" : [ {\n \"type\" : \"function\",\n \"function\" : {\n \"name\" : \"echo\",\n \"description\" : \"Echoes back the input\",\n \"parameters\" : {\n \"type\" : \"object\",\n \"properties\" : {\n \"message\" : {\n \"type\" : \"string\",\n \"description\" : \"Message to echo\"\n }\n },\n \"required\" : [ \"message\" ]\n }\n }\n }, {\n \"type\" : \"function\",\n \"function\" : {\n \"name\" : \"longRunningOperation\",\n \"description\" : \"Demonstrates a long running operation with progress updates\",\n \"parameters\" : {\n \"type\" : \"object\",\n \"properties\" : {\n \"duration\" : {\n \"type\" : \"number\",\n \"description\" : \"Duration of the operation in seconds\"\n },\n \"steps\" : {\n \"type\" : \"number\",\n \"description\" : \"Number of steps in the operation\"\n }\n },\n \"required\" : [ ]\n }\n }\n }, {\n \"type\" : \"function\",\n \"function\" : {\n \"name\" : \"add\",\n \"description\" : \"Adds two numbers\",\n \"parameters\" : {\n \"type\" : \"object\",\n \"properties\" : {\n \"a\" : {\n \"type\" : \"number\",\n \"description\" : \"First number\"\n },\n \"b\" : {\n \"type\" : \"number\",\n \"description\" : \"Second number\"\n }\n },\n \"required\" : [ \"a\", \"b\" ]\n }\n }\n } ]\n}",
"ignoreArrayOrder" : true,
"ignoreExtraElements" : false
} ]
},
"response" : {
"status" : 200,
"body" : "{\"model\":\"granite4:3b\",\"created_at\":\"2025-11-21T13:34:24.262165232Z\",\"message\":{\"role\":\"assistant\",\"content\":\"The long running operation was executed successfully for a duration of 2 seconds divided into 2 steps. No further actions are required.\"},\"done\":true,\"done_reason\":\"stop\",\"total_duration\":1950113721,\"load_duration\":58493280,\"prompt_eval_count\":372,\"prompt_eval_duration\":501168238,\"eval_count\":27,\"eval_duration\":1373362750}",
"headers" : {
"Date" : "Fri, 21 Nov 2025 13:34:24 GMT",
"Content-Type" : "application/json; charset=utf-8"
}
},
"uuid" : "31a8eb4e-6382-41f3-af6e-d0bd0c223779",
"persistent" : true,
"insertionIndex" : 22
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"id" : "40955a71-2ef8-4abf-a9e9-9e225590ff3d",
"name" : "api_chat",
"request" : {
"url" : "/api/chat",
"method" : "POST",
"bodyPatterns" : [ {
"equalToJson" : "{\n \"model\" : \"granite4:3b\",\n \"messages\" : [ {\n \"role\" : \"user\",\n \"content\" : \"Use your available tools to perform a long running operation for 2 seconds with 2 steps. DO NOT use any markdown formatting in the response.\"\n } ],\n \"options\" : {\n \"temperature\" : 0.3,\n \"stop\" : [ ]\n },\n \"stream\" : false,\n \"tools\" : [ {\n \"type\" : \"function\",\n \"function\" : {\n \"name\" : \"echo\",\n \"description\" : \"Echoes back the input\",\n \"parameters\" : {\n \"type\" : \"object\",\n \"properties\" : {\n \"message\" : {\n \"type\" : \"string\",\n \"description\" : \"Message to echo\"\n }\n },\n \"required\" : [ \"message\" ]\n }\n }\n }, {\n \"type\" : \"function\",\n \"function\" : {\n \"name\" : \"longRunningOperation\",\n \"description\" : \"Demonstrates a long running operation with progress updates\",\n \"parameters\" : {\n \"type\" : \"object\",\n \"properties\" : {\n \"duration\" : {\n \"type\" : \"number\",\n \"description\" : \"Duration of the operation in seconds\"\n },\n \"steps\" : {\n \"type\" : \"number\",\n \"description\" : \"Number of steps in the operation\"\n }\n },\n \"required\" : [ ]\n }\n }\n }, {\n \"type\" : \"function\",\n \"function\" : {\n \"name\" : \"add\",\n \"description\" : \"Adds two numbers\",\n \"parameters\" : {\n \"type\" : \"object\",\n \"properties\" : {\n \"a\" : {\n \"type\" : \"number\",\n \"description\" : \"First number\"\n },\n \"b\" : {\n \"type\" : \"number\",\n \"description\" : \"Second number\"\n }\n },\n \"required\" : [ \"a\", \"b\" ]\n }\n }\n } ]\n}",
"ignoreArrayOrder" : true,
"ignoreExtraElements" : false
} ]
},
"response" : {
"status" : 200,
"body" : "{\"model\":\"granite4:3b\",\"created_at\":\"2025-11-21T13:34:12.261633323Z\",\"message\":{\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"id\":\"call_nwsbgoqi\",\"function\":{\"index\":0,\"name\":\"longRunningOperation\",\"arguments\":{\"duration\":2,\"steps\":2}}}]},\"done\":true,\"done_reason\":\"stop\",\"total_duration\":2284427024,\"load_duration\":53618252,\"prompt_eval_count\":319,\"prompt_eval_duration\":429089470,\"eval_count\":35,\"eval_duration\":1782178545}",
"headers" : {
"Date" : "Fri, 21 Nov 2025 13:34:12 GMT",
"Content-Type" : "application/json; charset=utf-8"
}
},
"uuid" : "40955a71-2ef8-4abf-a9e9-9e225590ff3d",
"persistent" : true,
"insertionIndex" : 23
}
Loading