diff --git a/core/deployment/src/test/java/io/quarkiverse/langchain4j/test/tools/ToolExecutionModelWithStreamingUnsupportedTest.java b/core/deployment/src/test/java/io/quarkiverse/langchain4j/test/tools/ToolExecutionModelWithStreamingUnsupportedTest.java index 2ae8b683f..b877a7ffa 100644 --- a/core/deployment/src/test/java/io/quarkiverse/langchain4j/test/tools/ToolExecutionModelWithStreamingUnsupportedTest.java +++ b/core/deployment/src/test/java/io/quarkiverse/langchain4j/test/tools/ToolExecutionModelWithStreamingUnsupportedTest.java @@ -24,6 +24,7 @@ import dev.langchain4j.agent.tool.Tool; import dev.langchain4j.data.message.AiMessage; import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.exception.UnsupportedFeatureException; import dev.langchain4j.memory.ChatMemory; import dev.langchain4j.memory.chat.ChatMemoryProvider; import dev.langchain4j.memory.chat.MessageWindowChatMemory; @@ -61,8 +62,8 @@ void testBlockingToolInvocationFromWorkerThread() { String uuid = UUID.randomUUID().toString(); assertThatThrownBy(() -> aiService.hello("abc", "hi - " + uuid) .collect().asList().map(l -> String.join(" ", l)).await().indefinitely()) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Tools", "supported"); + .isInstanceOf(UnsupportedFeatureException.class) + .hasMessageContaining("tools", "supported"); } @Test @@ -92,7 +93,7 @@ void testBlockingToolInvocationFromEventLoop() { }); Awaitility.await().until(() -> failure.get() != null || result.get() != null); - assertThat(failure.get()).hasMessageContaining("Tools", "supported"); + assertThat(failure.get()).hasMessageContaining("tools", "supported"); assertThat(result.get()).isNull(); } @@ -113,7 +114,7 @@ void testBlockingToolInvocationFromVirtualThread() throws ExecutionException, In } }).get(); - assertThat(r).contains("Tools", "supported"); + assertThat(r).contains("tools", "supported"); } @Test @@ -122,8 +123,8 @@ void testNonBlockingToolInvocationFromWorkerThread() { String uuid = UUID.randomUUID().toString(); assertThatThrownBy(() -> aiService.helloNonBlocking("abc", "hiNonBlocking - " + uuid) .collect().asList().map(l -> String.join(" ", l)).await().indefinitely()) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Tools", "supported"); + .isInstanceOf(UnsupportedFeatureException.class) + .hasMessageContaining("tools", "supported"); } @Test @@ -153,7 +154,7 @@ void testNonBlockingToolInvocationFromEventLoop() { }); Awaitility.await().until(() -> result.get() != null); - assertThat(result.get()).contains("Tools", "supported"); + assertThat(result.get()).contains("tools", "supported"); } @Test @@ -182,7 +183,7 @@ void testNonBlockingToolInvocationFromEventLoopWhenWeSwitchToWorkerThread() { }); Awaitility.await().until(() -> result.get() != null); - assertThat(result.get()).contains("Tools", "supported"); + assertThat(result.get()).contains("tools", "supported"); } @Test @@ -204,7 +205,7 @@ void testNonBlockingToolInvocationFromVirtualThread() throws ExecutionException, } }).get(); - assertThat(r).contains("Tools", "supported"); + assertThat(r).contains("tools", "supported"); } @Test @@ -213,8 +214,8 @@ void testUniToolInvocationFromWorkerThread() { String uuid = UUID.randomUUID().toString(); assertThatThrownBy(() -> aiService.helloUni("abc", "hiUni - " + uuid) .collect().asList().map(l -> String.join(" ", l)).await().indefinitely()) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Tools", "supported"); + .isInstanceOf(UnsupportedFeatureException.class) + .hasMessageContaining("tools", "supported"); } @Test @@ -244,7 +245,7 @@ void testUniToolInvocationFromEventLoop() { }); Awaitility.await().until(() -> failure.get() != null || result.get() != null); - assertThat(failure.get()).hasMessageContaining("Tools", "supported"); + assertThat(failure.get()).hasMessageContaining("tools", "supported"); assertThat(result.get()).isNull(); } @@ -267,7 +268,7 @@ void testUniToolInvocationFromVirtualThread() throws ExecutionException, Interru } }).get(); - assertThat(r).contains("Tools", "supported"); + assertThat(r).contains("tools", "supported"); } @Test @@ -277,8 +278,8 @@ void testToolInvocationOnVirtualThread() { String uuid = UUID.randomUUID().toString(); assertThatThrownBy(() -> aiService.helloVirtualTools("abc", "hiVirtualThread - " + uuid) .collect().asList().map(l -> String.join(" ", l)).await().indefinitely()) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Tools", "supported"); + .isInstanceOf(UnsupportedFeatureException.class) + .hasMessageContaining("tools", "supported"); } @Test @@ -299,7 +300,7 @@ void testToolInvocationOnVirtualThreadFromVirtualThread() throws ExecutionExcept } }).get(); - assertThat(r).contains("Tools", "supported"); + assertThat(r).contains("tools", "supported"); } @Test @@ -328,7 +329,7 @@ void testToolInvocationOnVirtualThreadFromEventLoop() { }); Awaitility.await().until(() -> failure.get() != null || result.get() != null); - assertThat(failure.get()).hasMessageContaining("Tools", "supported"); + assertThat(failure.get()).hasMessageContaining("tools", "supported"); assertThat(result.get()).isNull(); } diff --git a/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/aiservice/QuarkusAiServiceStreamingResponseHandler.java b/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/aiservice/QuarkusAiServiceStreamingResponseHandler.java index 60d9aa18e..f8d984c95 100644 --- a/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/aiservice/QuarkusAiServiceStreamingResponseHandler.java +++ b/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/aiservice/QuarkusAiServiceStreamingResponseHandler.java @@ -19,6 +19,10 @@ import dev.langchain4j.data.message.ChatMessage; import dev.langchain4j.data.message.ToolExecutionResultMessage; import dev.langchain4j.model.StreamingResponseHandler; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.response.ChatResponse; +import dev.langchain4j.model.chat.response.ChatResponseMetadata; +import dev.langchain4j.model.chat.response.StreamingChatResponseHandler; import dev.langchain4j.model.output.Response; import dev.langchain4j.model.output.TokenUsage; import dev.langchain4j.service.AiServiceContext; @@ -31,16 +35,17 @@ * The main difference with the upstream implementation is the thread switch when receiving the `completion` event * when there is tool execution requests. */ -public class QuarkusAiServiceStreamingResponseHandler implements StreamingResponseHandler { +public class QuarkusAiServiceStreamingResponseHandler implements StreamingChatResponseHandler { private final Logger log = Logger.getLogger(QuarkusAiServiceStreamingResponseHandler.class); private final AiServiceContext context; private final Object memoryId; - private final Consumer tokenHandler; + private final Consumer partialResponseHandler; private final Consumer> completionHandler; private final Consumer toolExecuteHandler; + private final Consumer completeResponseHandler; private final Consumer errorHandler; private final List temporaryMemory; @@ -55,8 +60,9 @@ public class QuarkusAiServiceStreamingResponseHandler implements StreamingRespon QuarkusAiServiceStreamingResponseHandler(AiServiceContext context, Object memoryId, - Consumer tokenHandler, + Consumer partialResponseHandler, Consumer toolExecuteHandler, + Consumer completeResponseHandler, Consumer> completionHandler, Consumer errorHandler, List temporaryMemory, @@ -69,7 +75,8 @@ public class QuarkusAiServiceStreamingResponseHandler implements StreamingRespon this.context = ensureNotNull(context, "context"); this.memoryId = ensureNotNull(memoryId, "memoryId"); - this.tokenHandler = ensureNotNull(tokenHandler, "tokenHandler"); + this.partialResponseHandler = ensureNotNull(partialResponseHandler, "partialResponseHandler"); + this.completeResponseHandler = completeResponseHandler; this.completionHandler = completionHandler; this.toolExecuteHandler = toolExecuteHandler; this.errorHandler = errorHandler; @@ -92,16 +99,19 @@ public class QuarkusAiServiceStreamingResponseHandler implements StreamingRespon } } - public QuarkusAiServiceStreamingResponseHandler(AiServiceContext context, Object memoryId, Consumer tokenHandler, - Consumer toolExecuteHandler, Consumer> completionHandler, + public QuarkusAiServiceStreamingResponseHandler(AiServiceContext context, Object memoryId, + Consumer partialResponseHandler, + Consumer toolExecuteHandler, Consumer completeResponseHandler, + Consumer> completionHandler, Consumer errorHandler, List temporaryMemory, TokenUsage sum, List toolSpecifications, Map toolExecutors, boolean mustSwitchToWorkerThread, boolean switchToWorkerForEmission, Context executionContext, ExecutorService executor) { this.context = context; this.memoryId = memoryId; - this.tokenHandler = tokenHandler; + this.partialResponseHandler = ensureNotNull(partialResponseHandler, "partialResponseHandler"); this.toolExecuteHandler = toolExecuteHandler; + this.completeResponseHandler = completeResponseHandler; this.completionHandler = completionHandler; this.errorHandler = errorHandler; this.temporaryMemory = temporaryMemory; @@ -115,11 +125,11 @@ public QuarkusAiServiceStreamingResponseHandler(AiServiceContext context, Object } @Override - public void onNext(String token) { + public void onPartialResponse(String partialResponse) { execute(new Runnable() { @Override public void run() { - tokenHandler.accept(token); + partialResponseHandler.accept(partialResponse); } }); @@ -156,8 +166,8 @@ public Object call() throws Exception { } @Override - public void onComplete(Response response) { - AiMessage aiMessage = response.content(); + public void onCompleteResponse(ChatResponse completeResponse) { + AiMessage aiMessage = completeResponse.aiMessage(); if (aiMessage.hasToolExecutionRequests()) { // Tools execution may block the caller thread. When the caller thread is the event loop thread, and @@ -182,40 +192,61 @@ public void run() { QuarkusAiServiceStreamingResponseHandler.this.addToMemory(toolExecutionResultMessage); } - context.streamingChatModel.generate( - QuarkusAiServiceStreamingResponseHandler.this.messagesToSend(memoryId), + ChatRequest chatRequest = ChatRequest.builder() + .messages(messagesToSend(memoryId)) + .toolSpecifications(toolSpecifications) + .build(); + QuarkusAiServiceStreamingResponseHandler handler = new QuarkusAiServiceStreamingResponseHandler( + context, + memoryId, + partialResponseHandler, + toolExecuteHandler, + completeResponseHandler, + completionHandler, + errorHandler, + temporaryMemory, + TokenUsage.sum(tokenUsage, completeResponse.metadata().tokenUsage()), toolSpecifications, - new QuarkusAiServiceStreamingResponseHandler( - context, - memoryId, - tokenHandler, - toolExecuteHandler, - completionHandler, - errorHandler, - temporaryMemory, - TokenUsage.sum(tokenUsage, response.tokenUsage()), - toolSpecifications, - toolExecutors, - mustSwitchToWorkerThread, switchToWorkerForEmission, executionContext, executor)); + toolExecutors, + mustSwitchToWorkerThread, switchToWorkerForEmission, executionContext, executor); + context.streamingChatModel.chat(chatRequest, handler); } }); } else { - if (completionHandler != null) { + if (completeResponseHandler != null) { Runnable runnable = new Runnable() { @Override public void run() { try { + ChatResponse finalChatResponse = ChatResponse.builder() + .aiMessage(aiMessage) + .metadata(ChatResponseMetadata.builder() + .id(completeResponse.metadata().id()) + .modelName(completeResponse.metadata().modelName()) + .tokenUsage(TokenUsage.sum(tokenUsage, completeResponse.metadata().tokenUsage())) + .finishReason(completeResponse.metadata().finishReason()) + .build()) + .build(); addToMemory(aiMessage); - completionHandler.accept(Response.from( - aiMessage, - TokenUsage.sum(tokenUsage, response.tokenUsage()), - response.finishReason())); + completeResponseHandler.accept(finalChatResponse); } finally { shutdown(); // Terminal event, we can shutdown the executor } } }; execute(runnable); + } else if (completionHandler != null) { + Runnable runnable = new Runnable() { + @Override + public void run() { + Response finalResponse = Response.from(aiMessage, + TokenUsage.sum(tokenUsage, completeResponse.metadata().tokenUsage()), + completeResponse.metadata().finishReason()); + addToMemory(aiMessage); + completionHandler.accept(finalResponse); + } + }; + execute(runnable); } } } diff --git a/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/aiservice/QuarkusAiServiceTokenStream.java b/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/aiservice/QuarkusAiServiceTokenStream.java index 57a9b8276..0c9e77c22 100644 --- a/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/aiservice/QuarkusAiServiceTokenStream.java +++ b/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/aiservice/QuarkusAiServiceTokenStream.java @@ -1,7 +1,6 @@ package io.quarkiverse.langchain4j.runtime.aiservice; import static dev.langchain4j.internal.Utils.copyIfNotNull; -import static dev.langchain4j.internal.Utils.isNullOrEmpty; import static dev.langchain4j.internal.ValidationUtils.ensureNotEmpty; import static dev.langchain4j.internal.ValidationUtils.ensureNotNull; import static java.util.Collections.emptyList; @@ -15,6 +14,8 @@ import dev.langchain4j.data.message.AiMessage; import dev.langchain4j.data.message.ChatMessage; import dev.langchain4j.exception.IllegalConfigurationException; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.response.ChatResponse; import dev.langchain4j.model.output.Response; import dev.langchain4j.model.output.TokenUsage; import dev.langchain4j.rag.content.Content; @@ -42,13 +43,16 @@ public class QuarkusAiServiceTokenStream implements TokenStream { private final boolean switchToWorkerThreadForToolExecution; private final boolean switchToWorkerForEmission; - private Consumer tokenHandler; + private Consumer partialResponseHandler; private Consumer> contentsHandler; private Consumer errorHandler; private Consumer> completionHandler; private Consumer toolExecuteHandler; + private Consumer completeResponseHandler; + private int onPartialResponseInvoked; private int onNextInvoked; + private int onCompleteResponseInvoked; private int onCompleteInvoked; private int onRetrievedInvoked; private int onErrorInvoked; @@ -74,9 +78,16 @@ public QuarkusAiServiceTokenStream(List messages, this.switchToWorkerForEmission = switchToWorkerForEmission; } + @Override + public TokenStream onPartialResponse(Consumer partialResponseHandler) { + this.partialResponseHandler = partialResponseHandler; + this.onPartialResponseInvoked++; + return this; + } + @Override public TokenStream onNext(Consumer tokenHandler) { - this.tokenHandler = tokenHandler; + this.partialResponseHandler = tokenHandler; this.onNextInvoked++; return this; } @@ -95,6 +106,13 @@ public TokenStream onToolExecuted(Consumer toolExecuteHandler) { return this; } + @Override + public TokenStream onCompleteResponse(Consumer completionHandler) { + this.completeResponseHandler = completionHandler; + this.onCompleteResponseInvoked++; + return this; + } + @Override public TokenStream onComplete(Consumer> completionHandler) { this.completionHandler = completionHandler; @@ -119,11 +137,17 @@ public TokenStream ignoreErrors() { @Override public void start() { validateConfiguration(); + ChatRequest chatRequest = new ChatRequest.Builder() + .messages(messages) + .toolSpecifications(toolSpecifications) + .build(); + QuarkusAiServiceStreamingResponseHandler handler = new QuarkusAiServiceStreamingResponseHandler( context, memoryId, - tokenHandler, + partialResponseHandler, toolExecuteHandler, + completeResponseHandler, completionHandler, errorHandler, initTemporaryMemory(context, messages), @@ -138,39 +162,38 @@ public void start() { contentsHandler.accept(retrievedContents); } - if (isNullOrEmpty(toolSpecifications)) { - context.streamingChatModel.generate(messages, handler); - } else { - try { - // Some model do not support function calling with tool specifications - context.streamingChatModel.generate(messages, toolSpecifications, handler); - } catch (Exception e) { - if (errorHandler != null) { - errorHandler.accept(e); - } + try { + // Some model do not support function calling with tool specifications + context.streamingChatModel.chat(chatRequest, handler); + } catch (Exception e) { + if (errorHandler != null) { + errorHandler.accept(e); } } } private void validateConfiguration() { - if (onNextInvoked != 1) { - throw new IllegalConfigurationException("onNext must be invoked exactly 1 time"); + if (onPartialResponseInvoked + onNextInvoked != 1) { + throw new IllegalConfigurationException("One of [onPartialResponse, onNext] " + + "must be invoked on TokenStream exactly 1 time"); } - if (onCompleteInvoked > 1) { - throw new IllegalConfigurationException("onComplete must be invoked at most 1 time"); + if (onCompleteResponseInvoked + onCompleteInvoked > 1) { + throw new IllegalConfigurationException("One of [onCompleteResponse, onComplete] " + + "can be invoked on TokenStream at most 1 time"); } if (onRetrievedInvoked > 1) { - throw new IllegalConfigurationException("onRetrieved must be invoked at most 1 time"); + throw new IllegalConfigurationException("onRetrieved can be invoked on TokenStream at most 1 time"); } if (toolExecuteInvoked > 1) { - throw new IllegalConfigurationException("onToolExecuted must be invoked at most 1 time"); + throw new IllegalConfigurationException("onToolExecuted can be invoked on TokenStream at most 1 time"); } if (onErrorInvoked + ignoreErrorsInvoked != 1) { - throw new IllegalConfigurationException("One of onError or ignoreErrors must be invoked exactly 1 time"); + throw new IllegalConfigurationException("One of [onError, ignoreErrors] " + + "must be invoked on TokenStream exactly 1 time"); } } diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index 27ba4133d..c62aa28b1 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -37,6 +37,7 @@ ** xref:dev-ui.adoc[Dev UI] ** xref:reranking.adoc[Reranking] ** xref:web-search.adoc[Web search] +** xref:mcp.adoc[Model Context Protocol] * Advanced topics ** xref:fault-tolerance.adoc[Fault Tolerance] diff --git a/docs/modules/ROOT/pages/includes/attributes.adoc b/docs/modules/ROOT/pages/includes/attributes.adoc index d389a5e92..c884e2292 100644 --- a/docs/modules/ROOT/pages/includes/attributes.adoc +++ b/docs/modules/ROOT/pages/includes/attributes.adoc @@ -1,3 +1,3 @@ :project-version: 0.23.0.CR1 -:langchain4j-version: 0.36.2 +:langchain4j-version: 1.0.0-alpha1 :examples-dir: ./../examples/ \ No newline at end of file diff --git a/docs/modules/ROOT/pages/includes/quarkus-langchain4j-mcp.adoc b/docs/modules/ROOT/pages/includes/quarkus-langchain4j-mcp.adoc new file mode 100644 index 000000000..4c31bb9dc --- /dev/null +++ b/docs/modules/ROOT/pages/includes/quarkus-langchain4j-mcp.adoc @@ -0,0 +1,174 @@ +:summaryTableId: quarkus-langchain4j-mcp_quarkus-langchain4j +[.configuration-legend] +icon:lock[title=Fixed at build time] Configuration property fixed at build time - All other configuration properties are overridable at runtime +[.configuration-reference.searchable, cols="80,.^10,.^10"] +|=== + +h|[.header-title]##Configuration property## +h|Type +h|Default + +h|[[quarkus-langchain4j-mcp_section_quarkus-langchain4j-mcp]] [.section-name.section-level0]##link:#quarkus-langchain4j-mcp_section_quarkus-langchain4j-mcp[Configured MCP clients]## +h|Type +h|Default + +a|icon:lock[title=Fixed at build time] [[quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-client-name-transport-type]] [.property-path]##link:#quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-client-name-transport-type[`quarkus.langchain4j.mcp."client-name".transport-type`]## + +[.description] +-- +Transport type + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_LANGCHAIN4J_MCP__CLIENT_NAME__TRANSPORT_TYPE+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_LANGCHAIN4J_MCP__CLIENT_NAME__TRANSPORT_TYPE+++` +endif::add-copy-button-to-env-var[] +-- +a|`stdio`, `http` +|required icon:exclamation-circle[title=Configuration property is required] + +a|icon:lock[title=Fixed at build time] [[quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-client-name-url]] [.property-path]##link:#quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-client-name-url[`quarkus.langchain4j.mcp."client-name".url`]## + +[.description] +-- +The URL of the SSE endpoint. This only applies to MCP clients using the HTTP transport. + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_LANGCHAIN4J_MCP__CLIENT_NAME__URL+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_LANGCHAIN4J_MCP__CLIENT_NAME__URL+++` +endif::add-copy-button-to-env-var[] +-- +|string +| + +a|icon:lock[title=Fixed at build time] [[quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-client-name-command]] [.property-path]##link:#quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-client-name-command[`quarkus.langchain4j.mcp."client-name".command`]## + +[.description] +-- +The command to execute to spawn the MCP server process. This only applies to MCP clients using the STDIO transport. + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_LANGCHAIN4J_MCP__CLIENT_NAME__COMMAND+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_LANGCHAIN4J_MCP__CLIENT_NAME__COMMAND+++` +endif::add-copy-button-to-env-var[] +-- +|list of string +| + +a|icon:lock[title=Fixed at build time] [[quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-client-name-environment-env-var]] [.property-path]##link:#quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-client-name-environment-env-var[`quarkus.langchain4j.mcp."client-name".environment."env-var"`]## + +[.description] +-- +Environment variables for the spawned MCP server process. This only applies to MCP clients using the STDIO transport. + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_LANGCHAIN4J_MCP__CLIENT_NAME__ENVIRONMENT__ENV_VAR_+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_LANGCHAIN4J_MCP__CLIENT_NAME__ENVIRONMENT__ENV_VAR_+++` +endif::add-copy-button-to-env-var[] +-- +|Map +| + +a|icon:lock[title=Fixed at build time] [[quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-client-name-log-requests]] [.property-path]##link:#quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-client-name-log-requests[`quarkus.langchain4j.mcp."client-name".log-requests`]## + +[.description] +-- +Whether to log requests + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_LANGCHAIN4J_MCP__CLIENT_NAME__LOG_REQUESTS+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_LANGCHAIN4J_MCP__CLIENT_NAME__LOG_REQUESTS+++` +endif::add-copy-button-to-env-var[] +-- +|boolean +|`false` + +a|icon:lock[title=Fixed at build time] [[quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-client-name-log-responses]] [.property-path]##link:#quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-client-name-log-responses[`quarkus.langchain4j.mcp."client-name".log-responses`]## + +[.description] +-- +Whether to log responses + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_LANGCHAIN4J_MCP__CLIENT_NAME__LOG_RESPONSES+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_LANGCHAIN4J_MCP__CLIENT_NAME__LOG_RESPONSES+++` +endif::add-copy-button-to-env-var[] +-- +|boolean +|`false` + +a|icon:lock[title=Fixed at build time] [[quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-client-name-tool-execution-timeout]] [.property-path]##link:#quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-client-name-tool-execution-timeout[`quarkus.langchain4j.mcp."client-name".tool-execution-timeout`]## + +[.description] +-- +Timeout for tool executions performed by the MCP client + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_LANGCHAIN4J_MCP__CLIENT_NAME__TOOL_EXECUTION_TIMEOUT+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_LANGCHAIN4J_MCP__CLIENT_NAME__TOOL_EXECUTION_TIMEOUT+++` +endif::add-copy-button-to-env-var[] +-- +|link:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html[Duration] link:#duration-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the Duration format]] +|`60S` + + +a|icon:lock[title=Fixed at build time] [[quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-generate-tool-provider]] [.property-path]##link:#quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-generate-tool-provider[`quarkus.langchain4j.mcp.generate-tool-provider`]## + +[.description] +-- +Whether the MCP extension should automatically generate a ToolProvider that is wired up to all the configured MCP clients. The default is true if at least one MCP client is configured, false otherwise. + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_LANGCHAIN4J_MCP_GENERATE_TOOL_PROVIDER+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_LANGCHAIN4J_MCP_GENERATE_TOOL_PROVIDER+++` +endif::add-copy-button-to-env-var[] +-- +|boolean +|`true` + +|=== + +ifndef::no-duration-note[] +[NOTE] +[id=duration-note-anchor-quarkus-langchain4j-mcp_quarkus-langchain4j] +.About the Duration format +==== +To write duration values, use the standard `java.time.Duration` format. +See the link:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html#parse(java.lang.CharSequence)[Duration#parse() Java API documentation] for more information. + +You can also use a simplified format, starting with a number: + +* If the value is only a number, it represents time in seconds. +* If the value is a number followed by `ms`, it represents time in milliseconds. + +In other cases, the simplified format is translated to the `java.time.Duration` format for parsing: + +* If the value is a number followed by `h`, `m`, or `s`, it is prefixed with `PT`. +* If the value is a number followed by `d`, it is prefixed with `P`. +==== +endif::no-duration-note[] + +:!summaryTableId: \ No newline at end of file diff --git a/docs/modules/ROOT/pages/includes/quarkus-langchain4j-mcp_quarkus.langchain4j.adoc b/docs/modules/ROOT/pages/includes/quarkus-langchain4j-mcp_quarkus.langchain4j.adoc new file mode 100644 index 000000000..4c31bb9dc --- /dev/null +++ b/docs/modules/ROOT/pages/includes/quarkus-langchain4j-mcp_quarkus.langchain4j.adoc @@ -0,0 +1,174 @@ +:summaryTableId: quarkus-langchain4j-mcp_quarkus-langchain4j +[.configuration-legend] +icon:lock[title=Fixed at build time] Configuration property fixed at build time - All other configuration properties are overridable at runtime +[.configuration-reference.searchable, cols="80,.^10,.^10"] +|=== + +h|[.header-title]##Configuration property## +h|Type +h|Default + +h|[[quarkus-langchain4j-mcp_section_quarkus-langchain4j-mcp]] [.section-name.section-level0]##link:#quarkus-langchain4j-mcp_section_quarkus-langchain4j-mcp[Configured MCP clients]## +h|Type +h|Default + +a|icon:lock[title=Fixed at build time] [[quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-client-name-transport-type]] [.property-path]##link:#quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-client-name-transport-type[`quarkus.langchain4j.mcp."client-name".transport-type`]## + +[.description] +-- +Transport type + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_LANGCHAIN4J_MCP__CLIENT_NAME__TRANSPORT_TYPE+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_LANGCHAIN4J_MCP__CLIENT_NAME__TRANSPORT_TYPE+++` +endif::add-copy-button-to-env-var[] +-- +a|`stdio`, `http` +|required icon:exclamation-circle[title=Configuration property is required] + +a|icon:lock[title=Fixed at build time] [[quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-client-name-url]] [.property-path]##link:#quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-client-name-url[`quarkus.langchain4j.mcp."client-name".url`]## + +[.description] +-- +The URL of the SSE endpoint. This only applies to MCP clients using the HTTP transport. + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_LANGCHAIN4J_MCP__CLIENT_NAME__URL+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_LANGCHAIN4J_MCP__CLIENT_NAME__URL+++` +endif::add-copy-button-to-env-var[] +-- +|string +| + +a|icon:lock[title=Fixed at build time] [[quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-client-name-command]] [.property-path]##link:#quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-client-name-command[`quarkus.langchain4j.mcp."client-name".command`]## + +[.description] +-- +The command to execute to spawn the MCP server process. This only applies to MCP clients using the STDIO transport. + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_LANGCHAIN4J_MCP__CLIENT_NAME__COMMAND+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_LANGCHAIN4J_MCP__CLIENT_NAME__COMMAND+++` +endif::add-copy-button-to-env-var[] +-- +|list of string +| + +a|icon:lock[title=Fixed at build time] [[quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-client-name-environment-env-var]] [.property-path]##link:#quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-client-name-environment-env-var[`quarkus.langchain4j.mcp."client-name".environment."env-var"`]## + +[.description] +-- +Environment variables for the spawned MCP server process. This only applies to MCP clients using the STDIO transport. + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_LANGCHAIN4J_MCP__CLIENT_NAME__ENVIRONMENT__ENV_VAR_+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_LANGCHAIN4J_MCP__CLIENT_NAME__ENVIRONMENT__ENV_VAR_+++` +endif::add-copy-button-to-env-var[] +-- +|Map +| + +a|icon:lock[title=Fixed at build time] [[quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-client-name-log-requests]] [.property-path]##link:#quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-client-name-log-requests[`quarkus.langchain4j.mcp."client-name".log-requests`]## + +[.description] +-- +Whether to log requests + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_LANGCHAIN4J_MCP__CLIENT_NAME__LOG_REQUESTS+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_LANGCHAIN4J_MCP__CLIENT_NAME__LOG_REQUESTS+++` +endif::add-copy-button-to-env-var[] +-- +|boolean +|`false` + +a|icon:lock[title=Fixed at build time] [[quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-client-name-log-responses]] [.property-path]##link:#quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-client-name-log-responses[`quarkus.langchain4j.mcp."client-name".log-responses`]## + +[.description] +-- +Whether to log responses + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_LANGCHAIN4J_MCP__CLIENT_NAME__LOG_RESPONSES+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_LANGCHAIN4J_MCP__CLIENT_NAME__LOG_RESPONSES+++` +endif::add-copy-button-to-env-var[] +-- +|boolean +|`false` + +a|icon:lock[title=Fixed at build time] [[quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-client-name-tool-execution-timeout]] [.property-path]##link:#quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-client-name-tool-execution-timeout[`quarkus.langchain4j.mcp."client-name".tool-execution-timeout`]## + +[.description] +-- +Timeout for tool executions performed by the MCP client + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_LANGCHAIN4J_MCP__CLIENT_NAME__TOOL_EXECUTION_TIMEOUT+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_LANGCHAIN4J_MCP__CLIENT_NAME__TOOL_EXECUTION_TIMEOUT+++` +endif::add-copy-button-to-env-var[] +-- +|link:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html[Duration] link:#duration-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the Duration format]] +|`60S` + + +a|icon:lock[title=Fixed at build time] [[quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-generate-tool-provider]] [.property-path]##link:#quarkus-langchain4j-mcp_quarkus-langchain4j-mcp-generate-tool-provider[`quarkus.langchain4j.mcp.generate-tool-provider`]## + +[.description] +-- +Whether the MCP extension should automatically generate a ToolProvider that is wired up to all the configured MCP clients. The default is true if at least one MCP client is configured, false otherwise. + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_LANGCHAIN4J_MCP_GENERATE_TOOL_PROVIDER+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_LANGCHAIN4J_MCP_GENERATE_TOOL_PROVIDER+++` +endif::add-copy-button-to-env-var[] +-- +|boolean +|`true` + +|=== + +ifndef::no-duration-note[] +[NOTE] +[id=duration-note-anchor-quarkus-langchain4j-mcp_quarkus-langchain4j] +.About the Duration format +==== +To write duration values, use the standard `java.time.Duration` format. +See the link:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html#parse(java.lang.CharSequence)[Duration#parse() Java API documentation] for more information. + +You can also use a simplified format, starting with a number: + +* If the value is only a number, it represents time in seconds. +* If the value is a number followed by `ms`, it represents time in milliseconds. + +In other cases, the simplified format is translated to the `java.time.Duration` format for parsing: + +* If the value is a number followed by `h`, `m`, or `s`, it is prefixed with `PT`. +* If the value is a number followed by `d`, it is prefixed with `P`. +==== +endif::no-duration-note[] + +:!summaryTableId: \ No newline at end of file diff --git a/docs/modules/ROOT/pages/includes/quarkus-langchain4j-milvus.adoc b/docs/modules/ROOT/pages/includes/quarkus-langchain4j-milvus.adoc index 6191f178d..f1b9fd3ce 100644 --- a/docs/modules/ROOT/pages/includes/quarkus-langchain4j-milvus.adoc +++ b/docs/modules/ROOT/pages/includes/quarkus-langchain4j-milvus.adoc @@ -328,7 +328,7 @@ ifndef::add-copy-button-to-env-var[] Environment variable: `+++QUARKUS_LANGCHAIN4J_MILVUS_INDEX_TYPE+++` endif::add-copy-button-to-env-var[] -- -a|`none`, `flat`, `ivf-flat`, `ivf-sq8`, `ivf-pq`, `hnsw`, `diskann`, `autoindex`, `scann`, `gpu-ivf-flat`, `gpu-ivf-pq`, `bin-flat`, `bin-ivf-flat`, `trie`, `stl-sort` +a|`none`, `flat`, `ivf-flat`, `ivf-sq8`, `ivf-pq`, `hnsw`, `diskann`, `autoindex`, `scann`, `gpu-ivf-flat`, `gpu-ivf-pq`, `gpu-brute-force`, `gpu-cagra`, `bin-flat`, `bin-ivf-flat`, `trie`, `stl-sort`, `inverted`, `sparse-inverted-index`, `sparse-wand` |`flat` a| [[quarkus-langchain4j-milvus_quarkus-langchain4j-milvus-metric-type]] [.property-path]##link:#quarkus-langchain4j-milvus_quarkus-langchain4j-milvus-metric-type[`quarkus.langchain4j.milvus.metric-type`]## diff --git a/docs/modules/ROOT/pages/includes/quarkus-langchain4j-milvus_quarkus.langchain4j.adoc b/docs/modules/ROOT/pages/includes/quarkus-langchain4j-milvus_quarkus.langchain4j.adoc index 6191f178d..f1b9fd3ce 100644 --- a/docs/modules/ROOT/pages/includes/quarkus-langchain4j-milvus_quarkus.langchain4j.adoc +++ b/docs/modules/ROOT/pages/includes/quarkus-langchain4j-milvus_quarkus.langchain4j.adoc @@ -328,7 +328,7 @@ ifndef::add-copy-button-to-env-var[] Environment variable: `+++QUARKUS_LANGCHAIN4J_MILVUS_INDEX_TYPE+++` endif::add-copy-button-to-env-var[] -- -a|`none`, `flat`, `ivf-flat`, `ivf-sq8`, `ivf-pq`, `hnsw`, `diskann`, `autoindex`, `scann`, `gpu-ivf-flat`, `gpu-ivf-pq`, `bin-flat`, `bin-ivf-flat`, `trie`, `stl-sort` +a|`none`, `flat`, `ivf-flat`, `ivf-sq8`, `ivf-pq`, `hnsw`, `diskann`, `autoindex`, `scann`, `gpu-ivf-flat`, `gpu-ivf-pq`, `gpu-brute-force`, `gpu-cagra`, `bin-flat`, `bin-ivf-flat`, `trie`, `stl-sort`, `inverted`, `sparse-inverted-index`, `sparse-wand` |`flat` a| [[quarkus-langchain4j-milvus_quarkus-langchain4j-milvus-metric-type]] [.property-path]##link:#quarkus-langchain4j-milvus_quarkus-langchain4j-milvus-metric-type[`quarkus.langchain4j.milvus.metric-type`]## diff --git a/docs/modules/ROOT/pages/mcp.adoc b/docs/modules/ROOT/pages/mcp.adoc new file mode 100644 index 000000000..c3042de40 --- /dev/null +++ b/docs/modules/ROOT/pages/mcp.adoc @@ -0,0 +1,37 @@ += Model Context Protocol + +LangChain4j supports the Model Context Protocol (MCP) to communicate with +MCP compliant servers that can provide and execute tools. General +information about the protocol can be found at the +https://modelcontextprotocol.io/[MCP website]. More detailed information can +also be found in the https://docs.langchain4j.dev/tutorials/mcp[LangChain4j +documentation], this documentation focuses on features that Quarkus provides +on top of the upstream module. For an example project that uses MCP, see +https://github.com/quarkiverse/quarkus-langchain4j/tree/main/samples/mcp-tools[mcp-tools] +project in the `quarkus-langchain4j` repository. + +NOTE: There is also a Quarkus extension for developing MCP servers. See +https://github.com/quarkiverse/quarkus-mcp-server[GitHub repo] and +https://docs.quarkiverse.io/quarkus-mcp-server/dev/index.html[documentation]. + +== Declaratively generating a tool provider backed by MCP + +Quarkus offers a way to generate a tool provider backed by one or more MCP +servers declaratively from the configuration model. When using this, all AI +services that don't explicitly declare to use a different tool provider will +then be wired up to it, without having to write any MCP-specific code in the +AI service. Example: + +[source,properties] +---- +quarkus.langchain4j.mcp.github.transport-type=stdio +quarkus.langchain4j.mcp.github.command=npm,exec,@modelcontextprotocol/server-github +quarkus.langchain4j.mcp.github.environment.GITHUB_PERSONAL_ACCESS_TOKEN= +---- + +With this configuration, Quarkus will generate a tool provider that talks to the `server-github` +MCP server. The server will be started automatically as a subprocess using the provided command +(`npm exec @modelcontextprotocol/server-github`). The `environment.*` properties define +environment variables that will be passed to the subprocess. With this configuration, any +AI Service that does not declare a specific tool provider will be wired to this one. + diff --git a/docs/pom.xml b/docs/pom.xml index 085f4610d..8d2edff4d 100644 --- a/docs/pom.xml +++ b/docs/pom.xml @@ -81,6 +81,11 @@ quarkus-langchain4j-easy-rag ${project.version} + + io.quarkiverse.langchain4j + quarkus-langchain4j-mcp + ${project.version} + io.quarkiverse.antora quarkus-antora diff --git a/embedding-stores/chroma/runtime/src/main/java/io/quarkiverse/langchain4j/chroma/ChromaEmbeddingStore.java b/embedding-stores/chroma/runtime/src/main/java/io/quarkiverse/langchain4j/chroma/ChromaEmbeddingStore.java index 57e47d1e9..5b2023c1f 100644 --- a/embedding-stores/chroma/runtime/src/main/java/io/quarkiverse/langchain4j/chroma/ChromaEmbeddingStore.java +++ b/embedding-stores/chroma/runtime/src/main/java/io/quarkiverse/langchain4j/chroma/ChromaEmbeddingStore.java @@ -179,6 +179,11 @@ public List addAll(List embeddings, List textSeg return ids; } + @Override + public void addAll(List ids, List embeddings, List embedded) { + addAllInternal(ids, embeddings, embedded); + } + private void addInternal(String id, Embedding embedding, TextSegment textSegment) { addAllInternal(singletonList(id), singletonList(embedding), textSegment == null ? null : singletonList(textSegment)); } diff --git a/embedding-stores/infinispan/runtime/src/main/java/io/quarkiverse/langchain4j/infinispan/InfinispanEmbeddingStore.java b/embedding-stores/infinispan/runtime/src/main/java/io/quarkiverse/langchain4j/infinispan/InfinispanEmbeddingStore.java index d1bd4bc45..f710997e3 100644 --- a/embedding-stores/infinispan/runtime/src/main/java/io/quarkiverse/langchain4j/infinispan/InfinispanEmbeddingStore.java +++ b/embedding-stores/infinispan/runtime/src/main/java/io/quarkiverse/langchain4j/infinispan/InfinispanEmbeddingStore.java @@ -87,6 +87,11 @@ public List addAll(List embeddings, List embedde return ids; } + @Override + public void addAll(List ids, List embeddings, List embedded) { + addAllInternal(ids, embeddings, embedded); + } + private void addInternal(String id, Embedding embedding, TextSegment embedded) { addAllInternal(singletonList(id), singletonList(embedding), embedded == null ? null : singletonList(embedded)); } diff --git a/embedding-stores/pinecone/runtime/src/main/java/io/quarkiverse/langchain4j/pinecone/PineconeEmbeddingStore.java b/embedding-stores/pinecone/runtime/src/main/java/io/quarkiverse/langchain4j/pinecone/PineconeEmbeddingStore.java index c72e3c9ed..fb3c8402f 100644 --- a/embedding-stores/pinecone/runtime/src/main/java/io/quarkiverse/langchain4j/pinecone/PineconeEmbeddingStore.java +++ b/embedding-stores/pinecone/runtime/src/main/java/io/quarkiverse/langchain4j/pinecone/PineconeEmbeddingStore.java @@ -150,6 +150,11 @@ public List addAll(List embeddings, List embedde return ids; } + @Override + public void addAll(List ids, List embeddings, List embedded) { + addAllInternal(ids, embeddings, embedded); + } + @Override public List> findRelevant(Embedding embedding, int maxResults, double minScore) { indexExists.get(); diff --git a/embedding-stores/redis/runtime/src/main/java/io/quarkiverse/langchain4j/redis/RedisEmbeddingStore.java b/embedding-stores/redis/runtime/src/main/java/io/quarkiverse/langchain4j/redis/RedisEmbeddingStore.java index 93d447e1e..264955c64 100644 --- a/embedding-stores/redis/runtime/src/main/java/io/quarkiverse/langchain4j/redis/RedisEmbeddingStore.java +++ b/embedding-stores/redis/runtime/src/main/java/io/quarkiverse/langchain4j/redis/RedisEmbeddingStore.java @@ -120,6 +120,11 @@ public List addAll(List embeddings, List embedde return ids; } + @Override + public void addAll(List ids, List embeddings, List embedded) { + addAllInternal(ids, embeddings, embedded); + } + private void addInternal(String id, Embedding embedding, TextSegment embedded) { addAllInternal(singletonList(id), singletonList(embedding), embedded == null ? null : singletonList(embedded)); } diff --git a/mcp/deployment/pom.xml b/mcp/deployment/pom.xml new file mode 100644 index 000000000..3ae1161d2 --- /dev/null +++ b/mcp/deployment/pom.xml @@ -0,0 +1,59 @@ + + + 4.0.0 + + io.quarkiverse.langchain4j + quarkus-langchain4j-mcp-parent + 999-SNAPSHOT + + quarkus-langchain4j-mcp-deployment + Quarkus LangChain4j - Model Context Protocol - Deployment + + + io.quarkiverse.langchain4j + quarkus-langchain4j-core-deployment + ${project.version} + + + io.quarkiverse.langchain4j + quarkus-langchain4j-mcp + ${project.version} + + + io.quarkus + quarkus-rest-client-jackson-deployment + + + io.quarkus + quarkus-rest-jackson + test + + + io.quarkus + quarkus-junit5-internal + test + + + org.assertj + assertj-core + ${assertj.version} + test + + + + + + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${quarkus.version} + + + + + + + diff --git a/mcp/deployment/src/main/java/io/quarkiverse/langchain4j/mcp/deployment/McpProcessor.java b/mcp/deployment/src/main/java/io/quarkiverse/langchain4j/mcp/deployment/McpProcessor.java new file mode 100644 index 000000000..8536e960f --- /dev/null +++ b/mcp/deployment/src/main/java/io/quarkiverse/langchain4j/mcp/deployment/McpProcessor.java @@ -0,0 +1,75 @@ +package io.quarkiverse.langchain4j.mcp.deployment; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import jakarta.enterprise.context.ApplicationScoped; + +import org.jboss.jandex.AnnotationInstance; +import org.jboss.jandex.ClassType; +import org.jboss.jandex.DotName; + +import dev.langchain4j.mcp.client.McpClient; +import dev.langchain4j.service.tool.ToolProvider; +import io.quarkiverse.langchain4j.mcp.runtime.McpClientName; +import io.quarkiverse.langchain4j.mcp.runtime.McpRecorder; +import io.quarkiverse.langchain4j.mcp.runtime.config.McpClientConfig; +import io.quarkiverse.langchain4j.mcp.runtime.config.McpConfiguration; +import io.quarkus.arc.deployment.SyntheticBeanBuildItem; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.ExecutionTime; +import io.quarkus.deployment.annotations.Record; + +public class McpProcessor { + + private static final DotName MCP_CLIENT = DotName.createSimple(McpClient.class); + private static final DotName MCP_CLIENT_NAME = DotName.createSimple(McpClientName.class); + private static final DotName TOOL_PROVIDER = DotName.createSimple(ToolProvider.class); + + @BuildStep + @Record(ExecutionTime.RUNTIME_INIT) + public void registerMcpClients(McpConfiguration mcpConfiguration, + BuildProducer beanProducer, + McpRecorder recorder) { + if (mcpConfiguration.clients() != null && !mcpConfiguration.clients().isEmpty()) { + // generate MCP clients + List qualifiers = new ArrayList<>(); + for (Map.Entry client : mcpConfiguration.clients() + .entrySet()) { + AnnotationInstance qualifier = AnnotationInstance.builder(MCP_CLIENT_NAME) + .add("value", client.getKey()) + .build(); + qualifiers.add(qualifier); + beanProducer.produce(SyntheticBeanBuildItem + .configure(MCP_CLIENT) + .addQualifier(qualifier) + .setRuntimeInit() + .defaultBean() + .unremovable() + // TODO: should we allow other scopes? + .scope(ApplicationScoped.class) + .supplier(recorder.mcpClientSupplier(client.getKey(), mcpConfiguration)) + .done()); + } + // generate a tool provider if configured to do so + if (mcpConfiguration.generateToolProvider().orElse(true)) { + Set mcpClientNames = mcpConfiguration.clients().keySet(); + SyntheticBeanBuildItem.ExtendedBeanConfigurator configurator = SyntheticBeanBuildItem + .configure(TOOL_PROVIDER) + .addType(ClassType.create(TOOL_PROVIDER)) + .setRuntimeInit() + .defaultBean() + .unremovable() + .scope(ApplicationScoped.class) + .createWith(recorder.toolProviderFunction(mcpClientNames)); + for (AnnotationInstance qualifier : qualifiers) { + configurator.addInjectionPoint(ClassType.create(MCP_CLIENT), qualifier); + } + beanProducer.produce(configurator.done()); + } + } + } +} diff --git a/mcp/deployment/src/test/java/io/quarkiverse/langchain4j/mcp/test/McpClientAndToolProviderCDITest.java b/mcp/deployment/src/test/java/io/quarkiverse/langchain4j/mcp/test/McpClientAndToolProviderCDITest.java new file mode 100644 index 000000000..92537d324 --- /dev/null +++ b/mcp/deployment/src/test/java/io/quarkiverse/langchain4j/mcp/test/McpClientAndToolProviderCDITest.java @@ -0,0 +1,56 @@ +package io.quarkiverse.langchain4j.mcp.test; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import dev.langchain4j.mcp.McpToolProvider; +import dev.langchain4j.mcp.client.DefaultMcpClient; +import dev.langchain4j.mcp.client.McpClient; +import dev.langchain4j.service.tool.ToolProvider; +import io.quarkiverse.langchain4j.mcp.runtime.McpClientName; +import io.quarkus.arc.ClientProxy; +import io.quarkus.test.QuarkusUnitTest; + +public class McpClientAndToolProviderCDITest { + + @RegisterExtension + static QuarkusUnitTest unitTest = new QuarkusUnitTest() + .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) + .addClass(MockHttpMcpServer.class) + .addAsResource(new StringAsset(""" + quarkus.langchain4j.mcp.client1.transport-type=http + quarkus.langchain4j.mcp.client1.url=http://localhost:${quarkus.http.test-port}/mock-mcp/sse + quarkus.langchain4j.mcp.client1.log-requests=true + quarkus.langchain4j.mcp.client1.log-responses=true + quarkus.log.category."dev.langchain4j".level=DEBUG + quarkus.log.category."io.quarkiverse".level=DEBUG + """), + "application.properties")); + + @Inject + @McpClientName("client1") + Instance clientCDIInstance; + + @Inject + Instance toolProviderCDIInstance; + + @Test + public void test() { + McpClient client = clientCDIInstance.get(); + assertThat(client).isNotNull(); + assertThat(ClientProxy.unwrap(client)).isInstanceOf(DefaultMcpClient.class); + + ToolProvider provider = toolProviderCDIInstance.get(); + assertThat(provider).isNotNull(); + assertThat(ClientProxy.unwrap(provider)).isInstanceOf(McpToolProvider.class); + } + +} diff --git a/mcp/deployment/src/test/java/io/quarkiverse/langchain4j/mcp/test/McpOverHttpTransportTest.java b/mcp/deployment/src/test/java/io/quarkiverse/langchain4j/mcp/test/McpOverHttpTransportTest.java new file mode 100644 index 000000000..3e4c9f859 --- /dev/null +++ b/mcp/deployment/src/test/java/io/quarkiverse/langchain4j/mcp/test/McpOverHttpTransportTest.java @@ -0,0 +1,129 @@ +package io.quarkiverse.langchain4j.mcp.test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIterable; + +import java.util.Set; +import java.util.stream.Collectors; + +import jakarta.inject.Inject; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import dev.langchain4j.agent.tool.ToolExecutionRequest; +import dev.langchain4j.agent.tool.ToolSpecification; +import dev.langchain4j.model.chat.request.json.JsonNumberSchema; +import dev.langchain4j.service.tool.ToolExecutor; +import dev.langchain4j.service.tool.ToolProvider; +import dev.langchain4j.service.tool.ToolProviderResult; +import io.quarkus.test.QuarkusUnitTest; + +/** + * Test MCP clients over an HTTP transport. + * This is a very rudimentary test that runs against a mock MCP server. The plan is + * to replace it with a more proper MCP server once we have an appropriate Java SDK ready for it. + */ +public class McpOverHttpTransportTest { + + @RegisterExtension + static QuarkusUnitTest unitTest = new QuarkusUnitTest() + .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) + .addClass(MockHttpMcpServer.class) + .addAsResource(new StringAsset(""" + quarkus.langchain4j.mcp.client1.transport-type=http + quarkus.langchain4j.mcp.client1.url=http://localhost:${quarkus.http.test-port}/mock-mcp/sse + quarkus.langchain4j.mcp.client1.log-requests=true + quarkus.langchain4j.mcp.client1.log-responses=true + quarkus.log.category."dev.langchain4j".level=DEBUG + quarkus.log.category."io.quarkiverse".level=DEBUG + quarkus.langchain4j.mcp.client1.tool-execution-timeout=1s + """), + "application.properties")); + + @Inject + ToolProvider toolProvider; + + @Test + public void providingTools() { + // obtain a list of tools from the MCP server + ToolProviderResult toolProviderResult = toolProvider.provideTools(null); + + // verify the list of tools + assertThat(toolProviderResult.tools().size()).isEqualTo(2); + Set toolNames = toolProviderResult.tools().keySet().stream() + .map(ToolSpecification::name) + .collect(Collectors.toSet()); + assertThatIterable(toolNames) + .containsExactlyInAnyOrder("add", "longRunningOperation"); + + // verify the 'add' tool + ToolSpecification addTool = findToolByName(toolProviderResult, "add"); + assertThat(addTool.description()).isEqualTo("Adds two numbers"); + JsonNumberSchema a = (JsonNumberSchema) addTool.parameters().properties().get("a"); + assertThat(a.description().equals("First number")); + JsonNumberSchema b = (JsonNumberSchema) addTool.parameters().properties().get("b"); + assertThat(b.description().equals("Second number")); + + // verify the 'longRunningOperation' tool + ToolSpecification longRunningOperationTool = findToolByName(toolProviderResult, "longRunningOperation"); + assertThat(longRunningOperationTool.description()) + .isEqualTo("Demonstrates a long running operation with progress updates"); + JsonNumberSchema duration = (JsonNumberSchema) longRunningOperationTool.parameters().properties().get("duration"); + assertThat(duration.description().equals("Duration of the operation in seconds")); + JsonNumberSchema steps = (JsonNumberSchema) longRunningOperationTool.parameters().properties().get("steps"); + assertThat(steps.description().equals("Number of steps in the operation")); + } + + private ToolSpecification findToolByName(ToolProviderResult toolProviderResult, String name) { + return toolProviderResult.tools().keySet().stream() + .filter(toolSpecification -> toolSpecification.name().equals(name)) + .findFirst() + .get(); + } + + @Test + public void executingATool() { + // obtain tools from the server + ToolProviderResult toolProviderResult = toolProvider.provideTools(null); + + // find the 'add' tool and execute it on the MCP server + ToolExecutor executor = toolProviderResult.tools().entrySet().stream() + .filter(entry -> entry.getKey().name().equals("add")) + .findFirst() + .get() + .getValue(); + ToolExecutionRequest toolExecutionRequest = ToolExecutionRequest.builder() + .name("add") + .arguments("{\"a\": 5, \"b\": 12}") + .build(); + String toolExecutionResultString = executor.execute(toolExecutionRequest, null); + + // validate the tool execution result + assertThat(toolExecutionResultString).isEqualTo("The sum of 5 and 12 is 17."); + } + + @Test + public void timeout() { + // obtain tools from the server + ToolProviderResult toolProviderResult = toolProvider.provideTools(null); + + // find the 'longRunningOperation' tool and execute it on the MCP server + ToolExecutor executor = toolProviderResult.tools().entrySet().stream() + .filter(entry -> entry.getKey().name().equals("longRunningOperation")) + .findFirst() + .get() + .getValue(); + ToolExecutionRequest toolExecutionRequest = ToolExecutionRequest.builder() + .name("longRunningOperation") + .arguments("{\"duration\": 5, \"steps\": 1}") + .build(); + String toolExecutionResultString = executor.execute(toolExecutionRequest, null); + + // validate the tool execution result + assertThat(toolExecutionResultString).isEqualTo("There was a timeout executing the tool"); + } +} diff --git a/mcp/deployment/src/test/java/io/quarkiverse/langchain4j/mcp/test/MockHttpMcpServer.java b/mcp/deployment/src/test/java/io/quarkiverse/langchain4j/mcp/test/MockHttpMcpServer.java new file mode 100644 index 000000000..6d8519b70 --- /dev/null +++ b/mcp/deployment/src/test/java/io/quarkiverse/langchain4j/mcp/test/MockHttpMcpServer.java @@ -0,0 +1,190 @@ +package io.quarkiverse.langchain4j.mcp.test; + +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.sse.Sse; +import jakarta.ws.rs.sse.SseEventSink; + +import org.jboss.resteasy.reactive.RestStreamElementType; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * A very basic mock MCP server using the HTTP transport. + */ +@Path("/mock-mcp") +public class MockHttpMcpServer { + + // language=JSON + public static final String TOOLS_LIST_RESPONSE = """ + { + "result": { + "tools": [ + { + "name": "longRunningOperation", + "description": "Demonstrates a long running operation with progress updates", + "inputSchema": { + "type": "object", + "properties": { + "duration": { + "type": "number", + "default": 10, + "description": "Duration of the operation in seconds" + }, + "steps": { + "type": "number", + "default": 5, + "description": "Number of steps in the operation" + } + }, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "add", + "description": "Adds two numbers", + "inputSchema": { + "type": "object", + "properties": { + "a": { + "type": "number", + "description": "First number" + }, + "b": { + "type": "number", + "description": "Second number" + } + }, + "required": [ + "a", + "b" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + } + ] + }, + "jsonrpc": "2.0", + "id": "%s" + } + """; + + private volatile SseEventSink sink; + private volatile Sse sse; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Inject + ScheduledExecutorService scheduledExecutorService; + + @Path("/sse") + @Produces(MediaType.SERVER_SENT_EVENTS) + @RestStreamElementType(MediaType.TEXT_PLAIN) + public void sse(@Context SseEventSink sink, @Context Sse sse) { + this.sink = sink; + this.sse = sse; + sink.send(sse.newEventBuilder() + .id("id") + .name("endpoint") + .mediaType(MediaType.TEXT_PLAIN_TYPE) + .data("/mock-mcp/post") + .build()); + } + + @Path("/post") + @Consumes(MediaType.APPLICATION_JSON) + @POST + public Response post(JsonNode message) { + String method = message.get("method").asText(); + if (method.equals("notifications/cancelled")) { + return Response.ok().build(); + } + String operationId = message.get("id").asText(); + if (method.equals("initialize")) { + initialize(operationId); + } else if (method.equals("tools/list")) { + listTools(operationId); + } else if (method.equals("tools/call")) { + if (message.get("params").get("name").asText().equals("add")) { + executeAddOperation(message, operationId); + } else if (message.get("params").get("name").asText().equals("longRunningOperation")) { + executeLongRunningOperation(message, operationId); + } else { + return Response.serverError().entity("Unknown operation").build(); + } + } + return Response.accepted().build(); + } + + private void listTools(String operationId) { + String response = TOOLS_LIST_RESPONSE.formatted(operationId); + sink.send(sse.newEventBuilder() + .name("message") + .data(response) + .build()); + } + + private void initialize(String operationId) { + ObjectNode initializeResponse = objectMapper.createObjectNode(); + initializeResponse + .put("id", operationId) + .put("jsonrpc", "2.0") + .putObject("result") + .put("protocolVersion", "2024-11-05"); + sink.send(sse.newEventBuilder() + .name("message") + .data(initializeResponse) + .build()); + } + + private void executeAddOperation(JsonNode message, String operationId) { + ObjectNode result = objectMapper.createObjectNode(); + result.put("id", operationId); + result.put("jsonrpc", "2.0"); + ObjectNode resultContent = objectMapper.createObjectNode(); + result.set("result", resultContent); + int a = message.get("params").get("arguments").get("a").asInt(); + int b = message.get("params").get("arguments").get("b").asInt(); + int additionResult = a + b; + resultContent.putArray("content") + .addObject() + .put("type", "text") + .put("text", "The sum of " + a + " and " + b + " is " + additionResult + "."); + sink.send(sse.newEventBuilder() + .name("message") + .data(result) + .build()); + } + + private void executeLongRunningOperation(JsonNode message, String operationId) { + int duration = message.get("params").get("arguments").get("duration").asInt(); + scheduledExecutorService.schedule(() -> { + ObjectNode result = objectMapper.createObjectNode(); + result.put("id", operationId); + result.put("jsonrpc", "2.0"); + ObjectNode resultContent = objectMapper.createObjectNode(); + result.set("result", resultContent); + resultContent.putArray("content") + .addObject() + .put("type", "text") + .put("text", "Operation completed."); + sink.send(sse.newEventBuilder() + .name("message") + .data(result) + .build()); + }, duration, TimeUnit.SECONDS); + } + +} diff --git a/mcp/deployment/src/test/java/io/quarkiverse/langchain4j/mcp/test/NoAutomaticToolProviderTest.java b/mcp/deployment/src/test/java/io/quarkiverse/langchain4j/mcp/test/NoAutomaticToolProviderTest.java new file mode 100644 index 000000000..4a3f76d4e --- /dev/null +++ b/mcp/deployment/src/test/java/io/quarkiverse/langchain4j/mcp/test/NoAutomaticToolProviderTest.java @@ -0,0 +1,50 @@ +package io.quarkiverse.langchain4j.mcp.test; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import dev.langchain4j.mcp.client.McpClient; +import dev.langchain4j.service.tool.ToolProvider; +import io.quarkiverse.langchain4j.mcp.runtime.McpClientName; +import io.quarkus.test.QuarkusUnitTest; + +/** + * Verify that when some MCP clients are configured, but + * quarkus.langchain4j.mcp.generate-tool-provider=false, then no tool + * provider will be generated out of the box. + */ +public class NoAutomaticToolProviderTest { + + @RegisterExtension + static QuarkusUnitTest unitTest = new QuarkusUnitTest() + .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) + .addClass(MockHttpMcpServer.class) + .addAsResource(new StringAsset(""" + quarkus.langchain4j.mcp.client1.transport-type=http + quarkus.langchain4j.mcp.client1.url=http://localhost:${quarkus.http.test-port}/mock-mcp/sse + quarkus.langchain4j.mcp.generate-tool-provider=false + """), + "application.properties")); + + @Inject + @McpClientName("client1") + Instance clientCDIInstance; + + @Inject + Instance toolProviderCDIInstance; + + @Test + public void test() { + assertThat(clientCDIInstance.isResolvable()).isTrue(); + assertThat(toolProviderCDIInstance.isResolvable()).isFalse(); + } + +} diff --git a/mcp/pom.xml b/mcp/pom.xml new file mode 100644 index 000000000..845babc3d --- /dev/null +++ b/mcp/pom.xml @@ -0,0 +1,20 @@ + + + 4.0.0 + + io.quarkiverse.langchain4j + quarkus-langchain4j-parent + 999-SNAPSHOT + ../pom.xml + + quarkus-langchain4j-mcp-parent + Quarkus LangChain4j - Model Context Protocol - Parent + pom + + + deployment + runtime + + + + diff --git a/mcp/runtime/pom.xml b/mcp/runtime/pom.xml new file mode 100644 index 000000000..be38cf5cc --- /dev/null +++ b/mcp/runtime/pom.xml @@ -0,0 +1,75 @@ + + + 4.0.0 + + io.quarkiverse.langchain4j + quarkus-langchain4j-mcp-parent + 999-SNAPSHOT + + quarkus-langchain4j-mcp + Quarkus LangChain4j - Model Context Protocol - Runtime + + + io.quarkus + quarkus-arc + + + io.quarkus + quarkus-rest-client-jackson + + + dev.langchain4j + langchain4j-mcp + + + io.quarkiverse.langchain4j + quarkus-langchain4j-core + ${project.version} + + + io.quarkiverse.langchain4j + quarkus-langchain4j-testing-internal + ${project.version} + test + + + io.quarkus + quarkus-junit5-internal + test + + + + + + + io.quarkus + quarkus-extension-maven-plugin + ${quarkus.version} + + + compile + + extension-descriptor + + + ${project.groupId}:${project.artifactId}-deployment:${project.version} + + + + + + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${quarkus.version} + + + + + + + + diff --git a/mcp/runtime/src/main/java/io/quarkiverse/langchain4j/mcp/runtime/McpClientName.java b/mcp/runtime/src/main/java/io/quarkiverse/langchain4j/mcp/runtime/McpClientName.java new file mode 100644 index 000000000..88c6eb026 --- /dev/null +++ b/mcp/runtime/src/main/java/io/quarkiverse/langchain4j/mcp/runtime/McpClientName.java @@ -0,0 +1,41 @@ +package io.quarkiverse.langchain4j.mcp.runtime; + +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import jakarta.enterprise.util.AnnotationLiteral; +import jakarta.inject.Qualifier; + +/** + * Used as a qualifier to denote a particular MCP client by its name. + */ +@Target({ ElementType.TYPE, ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER }) +@Retention(RUNTIME) +@Documented +@Qualifier +public @interface McpClientName { + + String value(); + + class Literal extends AnnotationLiteral implements McpClientName { + + public static Literal of(String value) { + return new Literal(value); + } + + private final String value; + + public Literal(String value) { + this.value = value; + } + + @Override + public String value() { + return value; + } + } +} diff --git a/mcp/runtime/src/main/java/io/quarkiverse/langchain4j/mcp/runtime/McpRecorder.java b/mcp/runtime/src/main/java/io/quarkiverse/langchain4j/mcp/runtime/McpRecorder.java new file mode 100644 index 000000000..84694a464 --- /dev/null +++ b/mcp/runtime/src/main/java/io/quarkiverse/langchain4j/mcp/runtime/McpRecorder.java @@ -0,0 +1,77 @@ +package io.quarkiverse.langchain4j.mcp.runtime; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Supplier; + +import dev.langchain4j.mcp.McpToolProvider; +import dev.langchain4j.mcp.client.DefaultMcpClient; +import dev.langchain4j.mcp.client.McpClient; +import dev.langchain4j.mcp.client.transport.McpTransport; +import dev.langchain4j.mcp.client.transport.stdio.StdioMcpTransport; +import dev.langchain4j.service.tool.ToolProvider; +import io.quarkiverse.langchain4j.mcp.runtime.config.McpClientConfig; +import io.quarkiverse.langchain4j.mcp.runtime.config.McpConfiguration; +import io.quarkiverse.langchain4j.mcp.runtime.http.QuarkusHttpMcpTransport; +import io.quarkus.arc.SyntheticCreationalContext; +import io.quarkus.runtime.annotations.Recorder; +import io.quarkus.runtime.configuration.ConfigurationException; + +@Recorder +public class McpRecorder { + + public Supplier mcpClientSupplier(String key, McpConfiguration configuration) { + return new Supplier() { + @Override + public McpClient get() { + McpTransport transport = null; + McpClientConfig config = configuration.clients().get(key); + switch (config.transportType()) { + case STDIO: + List command = config.command().orElseThrow(() -> new ConfigurationException( + "MCP client configuration named " + key + " is missing the 'command' property")); + transport = new StdioMcpTransport.Builder() + .command(command) + .logEvents(config.logResponses().orElse(false)) + .environment(config.environment()) + .build(); + break; + case HTTP: + transport = new QuarkusHttpMcpTransport.Builder() + .sseUrl(config.url().orElseThrow(() -> new ConfigurationException( + "MCP client configuration named " + key + " is missing the 'url' property"))) + .logRequests(config.logRequests().orElse(false)) + .logResponses(config.logResponses().orElse(false)) + .build(); + break; + default: + throw new IllegalArgumentException("Unknown transport type: " + config.transportType()); + } + return new DefaultMcpClient.Builder() + .transport(transport) + .toolExecutionTimeout(config.toolExecutionTimeout()) + .build(); + } + }; + } + + public Function, ToolProvider> toolProviderFunction( + Set mcpClientNames) { + return new Function<>() { + @Override + public ToolProvider apply(SyntheticCreationalContext context) { + List clients = new ArrayList<>(); + for (String mcpClientName : mcpClientNames) { + McpClientName.Literal qualifier = McpClientName.Literal.of(mcpClientName); + clients.add(context.getInjectedReference(McpClient.class, qualifier)); + } + return new McpToolProvider.Builder() + .mcpClients(clients) + .build(); + } + + }; + } +} diff --git a/mcp/runtime/src/main/java/io/quarkiverse/langchain4j/mcp/runtime/config/McpClientConfig.java b/mcp/runtime/src/main/java/io/quarkiverse/langchain4j/mcp/runtime/config/McpClientConfig.java new file mode 100644 index 000000000..4ad60d4cb --- /dev/null +++ b/mcp/runtime/src/main/java/io/quarkiverse/langchain4j/mcp/runtime/config/McpClientConfig.java @@ -0,0 +1,58 @@ +package io.quarkiverse.langchain4j.mcp.runtime.config; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import io.quarkus.runtime.annotations.ConfigDocDefault; +import io.quarkus.runtime.annotations.ConfigDocMapKey; +import io.quarkus.runtime.annotations.ConfigGroup; +import io.smallrye.config.WithDefault; + +@ConfigGroup +public interface McpClientConfig { + + /** + * Transport type + */ + McpTransportType transportType(); + + /** + * The URL of the SSE endpoint. This only applies to MCP clients using the HTTP transport. + */ + Optional url(); + + /** + * The command to execute to spawn the MCP server process. This only applies to MCP clients + * using the STDIO transport. + */ + Optional> command(); + + /** + * Environment variables for the spawned MCP server process. This only applies to MCP clients + * using the STDIO transport. + */ + @ConfigDocMapKey("env-var") + Map environment(); + + /** + * Whether to log requests + */ + @ConfigDocDefault("false") + @WithDefault("${quarkus.langchain4j.log-requests}") + Optional logRequests(); + + /** + * Whether to log responses + */ + @ConfigDocDefault("false") + @WithDefault("${quarkus.langchain4j.log-responses}") + Optional logResponses(); + + /** + * Timeout for tool executions performed by the MCP client + */ + @WithDefault("60s") + Duration toolExecutionTimeout(); +} diff --git a/mcp/runtime/src/main/java/io/quarkiverse/langchain4j/mcp/runtime/config/McpConfiguration.java b/mcp/runtime/src/main/java/io/quarkiverse/langchain4j/mcp/runtime/config/McpConfiguration.java new file mode 100644 index 000000000..c7d50cb2b --- /dev/null +++ b/mcp/runtime/src/main/java/io/quarkiverse/langchain4j/mcp/runtime/config/McpConfiguration.java @@ -0,0 +1,34 @@ +package io.quarkiverse.langchain4j.mcp.runtime.config; + +import java.util.Map; +import java.util.Optional; + +import io.quarkus.runtime.annotations.ConfigDocDefault; +import io.quarkus.runtime.annotations.ConfigDocMapKey; +import io.quarkus.runtime.annotations.ConfigDocSection; +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithParentName; + +@ConfigRoot(phase = ConfigPhase.BUILD_AND_RUN_TIME_FIXED) +@ConfigMapping(prefix = "quarkus.langchain4j.mcp") +public interface McpConfiguration { + + /** + * Configured MCP clients + */ + @ConfigDocSection + @ConfigDocMapKey("client-name") + @WithParentName + Map clients(); + + /** + * Whether the MCP extension should automatically generate a ToolProvider that + * is wired up to all the configured MCP clients. The default is true if at least + * one MCP client is configured, false otherwise. + */ + @ConfigDocDefault("true") + Optional generateToolProvider(); + +} diff --git a/mcp/runtime/src/main/java/io/quarkiverse/langchain4j/mcp/runtime/config/McpTransportType.java b/mcp/runtime/src/main/java/io/quarkiverse/langchain4j/mcp/runtime/config/McpTransportType.java new file mode 100644 index 000000000..a8f0f07f6 --- /dev/null +++ b/mcp/runtime/src/main/java/io/quarkiverse/langchain4j/mcp/runtime/config/McpTransportType.java @@ -0,0 +1,6 @@ +package io.quarkiverse.langchain4j.mcp.runtime.config; + +public enum McpTransportType { + STDIO, + HTTP +} diff --git a/mcp/runtime/src/main/java/io/quarkiverse/langchain4j/mcp/runtime/http/McpHttpClientLogger.java b/mcp/runtime/src/main/java/io/quarkiverse/langchain4j/mcp/runtime/http/McpHttpClientLogger.java new file mode 100644 index 000000000..695a7608b --- /dev/null +++ b/mcp/runtime/src/main/java/io/quarkiverse/langchain4j/mcp/runtime/http/McpHttpClientLogger.java @@ -0,0 +1,74 @@ +package io.quarkiverse.langchain4j.mcp.runtime.http; + +import static java.util.stream.Collectors.joining; +import static java.util.stream.StreamSupport.stream; + +import org.jboss.logging.Logger; +import org.jboss.resteasy.reactive.client.api.ClientLogger; + +import io.vertx.core.Handler; +import io.vertx.core.MultiMap; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpClientRequest; +import io.vertx.core.http.HttpClientResponse; + +class McpHttpClientLogger implements ClientLogger { + private static final Logger log = Logger.getLogger(McpHttpClientLogger.class); + + private final boolean logRequests; + private final boolean logResponses; + + public McpHttpClientLogger(boolean logRequests, boolean logResponses) { + this.logRequests = logRequests; + this.logResponses = logResponses; + } + + @Override + public void setBodySize(int bodySize) { + // ignore + } + + @Override + public void logRequest(HttpClientRequest request, Buffer body, boolean omitBody) { + if (logRequests && log.isInfoEnabled()) { + try { + log.infof("Request:\n- method: %s\n- url: %s\n- headers: %s\n- body: %s", request.getMethod(), + request.absoluteURI(), inOneLine(request.headers()), bodyToString(body)); + } catch (Exception e) { + log.warn("Failed to log request", e); + } + } + } + + @Override + public void logResponse(HttpClientResponse response, boolean redirect) { + if (logResponses && log.isInfoEnabled()) { + response.bodyHandler(new Handler<>() { + @Override + public void handle(Buffer body) { + try { + log.infof("Response:\n- status code: %s\n- headers: %s\n- body: %s", response.statusCode(), + inOneLine(response.headers()), bodyToString(body)); + } catch (Exception e) { + log.warn("Failed to log response", e); + } + } + }); + } + } + + private String inOneLine(MultiMap headers) { + return stream(headers.spliterator(), false) + .map(header -> { + var headerKey = header.getKey(); + var headerValue = header.getValue(); + return "[%s: %s]".formatted(headerKey, headerValue); + }) + .collect(joining(", ")); + } + + private String bodyToString(Buffer body) { + return (body != null) ? body.toString() : ""; + } + +} diff --git a/mcp/runtime/src/main/java/io/quarkiverse/langchain4j/mcp/runtime/http/McpPostEndpoint.java b/mcp/runtime/src/main/java/io/quarkiverse/langchain4j/mcp/runtime/http/McpPostEndpoint.java new file mode 100644 index 000000000..0c6bac6a9 --- /dev/null +++ b/mcp/runtime/src/main/java/io/quarkiverse/langchain4j/mcp/runtime/http/McpPostEndpoint.java @@ -0,0 +1,19 @@ +package io.quarkiverse.langchain4j.mcp.runtime.http; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import dev.langchain4j.mcp.client.protocol.McpClientMessage; +import io.smallrye.mutiny.Uni; + +public interface McpPostEndpoint { + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + Uni post(McpClientMessage message); + +} diff --git a/mcp/runtime/src/main/java/io/quarkiverse/langchain4j/mcp/runtime/http/McpSseEndpoint.java b/mcp/runtime/src/main/java/io/quarkiverse/langchain4j/mcp/runtime/http/McpSseEndpoint.java new file mode 100644 index 000000000..2fbb54061 --- /dev/null +++ b/mcp/runtime/src/main/java/io/quarkiverse/langchain4j/mcp/runtime/http/McpSseEndpoint.java @@ -0,0 +1,16 @@ +package io.quarkiverse.langchain4j.mcp.runtime.http; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +import org.jboss.resteasy.reactive.client.SseEvent; + +import io.smallrye.mutiny.Multi; + +public interface McpSseEndpoint { + + @GET + @Produces(MediaType.SERVER_SENT_EVENTS) + Multi> get(); +} diff --git a/mcp/runtime/src/main/java/io/quarkiverse/langchain4j/mcp/runtime/http/QuarkusHttpMcpTransport.java b/mcp/runtime/src/main/java/io/quarkiverse/langchain4j/mcp/runtime/http/QuarkusHttpMcpTransport.java new file mode 100644 index 000000000..0648e6a93 --- /dev/null +++ b/mcp/runtime/src/main/java/io/quarkiverse/langchain4j/mcp/runtime/http/QuarkusHttpMcpTransport.java @@ -0,0 +1,189 @@ +package io.quarkiverse.langchain4j.mcp.runtime.http; + +import static dev.langchain4j.internal.Utils.getOrDefault; +import static dev.langchain4j.internal.ValidationUtils.ensureNotNull; + +import java.io.IOException; +import java.net.URI; +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import org.jboss.logging.Logger; +import org.jboss.resteasy.reactive.client.api.LoggingScope; +import org.jboss.resteasy.reactive.server.jackson.JacksonBasicMessageBodyReader; + +import com.fasterxml.jackson.databind.JsonNode; + +import dev.langchain4j.mcp.client.protocol.CancellationNotification; +import dev.langchain4j.mcp.client.protocol.McpCallToolRequest; +import dev.langchain4j.mcp.client.protocol.McpClientMessage; +import dev.langchain4j.mcp.client.protocol.McpInitializeRequest; +import dev.langchain4j.mcp.client.protocol.McpListToolsRequest; +import dev.langchain4j.mcp.client.transport.McpOperationHandler; +import dev.langchain4j.mcp.client.transport.McpTransport; +import io.quarkiverse.langchain4j.QuarkusJsonCodecFactory; +import io.quarkus.rest.client.reactive.QuarkusRestClientBuilder; + +public class QuarkusHttpMcpTransport implements McpTransport { + + private static final Logger log = Logger.getLogger(QuarkusHttpMcpTransport.class); + private final String sseUrl; + private final McpSseEndpoint sseEndpoint; + private final Duration timeout; + private final boolean logResponses; + private final boolean logRequests; + private SseSubscriber mcpSseEventListener; + + // this is obtained from the server after initializing the SSE channel + private volatile String postUrl; + private volatile McpPostEndpoint postEndpoint; + private volatile McpOperationHandler operationHandler; + + public QuarkusHttpMcpTransport(QuarkusHttpMcpTransport.Builder builder) { + sseUrl = ensureNotNull(builder.sseUrl, "Missing SSE endpoint URL"); + timeout = getOrDefault(builder.timeout, Duration.ofSeconds(60)); + + this.logRequests = builder.logRequests; + this.logResponses = builder.logResponses; + + QuarkusRestClientBuilder clientBuilder = QuarkusRestClientBuilder.newBuilder() + .baseUri(URI.create(builder.sseUrl)) + .connectTimeout(timeout.toSeconds(), TimeUnit.SECONDS) + .readTimeout(timeout.toSeconds(), TimeUnit.SECONDS) + .loggingScope(LoggingScope.ALL) + .register(new JacksonBasicMessageBodyReader(QuarkusJsonCodecFactory.ObjectMapperHolder.MAPPER)); + if (logRequests || logResponses) { + clientBuilder.loggingScope(LoggingScope.REQUEST_RESPONSE); + clientBuilder.clientLogger(new McpHttpClientLogger(logRequests, logResponses)); + } + sseEndpoint = clientBuilder.build(McpSseEndpoint.class); + } + + @Override + public void start(McpOperationHandler messageHandler) { + this.operationHandler = messageHandler; + mcpSseEventListener = startSseChannel(logResponses); + QuarkusRestClientBuilder builder = QuarkusRestClientBuilder.newBuilder() + .baseUri(URI.create(postUrl)) + .connectTimeout(timeout.toSeconds(), TimeUnit.SECONDS) + .readTimeout(timeout.toSeconds(), TimeUnit.SECONDS) + .register(new JacksonBasicMessageBodyReader(QuarkusJsonCodecFactory.ObjectMapperHolder.MAPPER)); + if (logRequests || logResponses) { + builder.loggingScope(LoggingScope.REQUEST_RESPONSE); + builder.clientLogger(new McpHttpClientLogger(logRequests, logResponses)); + } + postEndpoint = builder + .build(McpPostEndpoint.class); + } + + @Override + public CompletableFuture initialize(McpInitializeRequest request) { + return execute(request, request.getId()); + } + + @Override + public CompletableFuture listTools(McpListToolsRequest operation) { + return execute(operation, operation.getId()); + } + + @Override + public void cancelOperation(long operationId) { + CancellationNotification cancellationNotification = new CancellationNotification(operationId, "Timeout"); + execute(cancellationNotification, null); + } + + @Override + public CompletableFuture executeTool(McpCallToolRequest operation) { + return execute(operation, operation.getId()); + } + + private CompletableFuture execute(McpClientMessage request, Long id) { + CompletableFuture future = new CompletableFuture<>(); + if (id != null) { + operationHandler.startOperation(id, future); + } + postEndpoint.post(request).onItem().invoke(response -> { + int statusCode = response.getStatus(); + if (!isExpectedStatusCode(statusCode)) { + throw new RuntimeException("Unexpected status code: " + statusCode); + } + }).subscribeAsCompletionStage(); + return future; + } + + private boolean isExpectedStatusCode(int statusCode) { + return statusCode >= 200 && statusCode < 300; + } + + private SseSubscriber startSseChannel(boolean logResponses) { + CompletableFuture initializationFinished = new CompletableFuture<>(); + SseSubscriber listener = new SseSubscriber(operationHandler, logResponses, initializationFinished); + sseEndpoint.get().subscribe().with(listener, throwable -> { + if (!initializationFinished.isDone()) { + initializationFinished.completeExceptionally(throwable); + } + }); + // wait for the SSE channel to be created, receive the POST url from the server, throw an exception if that + // failed + try { + long timeoutMillis = this.timeout.toMillis() > 0 ? this.timeout.toMillis() : Integer.MAX_VALUE; + String relativePostUrl = initializationFinished.get(timeoutMillis, TimeUnit.MILLISECONDS); + postUrl = buildAbsolutePostUrl(relativePostUrl); + log.debug("Received the server's POST URL: " + postUrl); + } catch (Exception e) { + throw new RuntimeException(e); + } + return listener; + } + + private String buildAbsolutePostUrl(String relativePostUrl) { + try { + return URI.create(this.sseUrl).resolve(relativePostUrl).toString(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Override + public void close() throws IOException { + + } + + public static class Builder { + + private String sseUrl; + private Duration timeout; + private boolean logRequests = false; + private boolean logResponses = false; + + /** + * The initial URL where to connect to the server and request a SSE + * channel. + */ + public QuarkusHttpMcpTransport.Builder sseUrl(String sseUrl) { + this.sseUrl = sseUrl; + return this; + } + + public QuarkusHttpMcpTransport.Builder timeout(Duration timeout) { + this.timeout = timeout; + return this; + } + + public QuarkusHttpMcpTransport.Builder logRequests(boolean logRequests) { + this.logRequests = logRequests; + return this; + } + + public QuarkusHttpMcpTransport.Builder logResponses(boolean logResponses) { + this.logResponses = logResponses; + return this; + } + + public QuarkusHttpMcpTransport build() { + return new QuarkusHttpMcpTransport(this); + } + } + +} diff --git a/mcp/runtime/src/main/java/io/quarkiverse/langchain4j/mcp/runtime/http/SseSubscriber.java b/mcp/runtime/src/main/java/io/quarkiverse/langchain4j/mcp/runtime/http/SseSubscriber.java new file mode 100644 index 000000000..eecfddde0 --- /dev/null +++ b/mcp/runtime/src/main/java/io/quarkiverse/langchain4j/mcp/runtime/http/SseSubscriber.java @@ -0,0 +1,63 @@ +package io.quarkiverse.langchain4j.mcp.runtime.http; + +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; + +import org.jboss.logging.Logger; +import org.jboss.resteasy.reactive.client.SseEvent; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import dev.langchain4j.mcp.client.transport.McpOperationHandler; +import dev.langchain4j.mcp.client.transport.http.SseEventListener; + +public class SseSubscriber implements Consumer> { + + private final McpOperationHandler operationHandler; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final Logger log = Logger.getLogger(SseEventListener.class); + private final boolean logEvents; + // this will contain the POST url for sending commands to the server + private final CompletableFuture initializationFinished; + + public SseSubscriber( + McpOperationHandler operationHandler, + boolean logEvents, + CompletableFuture initializationFinished) { + this.operationHandler = operationHandler; + this.logEvents = logEvents; + this.initializationFinished = initializationFinished; + } + + @Override + public void accept(SseEvent s) { + if (logEvents) { + log.debug("< " + s.data()); + } + String name = s.name(); + if (name == null) { + log.warn("Received event with null name"); + return; + } + String data = s.data(); + if (name.equals("message")) { + if (logEvents) { + log.debug("< " + data); + } + try { + JsonNode jsonNode = OBJECT_MAPPER.readTree(data); + operationHandler.handle(jsonNode); + } catch (JsonProcessingException e) { + log.warn("Failed to parse JSON message: {}", data, e); + } + } else if (name.equals("endpoint")) { + if (initializationFinished.isDone()) { + log.warn("Received endpoint event after initialization"); + return; + } + initializationFinished.complete(s.data()); + } + } +} diff --git a/mcp/runtime/src/main/resources/META-INF/beans.xml b/mcp/runtime/src/main/resources/META-INF/beans.xml new file mode 100644 index 000000000..e69de29bb diff --git a/mcp/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/mcp/runtime/src/main/resources/META-INF/quarkus-extension.yaml new file mode 100644 index 000000000..96f8940f6 --- /dev/null +++ b/mcp/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -0,0 +1,12 @@ +name: LangChain4j Model Context Protocol client +artifact: ${project.groupId}:${project.artifactId}:${project.version} +description: Provides the Model Context Protocol client-side implementation for LangChain4j +metadata: + keywords: + - ai + - langchain4j + guide: "https://docs.quarkiverse.io/quarkus-langchain4j/dev/index.html" + categories: + - "ai" + status: "experimental" + diff --git a/model-providers/ollama/deployment/src/test/java/io/quarkiverse/langchain4j/ollama/deployment/OllamaJsonOutputTest.java b/model-providers/ollama/deployment/src/test/java/io/quarkiverse/langchain4j/ollama/deployment/OllamaJsonOutputTest.java index 4d4ebc6c1..1055ff3da 100644 --- a/model-providers/ollama/deployment/src/test/java/io/quarkiverse/langchain4j/ollama/deployment/OllamaJsonOutputTest.java +++ b/model-providers/ollama/deployment/src/test/java/io/quarkiverse/langchain4j/ollama/deployment/OllamaJsonOutputTest.java @@ -58,14 +58,13 @@ void extract() { "content": "Tell me something about Alan Wake\\nYou must answer strictly in the following JSON format: {\\n\\\"firstname\\\": (The firstname; type: string),\\n\\\"lastname\\\": (The lastname; type: string)\\n}" } ], - "stream": false, "options": { "temperature": 0.8, "top_k": 40, "top_p": 0.9 }, - "tools": [], - "format": "json" + "format": "json", + "stream": false }""")) .willReturn(aResponse() .withHeader("Content-Type", "application/json") diff --git a/model-providers/ollama/deployment/src/test/java/io/quarkiverse/langchain4j/ollama/deployment/OllamaTextOutputTest.java b/model-providers/ollama/deployment/src/test/java/io/quarkiverse/langchain4j/ollama/deployment/OllamaTextOutputTest.java index 48f08eea0..81d00f777 100644 --- a/model-providers/ollama/deployment/src/test/java/io/quarkiverse/langchain4j/ollama/deployment/OllamaTextOutputTest.java +++ b/model-providers/ollama/deployment/src/test/java/io/quarkiverse/langchain4j/ollama/deployment/OllamaTextOutputTest.java @@ -55,8 +55,7 @@ void extract() { "temperature": 0.8, "top_k": 40, "top_p": 0.9 - }, - "tools": [] + } }""")) .willReturn(aResponse() .withHeader("Content-Type", "application/json") diff --git a/model-providers/openai/azure-openai/runtime/src/main/java/io/quarkiverse/langchain4j/azure/openai/AzureOpenAiStreamingChatModel.java b/model-providers/openai/azure-openai/runtime/src/main/java/io/quarkiverse/langchain4j/azure/openai/AzureOpenAiStreamingChatModel.java index b3c1ce7d4..d28b15025 100644 --- a/model-providers/openai/azure-openai/runtime/src/main/java/io/quarkiverse/langchain4j/azure/openai/AzureOpenAiStreamingChatModel.java +++ b/model-providers/openai/azure-openai/runtime/src/main/java/io/quarkiverse/langchain4j/azure/openai/AzureOpenAiStreamingChatModel.java @@ -41,6 +41,7 @@ import dev.langchain4j.model.chat.listener.ChatModelRequestContext; import dev.langchain4j.model.chat.listener.ChatModelResponse; import dev.langchain4j.model.chat.listener.ChatModelResponseContext; +import dev.langchain4j.model.chat.response.ChatResponse; import dev.langchain4j.model.openai.OpenAiStreamingResponseBuilder; import dev.langchain4j.model.output.Response; import io.quarkiverse.langchain4j.openai.common.QuarkusOpenAiClient; @@ -202,7 +203,7 @@ private void generate(List messages, } }) .onComplete(() -> { - Response response = responseBuilder.build(); + ChatResponse response = responseBuilder.build(); ChatModelResponse modelListenerResponse = createModelListenerResponse( responseId.get(), @@ -220,10 +221,13 @@ private void generate(List messages, } }); - handler.onComplete(response); + Response aiResponse = Response.from(response.aiMessage(), + response.tokenUsage(), + response.finishReason()); + handler.onComplete(aiResponse); }) .onError((error) -> { - Response response = responseBuilder.build(); + ChatResponse response = responseBuilder.build(); ChatModelResponse modelListenerPartialResponse = createModelListenerResponse( responseId.get(), @@ -282,7 +286,7 @@ private ChatModelRequest createModelListenerRequest(ChatCompletionRequest reques private ChatModelResponse createModelListenerResponse(String responseId, String responseModel, - Response response) { + ChatResponse response) { if (response == null) { return null; } @@ -292,7 +296,7 @@ private ChatModelResponse createModelListenerResponse(String responseId, .model(responseModel) .tokenUsage(response.tokenUsage()) .finishReason(response.finishReason()) - .aiMessage(response.content()) + .aiMessage(response.aiMessage()) .build(); } diff --git a/model-providers/watsonx/runtime/src/main/java/io/quarkiverse/langchain4j/watsonx/WatsonxChatModel.java b/model-providers/watsonx/runtime/src/main/java/io/quarkiverse/langchain4j/watsonx/WatsonxChatModel.java index 37a8369e9..62aee9700 100644 --- a/model-providers/watsonx/runtime/src/main/java/io/quarkiverse/langchain4j/watsonx/WatsonxChatModel.java +++ b/model-providers/watsonx/runtime/src/main/java/io/quarkiverse/langchain4j/watsonx/WatsonxChatModel.java @@ -5,6 +5,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.Callable; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; @@ -15,11 +16,13 @@ import dev.langchain4j.data.message.AiMessage; import dev.langchain4j.data.message.ChatMessage; import dev.langchain4j.model.StreamingResponseHandler; +import dev.langchain4j.model.chat.Capability; import dev.langchain4j.model.chat.ChatLanguageModel; import dev.langchain4j.model.chat.StreamingChatLanguageModel; import dev.langchain4j.model.chat.TokenCountEstimator; import dev.langchain4j.model.chat.listener.ChatModelRequest; import dev.langchain4j.model.chat.listener.ChatModelResponse; +import dev.langchain4j.model.chat.request.ChatRequestParameters; import dev.langchain4j.model.output.FinishReason; import dev.langchain4j.model.output.Response; import dev.langchain4j.model.output.TokenUsage; @@ -278,6 +281,16 @@ public Integer call() throws Exception { }); } + @Override + public ChatRequestParameters defaultRequestParameters() { + return null; + } + + @Override + public Set supportedCapabilities() { + return Set.of(); + } + @Override public Response generate(List messages) { return generate(messages, List.of()); diff --git a/model-providers/watsonx/runtime/src/main/java/io/quarkiverse/langchain4j/watsonx/WatsonxGenerationModel.java b/model-providers/watsonx/runtime/src/main/java/io/quarkiverse/langchain4j/watsonx/WatsonxGenerationModel.java index dd8d6e073..39a07df6c 100644 --- a/model-providers/watsonx/runtime/src/main/java/io/quarkiverse/langchain4j/watsonx/WatsonxGenerationModel.java +++ b/model-providers/watsonx/runtime/src/main/java/io/quarkiverse/langchain4j/watsonx/WatsonxGenerationModel.java @@ -6,6 +6,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.concurrent.Callable; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; @@ -16,11 +17,13 @@ import dev.langchain4j.data.message.SystemMessage; import dev.langchain4j.data.message.UserMessage; import dev.langchain4j.model.StreamingResponseHandler; +import dev.langchain4j.model.chat.Capability; import dev.langchain4j.model.chat.ChatLanguageModel; import dev.langchain4j.model.chat.StreamingChatLanguageModel; import dev.langchain4j.model.chat.TokenCountEstimator; import dev.langchain4j.model.chat.listener.ChatModelRequest; import dev.langchain4j.model.chat.listener.ChatModelResponse; +import dev.langchain4j.model.chat.request.ChatRequestParameters; import dev.langchain4j.model.output.FinishReason; import dev.langchain4j.model.output.Response; import dev.langchain4j.model.output.TokenUsage; @@ -70,6 +73,16 @@ public WatsonxGenerationModel(Builder builder) { .build(); } + @Override + public ChatRequestParameters defaultRequestParameters() { + return null; + } + + @Override + public Set supportedCapabilities() { + return Set.of(); + } + @Override public Response generate(List messages) { diff --git a/pom.xml b/pom.xml index 97d8972f3..b261461b6 100644 --- a/pom.xml +++ b/pom.xml @@ -14,6 +14,7 @@ core embedding-stores + mcp memory-stores model-auth-providers model-providers @@ -32,8 +33,8 @@ 3.15.2 - 0.36.2 - 0.36.2 + 1.0.0-alpha1 + 1.0.0-alpha1 1.0.1 2.0.4 3.27.0 diff --git a/samples/mcp-tools/README.md b/samples/mcp-tools/README.md new file mode 100644 index 000000000..9a7bd9005 --- /dev/null +++ b/samples/mcp-tools/README.md @@ -0,0 +1,27 @@ +# MCP-based filesystem assistant + +This sample showcases how to use an MCP server (spawned as a subprocess) to +provide tools to an LLM. In this case, we use the +`@modelcontextprotocol/server-filesystem` MCP server that is provided as an +[NPM +package](https://www.npmjs.com/package/@modelcontextprotocol/server-filesystem), +giving the LLM a set of tools to interact with the local filesystem. The +only directory that the agent will be able to access is the `playground` +directory in the root of the project. + +# Prerequisites + +The project assumes that you have `npm` installed, and it attempts to run +the MCP server by executing `npm exec +@modelcontextprotocol/server-filesystem@0.6.2 playground` (`playground` +denotes the allowed directory for the agent). If your environment requires a +different command to run the server, please modify the constructor of the +`FilesystemToolProvider` class manually and adjust it to your needs, but +keep in mind that you have to run the package as a subprocess directly in a +way that Quarkus can connect to its standard input and output. + +# Running the sample + +Run the sample using `mvn quarkus:dev` and then access +`http://localhost:8080` to start chatting. Some more information +and a few suggested prompts to try out will be shown on that page. \ No newline at end of file diff --git a/samples/mcp-tools/playground/hello.txt b/samples/mcp-tools/playground/hello.txt new file mode 100644 index 000000000..6769dd60b --- /dev/null +++ b/samples/mcp-tools/playground/hello.txt @@ -0,0 +1 @@ +Hello world! \ No newline at end of file diff --git a/samples/mcp-tools/pom.xml b/samples/mcp-tools/pom.xml new file mode 100644 index 000000000..ffad97d8a --- /dev/null +++ b/samples/mcp-tools/pom.xml @@ -0,0 +1,190 @@ + + + 4.0.0 + + io.quarkiverse.langchain4j + quarkus-langchain4j-sample-mcp-tools + Quarkus LangChain4j - Sample - Tools using the Model Context Protocol + 1.0-SNAPSHOT + + + 3.13.0 + true + 17 + UTF-8 + UTF-8 + quarkus-bom + io.quarkus + 3.15.2 + true + 3.2.5 + 999-SNAPSHOT + + + + + + ${quarkus.platform.group-id} + ${quarkus.platform.artifact-id} + ${quarkus.platform.version} + pom + import + + + + + + + io.quarkus + quarkus-rest-jackson + + + io.quarkus + quarkus-websockets-next + + + io.quarkiverse.langchain4j + quarkus-langchain4j-openai + ${quarkus-langchain4j.version} + + + io.quarkiverse.langchain4j + quarkus-langchain4j-mcp + ${quarkus-langchain4j.version} + + + + + io.mvnpm + importmap + 1.0.11 + + + org.mvnpm + lit + 3.2.0 + runtime + + + org.mvnpm + wc-chatbot + 0.2.0 + runtime + + + + + io.quarkiverse.langchain4j + quarkus-langchain4j-openai-deployment + ${quarkus-langchain4j.version} + test + pom + + + * + * + + + + + io.quarkiverse.langchain4j + quarkus-langchain4j-mcp-deployment + ${quarkus-langchain4j.version} + test + pom + + + * + * + + + + + + + + io.quarkus + quarkus-maven-plugin + ${quarkus.platform.version} + + + + build + + + + + + maven-compiler-plugin + ${compiler-plugin.version} + + + maven-surefire-plugin + 3.5.1 + + + org.jboss.logmanager.LogManager + ${maven.home} + + + + + + + + + native + + + native + + + + + + maven-failsafe-plugin + 3.5.1 + + + + integration-test + verify + + + + ${project.build.directory}/${project.build.finalName}-runner + org.jboss.logmanager.LogManager + ${maven.home} + + + + + + + + + native + + + + + mvnpm + + + central + central + https://repo.maven.apache.org/maven2 + + + + false + + mvnpm.org + mvnpm + https://repo.mvnpm.org/maven2 + + + + + + diff --git a/samples/mcp-tools/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/Bot.java b/samples/mcp-tools/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/Bot.java new file mode 100644 index 000000000..01119af7d --- /dev/null +++ b/samples/mcp-tools/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/Bot.java @@ -0,0 +1,27 @@ +package io.quarkiverse.langchain4j.sample.chatbot; + +import dev.langchain4j.service.SystemMessage; +import dev.langchain4j.service.UserMessage; +import io.quarkiverse.langchain4j.RegisterAiService; +import jakarta.enterprise.context.SessionScoped; + +@RegisterAiService//(toolProviderSupplier = FilesystemToolProvider.class) +@SessionScoped +public interface Bot { + + @SystemMessage(""" + You have tools to interact with the local filesystem and the users + will ask you to perform operations like reading and writing files. + + The only directory allowed to interact with is the 'playground' directory relative + to the current working directory. If a user specifies a relative path to a file and + it does not start with 'playground', prepend the 'playground' + directory to the path. + + If the user asks, tell them you have access to a tool server + via the Model Context Protocol (MCP) and that they can find more + information about it on https://modelcontextprotocol.io/. + """ + ) + String chat(@UserMessage String question); +} diff --git a/samples/mcp-tools/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/ChatBotWebSocket.java b/samples/mcp-tools/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/ChatBotWebSocket.java new file mode 100644 index 000000000..d0cd3bc20 --- /dev/null +++ b/samples/mcp-tools/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/ChatBotWebSocket.java @@ -0,0 +1,28 @@ +package io.quarkiverse.langchain4j.sample.chatbot; + +import io.quarkus.websockets.next.OnOpen; +import io.quarkus.websockets.next.OnTextMessage; +import io.quarkus.websockets.next.WebSocket; +import io.smallrye.common.annotation.Blocking; + +@WebSocket(path = "/chatbot") +public class ChatBotWebSocket { + + private final Bot bot; + + public ChatBotWebSocket(Bot bot) { + this.bot = bot; + } + + @OnOpen + public String onOpen() { + return "Hello, I am a filesystem robot, how can I help?"; + } + + @OnTextMessage + @Blocking + public String onMessage(String message) { + return bot.chat(message); + } + +} diff --git a/samples/mcp-tools/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/FilesystemToolProvider.java b/samples/mcp-tools/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/FilesystemToolProvider.java new file mode 100644 index 000000000..adcfafa69 --- /dev/null +++ b/samples/mcp-tools/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/FilesystemToolProvider.java @@ -0,0 +1,53 @@ +package io.quarkiverse.langchain4j.sample.chatbot; + +import dev.langchain4j.mcp.McpToolProvider; +import dev.langchain4j.mcp.client.DefaultMcpClient; +import dev.langchain4j.mcp.client.McpClient; +import dev.langchain4j.mcp.client.transport.McpTransport; +import dev.langchain4j.mcp.client.transport.stdio.StdioMcpTransport; +import dev.langchain4j.service.tool.ToolProvider; +import jakarta.enterprise.context.ApplicationScoped; + +import java.io.File; +import java.util.List; +import java.util.function.Supplier; + +/** + * THIS CLASS IS NOT USED! + * + * This class just shows how to manually provide a ToolProvider, but in this + * project, it is unused because we declaratively configure the + * `ToolProvider` via configuration properties, so this class serves just as + * a reference example. To use it instead of the generated one, uncomment + * the @ApplicationScoped annotation here and add a `toolProviderSupplier = + * FilesystemToolProvider.class` argument to the RegisterAiService + * annotation on the Bot interface. + */ +//@ApplicationScoped +public class FilesystemToolProvider implements Supplier { + + private McpTransport transport; + private McpClient mcpClient; + private ToolProvider toolProvider; + + @Override + public ToolProvider get() { + if(toolProvider == null) { + transport = new StdioMcpTransport.Builder() + .command(List.of("npm", "exec", + "@modelcontextprotocol/server-filesystem@0.6.2", + // allowed directory for the server to interact with + new File("playground").getAbsolutePath() + )) + .logEvents(true) + .build(); + mcpClient = new DefaultMcpClient.Builder() + .transport(transport) + .build(); + toolProvider = McpToolProvider.builder() + .mcpClients(mcpClient) + .build(); + } + return toolProvider; + } +} diff --git a/samples/mcp-tools/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/ImportmapResource.java b/samples/mcp-tools/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/ImportmapResource.java new file mode 100644 index 000000000..88d0ee21d --- /dev/null +++ b/samples/mcp-tools/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/ImportmapResource.java @@ -0,0 +1,51 @@ +package io.quarkiverse.langchain4j.sample.chatbot; + +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; + +import io.mvnpm.importmap.Aggregator; + +/** + * Dynamically create the import map + */ +@ApplicationScoped +@Path("/_importmap") +public class ImportmapResource { + private String importmap; + + // See https://github.com/WICG/import-maps/issues/235 + // This does not seem to be supported by browsers yet... + @GET + @Path("/dynamic.importmap") + @Produces("application/importmap+json") + public String importMap() { + return this.importmap; + } + + @GET + @Path("/dynamic-importmap.js") + @Produces("application/javascript") + public String importMapJson() { + return JAVASCRIPT_CODE.formatted(this.importmap); + } + + @PostConstruct + void init() { + Aggregator aggregator = new Aggregator(); + // Add our own mappings + aggregator.addMapping("icons/", "/icons/"); + aggregator.addMapping("components/", "/components/"); + aggregator.addMapping("fonts/", "/fonts/"); + this.importmap = aggregator.aggregateAsJson(); + } + + private static final String JAVASCRIPT_CODE = """ + const im = document.createElement('script'); + im.type = 'importmap'; + im.textContent = JSON.stringify(%s); + document.currentScript.after(im); + """; +} diff --git a/samples/mcp-tools/src/main/resources/META-INF/resources/components/demo-chat.js b/samples/mcp-tools/src/main/resources/META-INF/resources/components/demo-chat.js new file mode 100644 index 000000000..1b7823baf --- /dev/null +++ b/samples/mcp-tools/src/main/resources/META-INF/resources/components/demo-chat.js @@ -0,0 +1,64 @@ +import {css, LitElement} from 'lit'; + +export class DemoChat extends LitElement { + + _stripHtml(html) { + const div = document.createElement("div"); + div.innerHTML = html; + return div.textContent || div.innerText || ""; + } + + connectedCallback() { + const chatBot = document.getElementsByTagName("chat-bot")[0]; + + const protocol = (window.location.protocol === 'https:') ? 'wss' : 'ws'; + const socket = new WebSocket(protocol + '://' + window.location.host + '/chatbot'); + + const that = this; + socket.onmessage = function (event) { + chatBot.hideLastLoading(); + // LLM response + let lastMessage; + if (chatBot.messages.length > 0) { + lastMessage = chatBot.messages[chatBot.messages.length - 1]; + } + if (lastMessage && lastMessage.sender.name === "Bot" && ! lastMessage.loading) { + if (! lastMessage.msg) { + lastMessage.msg = ""; + } + lastMessage.msg += event.data; + let bubbles = chatBot.shadowRoot.querySelectorAll("chat-bubble"); + let bubble = bubbles.item(bubbles.length - 1); + if (lastMessage.message) { + bubble.innerHTML = that._stripHtml(lastMessage.message) + lastMessage.msg; + } else { + bubble.innerHTML = lastMessage.msg; + } + chatBot.body.scrollTo({ top: chatBot.body.scrollHeight, behavior: 'smooth' }) + } else { + chatBot.sendMessage(event.data, { + right: false, + sender: { + name: "Bot" + } + }); + } + } + + chatBot.addEventListener("sent", function (e) { + if (e.detail.message.sender.name !== "Bot") { + // User message + const msg = that._stripHtml(e.detail.message.message); + socket.send(msg); + chatBot.sendMessage("", { + right: false, + loading: true + }); + } + }); + } + + +} + +customElements.define('demo-chat', DemoChat); \ No newline at end of file diff --git a/samples/mcp-tools/src/main/resources/META-INF/resources/components/demo-title.js b/samples/mcp-tools/src/main/resources/META-INF/resources/components/demo-title.js new file mode 100644 index 000000000..2cdfe15f1 --- /dev/null +++ b/samples/mcp-tools/src/main/resources/META-INF/resources/components/demo-title.js @@ -0,0 +1,66 @@ +import {LitElement, html, css} from 'lit'; + +export class DemoTitle extends LitElement { + + static styles = css` + h1 { + font-family: "Red Hat Mono", monospace; + font-size: 60px; + font-style: normal; + font-variant: normal; + font-weight: 700; + line-height: 26.4px; + color: var(--main-highlight-text-color); + } + + .title { + text-align: center; + padding: 1em; + background: var(--main-bg-color); + } + + .explanation { + margin-left: auto; + margin-right: auto; + width: 50%; + text-align: justify; + font-size: 20px; + } + + .explanation img { + max-width: 60%; + display: block; + float:left; + margin-right: 2em; + margin-top: 1em; + } + ` + + render() { + return html` +
+

Filesystem assistant

+
+
+ This sample demonstrates the usage of MCP servers + (@modelcontextprotocol/server-filesystem in particular) to allow + the LLM to interact with the filesystem of the host machine. + + The only directory that the agent has access to is the playground + directory relative to this project's root directory, so all relative paths + that you provide will be resolved against that directory. + + Suggested prompts to try out: +
    +
  • Read the contents of the file hello.txt
  • +
  • Read the contents of the file hello2.txt (NOTE: this file does not exist)
  • +
  • Write a python script that takes two integer arguments and prints their sum, and then save it as adder.py
  • +
+
+ ` + } + + +} + +customElements.define('demo-title', DemoTitle); \ No newline at end of file diff --git a/samples/mcp-tools/src/main/resources/META-INF/resources/fonts/red-hat-font.min.css b/samples/mcp-tools/src/main/resources/META-INF/resources/fonts/red-hat-font.min.css new file mode 100644 index 000000000..f03010775 --- /dev/null +++ b/samples/mcp-tools/src/main/resources/META-INF/resources/fonts/red-hat-font.min.css @@ -0,0 +1 @@ +@font-face{font-family:"Red Hat Display";font-style:normal;font-weight:400;font-display:swap;src:url(https://fonts.gstatic.com/s/redhatdisplay/v7/8vIQ7wUr0m80wwYf0QCXZzYzUoTg8z6hR4jNCH5Z.woff2) format("woff2");unicode-range:U+0100-024F,U+0259,U+1E00-1EFF,U+2020,U+20A0-20AB,U+20AD-20CF,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:"Red Hat Display";font-style:normal;font-weight:400;font-display:swap;src:url(https://fonts.gstatic.com/s/redhatdisplay/v7/8vIQ7wUr0m80wwYf0QCXZzYzUoTg_T6hR4jNCA.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:"Red Hat Display";font-style:normal;font-weight:500;font-display:swap;src:url(https://fonts.gstatic.com/s/redhatdisplay/v7/8vIQ7wUr0m80wwYf0QCXZzYzUoTg8z6hR4jNCH5Z.woff2) format("woff2");unicode-range:U+0100-024F,U+0259,U+1E00-1EFF,U+2020,U+20A0-20AB,U+20AD-20CF,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:"Red Hat Display";font-style:normal;font-weight:500;font-display:swap;src:url(https://fonts.gstatic.com/s/redhatdisplay/v7/8vIQ7wUr0m80wwYf0QCXZzYzUoTg_T6hR4jNCA.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:"Red Hat Display";font-style:normal;font-weight:700;font-display:swap;src:url(https://fonts.gstatic.com/s/redhatdisplay/v7/8vIQ7wUr0m80wwYf0QCXZzYzUoTg8z6hR4jNCH5Z.woff2) format("woff2");unicode-range:U+0100-024F,U+0259,U+1E00-1EFF,U+2020,U+20A0-20AB,U+20AD-20CF,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:"Red Hat Display";font-style:normal;font-weight:700;font-display:swap;src:url(https://fonts.gstatic.com/s/redhatdisplay/v7/8vIQ7wUr0m80wwYf0QCXZzYzUoTg_T6hR4jNCA.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:"Red Hat Text";font-style:normal;font-weight:400;font-display:swap;src:url(https://fonts.gstatic.com/s/redhattext/v6/RrQXbohi_ic6B3yVSzGBrMxQZqctMc-JPWCN.woff2) format("woff2");unicode-range:U+0100-024F,U+0259,U+1E00-1EFF,U+2020,U+20A0-20AB,U+20AD-20CF,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:"Red Hat Text";font-style:normal;font-weight:400;font-display:swap;src:url(https://fonts.gstatic.com/s/redhattext/v6/RrQXbohi_ic6B3yVSzGBrMxQaKctMc-JPQ.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:"Red Hat Text";font-style:normal;font-weight:500;font-display:swap;src:url(https://fonts.gstatic.com/s/redhattext/v6/RrQXbohi_ic6B3yVSzGBrMxQZqctMc-JPWCN.woff2) format("woff2");unicode-range:U+0100-024F,U+0259,U+1E00-1EFF,U+2020,U+20A0-20AB,U+20AD-20CF,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:"Red Hat Text";font-style:normal;font-weight:500;font-display:swap;src:url(https://fonts.gstatic.com/s/redhattext/v6/RrQXbohi_ic6B3yVSzGBrMxQaKctMc-JPQ.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}/*# sourceMappingURL=red-hat-font.css.map */ \ No newline at end of file diff --git a/samples/mcp-tools/src/main/resources/META-INF/resources/images/chatbot-architecture.png b/samples/mcp-tools/src/main/resources/META-INF/resources/images/chatbot-architecture.png new file mode 100644 index 000000000..c5f104adb Binary files /dev/null and b/samples/mcp-tools/src/main/resources/META-INF/resources/images/chatbot-architecture.png differ diff --git a/samples/mcp-tools/src/main/resources/META-INF/resources/index.html b/samples/mcp-tools/src/main/resources/META-INF/resources/index.html new file mode 100644 index 000000000..87005b73e --- /dev/null +++ b/samples/mcp-tools/src/main/resources/META-INF/resources/index.html @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + ChatBot + + + + + + + + +
+ + + +
+ + + + \ No newline at end of file diff --git a/samples/mcp-tools/src/main/resources/application.properties b/samples/mcp-tools/src/main/resources/application.properties new file mode 100644 index 000000000..71214bd9e --- /dev/null +++ b/samples/mcp-tools/src/main/resources/application.properties @@ -0,0 +1,8 @@ +quarkus.langchain4j.timeout=60s +quarkus.log.category.\"dev.langchain4j\".level=DEBUG +quarkus.log.category.\"io.quarkiverse\".level=DEBUG + +quarkus.langchain4j.mcp.filesystem.transport-type=stdio +quarkus.langchain4j.mcp.filesystem.command=npm,exec,@modelcontextprotocol/server-filesystem@0.6.2,playground +quarkus.langchain4j.mcp.filesystem.log-requests=true +quarkus.langchain4j.mcp.filesystem.log-responses=true \ No newline at end of file diff --git a/samples/pom.xml b/samples/pom.xml index b04dd2cc7..415a88c04 100644 --- a/samples/pom.xml +++ b/samples/pom.xml @@ -17,6 +17,7 @@ cli-translator email-a-poem fraud-detection + mcp-tools review-triage secure-fraud-detection secure-poem-multiple-models