diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index e0b12a371..7407f17ba 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -36,6 +36,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..79aaf1999 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: 0.37.0-SNAPSHOT :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..dc3a61c69 --- /dev/null +++ b/docs/modules/ROOT/pages/includes/quarkus-langchain4j-mcp.adoc @@ -0,0 +1,138 @@ +: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 values 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-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 +| + +|=== + + +:!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..dc3a61c69 --- /dev/null +++ b/docs/modules/ROOT/pages/includes/quarkus-langchain4j-mcp_quarkus.langchain4j.adoc @@ -0,0 +1,138 @@ +: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 values 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-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 +| + +|=== + + +:!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-tools.adoc b/docs/modules/ROOT/pages/mcp-tools.adoc new file mode 100644 index 000000000..f87f5c14c --- /dev/null +++ b/docs/modules/ROOT/pages/mcp-tools.adoc @@ -0,0 +1 @@ +# TODO \ No newline at end of file diff --git a/docs/modules/ROOT/pages/mcp.adoc b/docs/modules/ROOT/pages/mcp.adoc new file mode 100644 index 000000000..8d387f61a --- /dev/null +++ b/docs/modules/ROOT/pages/mcp.adoc @@ -0,0 +1,33 @@ += 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. + +// TODO: double-check the https://docs.langchain4j.dev/tutorials/mcp link +// works after the langchain4j docs are updated, as well as the mcp-tools project link + +== 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. 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/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..22823506d --- /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 values 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..e86e6c1df --- /dev/null +++ b/mcp/runtime/src/main/java/io/quarkiverse/langchain4j/mcp/runtime/config/McpConfiguration.java @@ -0,0 +1,32 @@ +package io.quarkiverse.langchain4j.mcp.runtime.config; + +import java.util.Map; +import java.util.Optional; + +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. + */ + 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..7e807be4e --- /dev/null +++ b/mcp/runtime/src/main/java/io/quarkiverse/langchain4j/mcp/runtime/http/McpPostEndpoint.java @@ -0,0 +1,18 @@ +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; + +public interface McpPostEndpoint { + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + Response 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..c76aa9b89 --- /dev/null +++ b/mcp/runtime/src/main/java/io/quarkiverse/langchain4j/mcp/runtime/http/QuarkusHttpMcpTransport.java @@ -0,0 +1,217 @@ +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.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import jakarta.ws.rs.core.Response; + +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 com.fasterxml.jackson.databind.ObjectMapper; + +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.McpTransport; +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; + private final Map> pendingOperations = new ConcurrentHashMap<>(); + + // this is obtained from the server after initializing the SSE channel + private volatile String postUrl; + private volatile McpPostEndpoint postEndpoint; + + 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(new ObjectMapper())); + if (logRequests || logResponses) { + clientBuilder.loggingScope(LoggingScope.REQUEST_RESPONSE); + clientBuilder.clientLogger(new McpHttpClientLogger(logRequests, logResponses)); + } + sseEndpoint = clientBuilder.build(McpSseEndpoint.class); + } + + @Override + public void start() { + mcpSseEventListener = startSseChannel(logResponses); + QuarkusRestClientBuilder builder = QuarkusRestClientBuilder.newBuilder() + .baseUri(URI.create(postUrl)) + .connectTimeout(timeout.toSeconds(), TimeUnit.SECONDS) + .readTimeout(timeout.toSeconds(), TimeUnit.SECONDS) + .register(new JacksonBasicMessageBodyReader(new ObjectMapper())); + if (logRequests || logResponses) { + builder.loggingScope(LoggingScope.REQUEST_RESPONSE); + builder.clientLogger(new McpHttpClientLogger(logRequests, logResponses)); + } + postEndpoint = builder + .build(McpPostEndpoint.class); + } + + @Override + public JsonNode initialize(McpInitializeRequest request) { + return executeAndWait(request, request.getId()); + } + + @Override + public JsonNode listTools(McpListToolsRequest operation) { + return executeAndWait(operation, operation.getId()); + } + + @Override + public JsonNode executeTool(McpCallToolRequest operation, Duration timeout) throws TimeoutException { + return executeAndWait(operation, operation.getId(), timeout); + } + + private JsonNode executeAndWait(McpClientMessage request, Long id) { + try { + CompletableFuture future = new CompletableFuture<>(); + pendingOperations.put(id, future); + try (Response response = postEndpoint.post(request)) { + int statusCode = response.getStatus(); + if (!isExpectedStatusCode(statusCode)) { + throw new RuntimeException("Unexpected status code: " + statusCode); + } + } + return future.get(); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + pendingOperations.remove(id); + } + } + + private boolean isExpectedStatusCode(int statusCode) { + return statusCode >= 200 && statusCode < 300; + } + + private JsonNode executeAndWait(McpClientMessage request, Long id, Duration timeout) throws TimeoutException { + try { + CompletableFuture future = new CompletableFuture<>(); + pendingOperations.put(id, future); + try (Response response = postEndpoint.post(request)) { + int statusCode = response.getStatus(); + if (!isExpectedStatusCode(statusCode)) { + throw new RuntimeException("Unexpected status code: {}" + statusCode); + } + } + long timeoutMillis = timeout.toMillis() == 0 ? Long.MAX_VALUE : timeout.toMillis(); + return future.get(timeoutMillis, TimeUnit.MILLISECONDS); + } catch (TimeoutException timeoutException) { + CancellationNotification cancellationNotification = new CancellationNotification(id, "Timeout"); + try (Response response = postEndpoint.post(cancellationNotification)) { + if (!isExpectedStatusCode(response.getStatus())) { + log.warn("Failed to send cancellation notification, the server returned: " + response.getStatus()); + } + } + throw timeoutException; + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + pendingOperations.remove(id); + } + } + + private SseSubscriber startSseChannel(boolean logResponses) { + CompletableFuture initializationFinished = new CompletableFuture<>(); + SseSubscriber listener = new SseSubscriber(pendingOperations, 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..752d71861 --- /dev/null +++ b/mcp/runtime/src/main/java/io/quarkiverse/langchain4j/mcp/runtime/http/SseSubscriber.java @@ -0,0 +1,65 @@ +package io.quarkiverse.langchain4j.mcp.runtime.http; + +import java.util.Map; +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.http.SseEventListener; + +public class SseSubscriber implements Consumer> { + + private final Map> pendingOperations; + 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( + Map> pendingOperations, + boolean logEvents, + CompletableFuture initializationFinished) { + this.pendingOperations = pendingOperations; + 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; + } + if (name.equals("message")) { + try { + JsonNode message = OBJECT_MAPPER.readValue(s.data(), JsonNode.class); + long messageId = message.get("id").asLong(); + CompletableFuture op = pendingOperations.remove(messageId); + if (op != null) { + op.complete(message); + } else { + log.warn("Received response for unknown message id: " + messageId); + } + } catch (JsonProcessingException e) { + log.warn("Failed to parse response 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/pom.xml b/pom.xml index 70e2795e8..ec6e9ccd0 100644 --- a/pom.xml +++ b/pom.xml @@ -14,6 +14,7 @@ core embedding-stores + mcp memory-stores model-auth-providers model-providers @@ -31,7 +32,7 @@ 3.15.2 - 0.36.2 + 0.37.0-SNAPSHOT 0.36.2 1.0.1 2.0.4 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