This is an automated email from the ASF dual-hosted git repository. acosentino pushed a commit to branch 22886 in repository https://gitbox.apache.org/repos/asf/camel.git
commit 1c5e4e2b505bfca821be635061a0baa0fef7ba39 Author: Andrea Cosentino <[email protected]> AuthorDate: Thu Jan 22 09:24:05 2026 +0100 CAMEL-22886 - Camel-Jbang: Add an MCP module Signed-off-by: Andrea Cosentino <[email protected]> --- dsl/camel-jbang/camel-jbang-mcp/pom.xml | 143 ++++++++ .../camel-jbang-plugin/camel-jbang-plugin-mcp | 2 + .../dsl/jbang/core/commands/mcp/CatalogTools.java | 363 +++++++++++++++++++++ .../dsl/jbang/core/commands/mcp/ExplainTools.java | 212 ++++++++++++ .../jbang/core/commands/mcp/TransformTools.java | 188 +++++++++++ .../src/main/resources/application.properties | 41 +++ dsl/camel-jbang/pom.xml | 1 + 7 files changed, 950 insertions(+) diff --git a/dsl/camel-jbang/camel-jbang-mcp/pom.xml b/dsl/camel-jbang/camel-jbang-mcp/pom.xml new file mode 100644 index 000000000000..a66bfdbb5721 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-mcp/pom.xml @@ -0,0 +1,143 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + + Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You under the Apache License, Version 2.0 + (the "License"); you may not use this file except in compliance with + the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +--> +<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + + <modelVersion>4.0.0</modelVersion> + + <parent> + <groupId>org.apache.camel</groupId> + <artifactId>camel-jbang-parent</artifactId> + <version>4.18.0-SNAPSHOT</version> + <relativePath>../pom.xml</relativePath> + </parent> + + <artifactId>camel-jbang-mcp</artifactId> + <packaging>jar</packaging> + + <name>Camel :: JBang :: MCP</name> + <description>Camel JBang MCP (Model Context Protocol) Server using Quarkus</description> + + <properties> + <firstVersion>4.18.0</firstVersion> + <label>jbang,mcp,ai</label> + <supportLevel>Preview</supportLevel> + <camel-prepare-component>false</camel-prepare-component> + <quarkus-mcp-server-version>1.2.0</quarkus-mcp-server-version> + <quarkus.platform.version>3.30.6</quarkus.platform.version> + <!-- Build uber-jar for easy JBang execution --> + <quarkus.package.jar.type>uber-jar</quarkus.package.jar.type> + </properties> + + <dependencyManagement> + <dependencies> + <dependency> + <groupId>io.quarkus.platform</groupId> + <artifactId>quarkus-bom</artifactId> + <version>${quarkus.platform.version}</version> + <type>pom</type> + <scope>import</scope> + </dependency> + </dependencies> + </dependencyManagement> + + <dependencies> + <!-- Quarkus MCP Server with STDIO transport --> + <dependency> + <groupId>io.quarkiverse.mcp</groupId> + <artifactId>quarkus-mcp-server-stdio</artifactId> + <version>${quarkus-mcp-server-version}</version> + </dependency> + + <!-- Quarkus core dependencies --> + <dependency> + <groupId>io.quarkus</groupId> + <artifactId>quarkus-arc</artifactId> + </dependency> + <dependency> + <groupId>io.quarkus</groupId> + <artifactId>quarkus-jackson</artifactId> + </dependency> + + <!-- Quarkus LangChain4j for LLM integration --> + <dependency> + <groupId>io.quarkiverse.langchain4j</groupId> + <artifactId>quarkus-langchain4j-ollama</artifactId> + <version>1.6.0.CR1</version> + </dependency> + + <!-- camel catalog for component/eip metadata --> + <dependency> + <groupId>org.apache.camel</groupId> + <artifactId>camel-catalog</artifactId> + </dependency> + <dependency> + <groupId>org.apache.camel</groupId> + <artifactId>camel-route-parser</artifactId> + </dependency> + + <!-- test dependencies --> + <dependency> + <groupId>io.quarkus</groupId> + <artifactId>quarkus-junit5</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.assertj</groupId> + <artifactId>assertj-core</artifactId> + <scope>test</scope> + </dependency> + </dependencies> + + <build> + <plugins> + <plugin> + <groupId>io.quarkus.platform</groupId> + <artifactId>quarkus-maven-plugin</artifactId> + <version>${quarkus.platform.version}</version> + <extensions>true</extensions> + <executions> + <execution> + <goals> + <goal>build</goal> + <goal>generate-code</goal> + <goal>generate-code-tests</goal> + </goals> + </execution> + </executions> + </plugin> + <plugin> + <artifactId>maven-compiler-plugin</artifactId> + <configuration> + <parameters>true</parameters> + </configuration> + </plugin> + <plugin> + <artifactId>maven-surefire-plugin</artifactId> + <configuration> + <systemPropertyVariables> + <java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager> + </systemPropertyVariables> + </configuration> + </plugin> + </plugins> + </build> + +</project> diff --git a/dsl/camel-jbang/camel-jbang-mcp/src/generated/resources/META-INF/services/org/apache/camel/camel-jbang-plugin/camel-jbang-plugin-mcp b/dsl/camel-jbang/camel-jbang-mcp/src/generated/resources/META-INF/services/org/apache/camel/camel-jbang-plugin/camel-jbang-plugin-mcp new file mode 100644 index 000000000000..d57b288500ea --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-mcp/src/generated/resources/META-INF/services/org/apache/camel/camel-jbang-plugin/camel-jbang-plugin-mcp @@ -0,0 +1,2 @@ +# Generated by camel build tools - do NOT edit this file! +class=org.apache.camel.dsl.jbang.core.commands.mcp.McpPlugin diff --git a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/CatalogTools.java b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/CatalogTools.java new file mode 100644 index 000000000000..dcd315d620a0 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/CatalogTools.java @@ -0,0 +1,363 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.mcp; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.quarkiverse.mcp.server.Tool; +import io.quarkiverse.mcp.server.ToolArg; +import io.quarkiverse.mcp.server.ToolCallException; +import org.apache.camel.catalog.CamelCatalog; +import org.apache.camel.catalog.DefaultCamelCatalog; +import org.apache.camel.tooling.model.ComponentModel; +import org.apache.camel.tooling.model.DataFormatModel; +import org.apache.camel.tooling.model.EipModel; +import org.apache.camel.tooling.model.LanguageModel; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; + +/** + * MCP Tools for querying the Camel Catalog using Quarkus MCP Server. + */ +@ApplicationScoped +public class CatalogTools { + + private final CamelCatalog catalog; + + @Inject + ObjectMapper mapper; + + public CatalogTools() { + this.catalog = new DefaultCamelCatalog(); + } + + /** + * Tool to list available Camel components. + */ + @Tool(description = "List available Camel components from the catalog. " + + "Returns component name, description, and labels. " + + "Use filter to search by name, label to filter by category.") + public String camel_catalog_components( + @ToolArg(description = "Filter components by name (case-insensitive substring match)") String filter, + @ToolArg(description = "Filter by category label (e.g., cloud, messaging, database, file)") String label, + @ToolArg(description = "Maximum number of results to return (default: 50)") Integer limit) { + + int maxResults = limit != null ? limit : 50; + + try { + List<Map<String, Object>> components = catalog.findComponentNames().stream() + .map(catalog::componentModel) + .filter(m -> m != null) + .filter(m -> matchesFilter(m.getScheme(), m.getTitle(), m.getDescription(), filter)) + .filter(m -> matchesLabel(m.getLabel(), label)) + .limit(maxResults) + .map(this::componentToMap) + .collect(Collectors.toList()); + + JsonObject resultJson = new JsonObject(); + resultJson.put("count", components.size()); + resultJson.put("camelVersion", catalog.getCatalogVersion()); + JsonArray arr = new JsonArray(); + components.forEach(c -> { + JsonObject jo = new JsonObject(); + jo.putAll(c); + arr.add(jo); + }); + resultJson.put("components", arr); + + return resultJson.toJson(); + } catch (Exception e) { + throw new ToolCallException("Failed to list components: " + e.getMessage(), e); + } + } + + /** + * Tool to get detailed documentation for a specific component. + */ + @Tool(description = "Get detailed documentation for a Camel component including all options, " + + "endpoint parameters, and usage examples.") + public String camel_catalog_component_doc( + @ToolArg(description = "Component name (e.g., kafka, http, file, timer)") String component) { + + if (component == null || component.isBlank()) { + throw new ToolCallException("Component name is required", null); + } + + ComponentModel model = catalog.componentModel(component); + if (model == null) { + throw new ToolCallException("Component not found: " + component, null); + } + + JsonObject resultJson = componentToDetailedJson(model); + return resultJson.toJson(); + } + + /** + * Tool to list data formats. + */ + @Tool(description = "List available Camel data formats for marshalling/unmarshalling " + + "(e.g., json, xml, csv, avro, protobuf).") + public String camel_catalog_dataformats( + @ToolArg(description = "Filter by name") String filter, + @ToolArg(description = "Maximum results (default: 50)") Integer limit) { + + int maxResults = limit != null ? limit : 50; + + try { + List<Map<String, Object>> dataFormats = catalog.findDataFormatNames().stream() + .map(catalog::dataFormatModel) + .filter(m -> m != null) + .filter(m -> matchesFilter(m.getName(), m.getTitle(), m.getDescription(), filter)) + .limit(maxResults) + .map(this::dataFormatToMap) + .collect(Collectors.toList()); + + JsonObject resultJson = new JsonObject(); + resultJson.put("count", dataFormats.size()); + JsonArray arr = new JsonArray(); + dataFormats.forEach(df -> { + JsonObject jo = new JsonObject(); + jo.putAll(df); + arr.add(jo); + }); + resultJson.put("dataFormats", arr); + + return resultJson.toJson(); + } catch (Exception e) { + throw new ToolCallException("Failed to list data formats: " + e.getMessage(), e); + } + } + + /** + * Tool to list expression languages. + */ + @Tool(description = "List available Camel expression languages " + + "(e.g., simple, jsonpath, xpath, groovy, jq).") + public String camel_catalog_languages( + @ToolArg(description = "Filter by name") String filter) { + + try { + List<Map<String, Object>> languages = catalog.findLanguageNames().stream() + .map(catalog::languageModel) + .filter(m -> m != null) + .filter(m -> matchesFilter(m.getName(), m.getTitle(), m.getDescription(), filter)) + .map(this::languageToMap) + .collect(Collectors.toList()); + + JsonObject resultJson = new JsonObject(); + resultJson.put("count", languages.size()); + JsonArray arr = new JsonArray(); + languages.forEach(l -> { + JsonObject jo = new JsonObject(); + jo.putAll(l); + arr.add(jo); + }); + resultJson.put("languages", arr); + + return resultJson.toJson(); + } catch (Exception e) { + throw new ToolCallException("Failed to list languages: " + e.getMessage(), e); + } + } + + /** + * Tool to list EIPs (Enterprise Integration Patterns). + */ + @Tool(description = "List Camel Enterprise Integration Patterns (EIPs) like split, aggregate, " + + "filter, choice, multicast, circuit-breaker, etc.") + public String camel_catalog_eips( + @ToolArg(description = "Filter by name") String filter, + @ToolArg(description = "Filter by category (e.g., routing, transformation, error handling)") String label) { + + try { + List<Map<String, Object>> eips = catalog.findModelNames().stream() + .map(catalog::eipModel) + .filter(m -> m != null) + .filter(m -> matchesFilter(m.getName(), m.getTitle(), m.getDescription(), filter)) + .filter(m -> matchesLabel(m.getLabel(), label)) + .map(this::eipToMap) + .collect(Collectors.toList()); + + JsonObject resultJson = new JsonObject(); + resultJson.put("count", eips.size()); + JsonArray arr = new JsonArray(); + eips.forEach(e -> { + JsonObject jo = new JsonObject(); + jo.putAll(e); + arr.add(jo); + }); + resultJson.put("eips", arr); + + return resultJson.toJson(); + } catch (Exception e) { + throw new ToolCallException("Failed to list EIPs: " + e.getMessage(), e); + } + } + + /** + * Tool to get detailed documentation for a specific EIP. + */ + @Tool(description = "Get detailed documentation for a Camel EIP (Enterprise Integration Pattern).") + public String camel_catalog_eip_doc( + @ToolArg(description = "EIP name (e.g., split, aggregate, choice, filter)") String eip) { + + if (eip == null || eip.isBlank()) { + throw new ToolCallException("EIP name is required", null); + } + + EipModel model = catalog.eipModel(eip); + if (model == null) { + throw new ToolCallException("EIP not found: " + eip, null); + } + + JsonObject resultJson = eipToDetailedJson(model); + return resultJson.toJson(); + } + + // Helper methods + + private boolean matchesFilter(String name, String title, String description, String filter) { + if (filter == null || filter.isBlank()) { + return true; + } + String lowerFilter = filter.toLowerCase(); + return (name != null && name.toLowerCase().contains(lowerFilter)) + || (title != null && title.toLowerCase().contains(lowerFilter)) + || (description != null && description.toLowerCase().contains(lowerFilter)); + } + + private boolean matchesLabel(String labels, String labelFilter) { + if (labelFilter == null || labelFilter.isBlank()) { + return true; + } + if (labels == null) { + return false; + } + return labels.toLowerCase().contains(labelFilter.toLowerCase()); + } + + private Map<String, Object> componentToMap(ComponentModel model) { + return Map.of( + "name", model.getScheme(), + "title", model.getTitle() != null ? model.getTitle() : "", + "description", model.getDescription() != null ? model.getDescription() : "", + "label", model.getLabel() != null ? model.getLabel() : "", + "deprecated", model.isDeprecated(), + "supportLevel", model.getSupportLevel() != null ? model.getSupportLevel().name() : ""); + } + + private JsonObject componentToDetailedJson(ComponentModel model) { + JsonObject jo = new JsonObject(); + jo.put("name", model.getScheme()); + jo.put("title", model.getTitle()); + jo.put("description", model.getDescription()); + jo.put("label", model.getLabel()); + jo.put("deprecated", model.isDeprecated()); + jo.put("supportLevel", model.getSupportLevel() != null ? model.getSupportLevel().name() : null); + jo.put("groupId", model.getGroupId()); + jo.put("artifactId", model.getArtifactId()); + jo.put("version", model.getVersion()); + jo.put("syntax", model.getSyntax()); + jo.put("async", model.isAsync()); + jo.put("consumerOnly", model.isConsumerOnly()); + jo.put("producerOnly", model.isProducerOnly()); + + JsonArray options = new JsonArray(); + if (model.getComponentOptions() != null) { + model.getComponentOptions().forEach(opt -> { + JsonObject optJson = new JsonObject(); + optJson.put("name", opt.getName()); + optJson.put("description", opt.getDescription()); + optJson.put("type", opt.getType()); + optJson.put("required", opt.isRequired()); + optJson.put("defaultValue", opt.getDefaultValue()); + options.add(optJson); + }); + } + jo.put("componentOptions", options); + + JsonArray endpointOptions = new JsonArray(); + if (model.getEndpointOptions() != null) { + model.getEndpointOptions().forEach(opt -> { + JsonObject optJson = new JsonObject(); + optJson.put("name", opt.getName()); + optJson.put("description", opt.getDescription()); + optJson.put("type", opt.getType()); + optJson.put("required", opt.isRequired()); + optJson.put("defaultValue", opt.getDefaultValue()); + optJson.put("group", opt.getGroup()); + endpointOptions.add(optJson); + }); + } + jo.put("endpointOptions", endpointOptions); + + return jo; + } + + private Map<String, Object> dataFormatToMap(DataFormatModel model) { + return Map.of( + "name", model.getName(), + "title", model.getTitle() != null ? model.getTitle() : "", + "description", model.getDescription() != null ? model.getDescription() : "", + "deprecated", model.isDeprecated()); + } + + private Map<String, Object> languageToMap(LanguageModel model) { + return Map.of( + "name", model.getName(), + "title", model.getTitle() != null ? model.getTitle() : "", + "description", model.getDescription() != null ? model.getDescription() : ""); + } + + private Map<String, Object> eipToMap(EipModel model) { + return Map.of( + "name", model.getName(), + "title", model.getTitle() != null ? model.getTitle() : "", + "description", model.getDescription() != null ? model.getDescription() : "", + "label", model.getLabel() != null ? model.getLabel() : ""); + } + + private JsonObject eipToDetailedJson(EipModel model) { + JsonObject jo = new JsonObject(); + jo.put("name", model.getName()); + jo.put("title", model.getTitle()); + jo.put("description", model.getDescription()); + jo.put("label", model.getLabel()); + + JsonArray options = new JsonArray(); + if (model.getOptions() != null) { + model.getOptions().forEach(opt -> { + JsonObject optJson = new JsonObject(); + optJson.put("name", opt.getName()); + optJson.put("description", opt.getDescription()); + optJson.put("type", opt.getType()); + optJson.put("required", opt.isRequired()); + optJson.put("defaultValue", opt.getDefaultValue()); + options.add(optJson); + }); + } + jo.put("options", options); + + return jo; + } +} diff --git a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/ExplainTools.java b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/ExplainTools.java new file mode 100644 index 000000000000..0ea1ffd5364d --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/ExplainTools.java @@ -0,0 +1,212 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.mcp; + +import java.util.Arrays; +import java.util.List; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import dev.langchain4j.model.chat.ChatModel; +import io.quarkiverse.mcp.server.Tool; +import io.quarkiverse.mcp.server.ToolArg; +import io.quarkiverse.mcp.server.ToolCallException; +import org.apache.camel.catalog.CamelCatalog; +import org.apache.camel.catalog.DefaultCamelCatalog; +import org.apache.camel.tooling.model.ComponentModel; +import org.apache.camel.tooling.model.EipModel; +import org.apache.camel.util.json.JsonObject; + +/** + * MCP Tool for explaining Camel routes using AI/LLM services. + * <p> + * The LLM is configured at the application level via application.properties. Supported configurations include: + * <ul> + * <li>Ollama: quarkus.langchain4j.ollama.*</li> + * <li>OpenAI: quarkus.langchain4j.openai.*</li> + * </ul> + */ +@ApplicationScoped +public class ExplainTools { + + private static final List<String> COMMON_COMPONENTS = Arrays.asList( + "kafka", "http", "https", "file", "timer", "direct", "seda", + "log", "mock", "rest", "rest-api", "sql", "jms", "activemq", + "aws2-s3", "aws2-sqs", "aws2-sns", "aws2-kinesis", + "azure-storage-blob", "azure-storage-queue", + "google-pubsub", "google-storage", + "mongodb", "couchdb", "cassandraql", + "elasticsearch", "opensearch", + "ftp", "sftp", "ftps", + "smtp", "imap", "pop3", + "websocket", "netty-http", "vertx-http", + "bean", "process", "script"); + + private static final List<String> COMMON_EIPS = Arrays.asList( + "split", "aggregate", "filter", "choice", "when", "otherwise", + "multicast", "recipientList", "routingSlip", "dynamicRouter", + "circuitBreaker", "throttle", "delay", "resequence", + "idempotentConsumer", "loadBalance", "saga", + "onException", "doTry", "doCatch", "doFinally", + "transform", "setBody", "setHeader", "removeHeader", + "marshal", "unmarshal", "convertBodyTo", + "enrich", "pollEnrich", "wireTap", "pipeline"); + + private final CamelCatalog catalog; + + @Inject + ChatModel chatModel; + + public ExplainTools() { + this.catalog = new DefaultCamelCatalog(); + } + + /** + * Tool to explain a Camel route using AI/LLM. + */ + @Tool(description = "Explain what a Camel route does using AI/LLM. " + + "Returns a human-readable explanation of the route's purpose and flow. " + + "The LLM is configured at the server level via application.properties.") + public String camel_explain_route( + @ToolArg(description = "The Camel route content (YAML, XML, or Java DSL)") String route, + @ToolArg(description = "Route format: yaml, xml, or java (default: yaml)") String format, + @ToolArg(description = "Include Camel Catalog context in prompt (default: true)") Boolean catalogContext, + @ToolArg(description = "Include verbose technical details (default: false)") Boolean verbose) { + + if (route == null || route.isBlank()) { + throw new ToolCallException("Route content is required", null); + } + + if (chatModel == null) { + throw new ToolCallException( + "No LLM configured. Please configure an LLM provider in application.properties " + + "(e.g., quarkus.langchain4j.ollama.* or quarkus.langchain4j.openai.*)", + null); + } + + // Set defaults + String resolvedFormat = format != null && !format.isBlank() ? format.toLowerCase() : "yaml"; + boolean useCatalogContext = catalogContext == null || catalogContext; + boolean useVerbose = verbose != null && verbose; + + // Build the prompt + String systemPrompt = buildSystemPrompt(useVerbose); + String userPrompt = buildUserPrompt(route, resolvedFormat, useCatalogContext); + String fullPrompt = systemPrompt + "\n\n" + userPrompt; + + // Call the injected LLM + String explanation; + try { + explanation = chatModel.chat(fullPrompt); + } catch (Exception e) { + throw new ToolCallException("Error calling LLM: " + e.getMessage(), e); + } + + if (explanation == null || explanation.isBlank()) { + throw new ToolCallException("Failed to get explanation from LLM", null); + } + + // Build result + JsonObject result = new JsonObject(); + result.put("explanation", explanation); + result.put("format", resolvedFormat); + + return result.toJson(); + } + + private String buildSystemPrompt(boolean verbose) { + StringBuilder prompt = new StringBuilder(); + prompt.append("You are an Apache Camel integration expert. "); + prompt.append("Your task is to explain Camel routes in plain English.\n\n"); + prompt.append("Guidelines:\n"); + prompt.append("- Start with a one-sentence summary\n"); + prompt.append("- Describe step by step what the route does\n"); + prompt.append("- Explain each component and EIP used\n"); + prompt.append("- Mention error handling if present\n"); + prompt.append("- Use bullet points for clarity\n"); + prompt.append("- Format output as Markdown\n"); + + if (verbose) { + prompt.append("- Include technical details about options and expressions\n"); + } + + return prompt.toString(); + } + + private String buildUserPrompt(String routeContent, String fileExtension, boolean includeCatalogContext) { + StringBuilder prompt = new StringBuilder(); + + if (includeCatalogContext) { + String catalogInfo = buildCatalogContext(routeContent); + if (!catalogInfo.isEmpty()) { + prompt.append("Reference information:\n").append(catalogInfo).append("\n"); + } + } + + prompt.append("Format: ").append(fileExtension.toUpperCase()).append("\n\n"); + prompt.append("Route definition:\n```").append(fileExtension).append("\n"); + prompt.append(routeContent).append("\n```\n\n"); + prompt.append("Please explain this Camel route:"); + + return prompt.toString(); + } + + private String buildCatalogContext(String routeContent) { + StringBuilder context = new StringBuilder(); + String lowerContent = routeContent.toLowerCase(); + + // Add component descriptions + for (String comp : COMMON_COMPONENTS) { + if (containsComponent(lowerContent, comp)) { + ComponentModel model = catalog.componentModel(comp); + if (model != null && model.getDescription() != null) { + context.append("- ").append(comp).append(": ").append(model.getDescription()).append("\n"); + } + } + } + + // Add EIP descriptions + for (String eip : COMMON_EIPS) { + if (lowerContent.contains(eip.toLowerCase()) || lowerContent.contains(camelCaseToDash(eip))) { + EipModel model = catalog.eipModel(eip); + if (model != null && model.getDescription() != null) { + context.append("- ").append(eip).append(" (EIP): ").append(model.getDescription()).append("\n"); + } + } + } + + return context.toString(); + } + + private boolean containsComponent(String content, String comp) { + return content.contains(comp + ":") + || content.contains("\"" + comp + "\"") + || content.contains("'" + comp + "'"); + } + + private String camelCaseToDash(String text) { + StringBuilder sb = new StringBuilder(); + for (char c : text.toCharArray()) { + if (Character.isUpperCase(c) && sb.length() > 0) { + sb.append('-'); + } + sb.append(Character.toLowerCase(c)); + } + return sb.toString(); + } +} diff --git a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/TransformTools.java b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/TransformTools.java new file mode 100644 index 000000000000..d80ba6ea10d6 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/TransformTools.java @@ -0,0 +1,188 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.mcp; + +import java.util.ArrayList; +import java.util.List; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkiverse.mcp.server.Tool; +import io.quarkiverse.mcp.server.ToolArg; +import io.quarkiverse.mcp.server.ToolCallException; +import org.apache.camel.catalog.CamelCatalog; +import org.apache.camel.catalog.DefaultCamelCatalog; +import org.apache.camel.catalog.EndpointValidationResult; +import org.apache.camel.util.json.JsonObject; + +/** + * MCP Tools for validating and transforming Camel routes using Quarkus MCP Server. + */ +@ApplicationScoped +public class TransformTools { + + private final CamelCatalog catalog; + + public TransformTools() { + this.catalog = new DefaultCamelCatalog(); + } + + /** + * Tool to validate a Camel route or endpoint URI. + */ + @Tool(description = "Validate a Camel endpoint URI or route definition. " + + "Checks syntax, required options, and valid parameter names.") + public String camel_validate_route( + @ToolArg(description = "Camel endpoint URI to validate (e.g., 'kafka:myTopic?brokers=localhost:9092')") String uri, + @ToolArg(description = "YAML route definition to validate") String route) { + + if (uri == null && route == null) { + throw new ToolCallException("Either 'uri' or 'route' is required", null); + } + + JsonObject resultJson = new JsonObject(); + + if (uri != null) { + EndpointValidationResult validation = catalog.validateEndpointProperties(uri); + resultJson.put("uri", uri); + resultJson.put("valid", validation.isSuccess()); + + if (!validation.isSuccess()) { + JsonObject errors = new JsonObject(); + if (validation.getUnknown() != null && !validation.getUnknown().isEmpty()) { + errors.put("unknownOptions", String.join(", ", validation.getUnknown())); + } + if (validation.getRequired() != null && !validation.getRequired().isEmpty()) { + errors.put("missingRequired", String.join(", ", validation.getRequired())); + } + if (validation.getInvalidEnum() != null && !validation.getInvalidEnum().isEmpty()) { + errors.put("invalidEnumValues", validation.getInvalidEnum().toString()); + } + if (validation.getInvalidInteger() != null && !validation.getInvalidInteger().isEmpty()) { + errors.put("invalidIntegers", validation.getInvalidInteger().toString()); + } + if (validation.getInvalidBoolean() != null && !validation.getInvalidBoolean().isEmpty()) { + errors.put("invalidBooleans", validation.getInvalidBoolean().toString()); + } + if (validation.getSyntaxError() != null) { + errors.put("syntaxError", validation.getSyntaxError()); + } + resultJson.put("errors", errors); + + if (validation.getUnknown() != null && validation.getUnknownSuggestions() != null) { + JsonObject suggestions = new JsonObject(); + for (String unknown : validation.getUnknown()) { + String[] suggestionArr = validation.getUnknownSuggestions().get(unknown); + if (suggestionArr != null && suggestionArr.length > 0) { + suggestions.put(unknown, String.join(", ", suggestionArr)); + } + } + if (!suggestions.isEmpty()) { + resultJson.put("suggestions", suggestions); + } + } + } + } + + if (route != null) { + resultJson.put("routeProvided", true); + resultJson.put("valid", true); + resultJson.put("note", + "Full route validation requires loading the route into a CamelContext. " + + "Use 'camel run --validate' for complete validation."); + + List<String> uris = extractUrisFromRoute(route); + if (!uris.isEmpty()) { + JsonObject uriValidations = new JsonObject(); + boolean allValid = true; + for (String extractedUri : uris) { + EndpointValidationResult validation = catalog.validateEndpointProperties(extractedUri); + uriValidations.put(extractedUri, validation.isSuccess()); + if (!validation.isSuccess()) { + allValid = false; + } + } + resultJson.put("uriValidations", uriValidations); + resultJson.put("valid", allValid); + } + } + + return resultJson.toJson(); + } + + /** + * Tool to transform routes between DSL formats. + */ + @Tool(description = "Transform a Camel route between different DSL formats (YAML, XML). " + + "Note: Java to YAML/XML transformation has limitations.") + public String camel_transform_route( + @ToolArg(description = "Route definition to transform") String route, + @ToolArg(description = "Source format (yaml, xml, java)") String fromFormat, + @ToolArg(description = "Target format (yaml, xml)") String toFormat) { + + if (route == null || fromFormat == null || toFormat == null) { + throw new ToolCallException("route, fromFormat, and toFormat are required", null); + } + + JsonObject resultJson = new JsonObject(); + resultJson.put("fromFormat", fromFormat); + resultJson.put("toFormat", toFormat); + resultJson.put("note", + "Route transformation requires the full Camel route parser. " + + "Use 'camel transform route' CLI command for complete transformation."); + resultJson.put("supported", true); + resultJson.put("recommendation", + "Use 'camel transform route --format=" + toFormat + " <file>' for DSL transformation"); + + return resultJson.toJson(); + } + + /** + * Extract endpoint URIs from a YAML route definition. + */ + private List<String> extractUrisFromRoute(String route) { + List<String> uris = new ArrayList<>(); + + String[] lines = route.split("\n"); + for (String line : lines) { + line = line.trim(); + if (line.contains(":") && !line.startsWith("#")) { + int colonPos = line.indexOf(":"); + if (colonPos > 0 && colonPos < line.length() - 1) { + String key = line.substring(0, colonPos).trim(); + String value = line.substring(colonPos + 1).trim(); + + if (value.startsWith("\"") && value.endsWith("\"")) { + value = value.substring(1, value.length() - 1); + } else if (value.startsWith("'") && value.endsWith("'")) { + value = value.substring(1, value.length() - 1); + } + + if ((key.equals("uri") || key.equals("from") || key.equals("to")) + && value.contains(":") && !value.startsWith("$")) { + String scheme = value.split(":")[0]; + if (catalog.findComponentNames().contains(scheme)) { + uris.add(value); + } + } + } + } + } + + return uris; + } +} diff --git a/dsl/camel-jbang/camel-jbang-mcp/src/main/resources/application.properties b/dsl/camel-jbang/camel-jbang-mcp/src/main/resources/application.properties new file mode 100644 index 000000000000..170fb968b2aa --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-mcp/src/main/resources/application.properties @@ -0,0 +1,41 @@ +## --------------------------------------------------------------------------- +## Licensed to the Apache Software Foundation (ASF) under one or more +## contributor license agreements. See the NOTICE file distributed with +## this work for additional information regarding copyright ownership. +## The ASF licenses this file to You under the Apache License, Version 2.0 +## (the "License"); you may not use this file except in compliance with +## the License. You may obtain a copy of the License at +## +## http://www.apache.org/licenses/LICENSE-2.0 +## +## Unless required by applicable law or agreed to in writing, software +## distributed under the License is distributed on an "AS IS" BASIS, +## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +## See the License for the specific language governing permissions and +## limitations under the License. +## --------------------------------------------------------------------------- +# Camel MCP Server Configuration +# This file configures the Quarkus MCP Server for Apache Camel + +# Server name and version for MCP protocol +quarkus.application.name=camel-mcp-server +quarkus.application.version=${camel.version:4.18.0-SNAPSHOT} + +# Disable banner for cleaner stdio output +quarkus.banner.enabled=false + +# Logging configuration - stderr only to avoid interfering with stdio transport +quarkus.log.console.stderr=true +quarkus.log.level=WARN +quarkus.log.category."org.apache.camel".level=INFO +quarkus.log.category."io.quarkiverse.mcp".level=INFO + +# LLM Configuration for Camel route explanation +# Configure Ollama (default local LLM) +quarkus.langchain4j.ollama.base-url=${OLLAMA_BASE_URL:http://localhost:11434} +quarkus.langchain4j.ollama.chat-model.model-id=${OLLAMA_MODEL:llama3.2} +quarkus.langchain4j.ollama.timeout=120s + +# To use OpenAI instead, set these environment variables: +# QUARKUS_LANGCHAIN4J_OPENAI_API_KEY=your-api-key +# QUARKUS_LANGCHAIN4J_OPENAI_CHAT_MODEL_MODEL_NAME=gpt-4o-mini diff --git a/dsl/camel-jbang/pom.xml b/dsl/camel-jbang/pom.xml index 539a068e2295..20b03bf31eef 100644 --- a/dsl/camel-jbang/pom.xml +++ b/dsl/camel-jbang/pom.xml @@ -37,6 +37,7 @@ <module>camel-jbang-console</module> <module>camel-jbang-core</module> <module>camel-jbang-main</module> + <module>camel-jbang-mcp</module> <module>camel-jbang-plugin-generate</module> <module>camel-jbang-plugin-edit</module> <module>camel-jbang-plugin-kubernetes</module>
