This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch fix/CAMEL-24810 in repository https://gitbox.apache.org/repos/asf/camel.git
commit 581fc778a8a4188b7cc9995b1a7173dd38d28e4c Author: Claus Ibsen <[email protected]> AuthorDate: Fri Sep 18 13:47:33 2026 +0200 CAMEL-24810: camel-jbang-mcp - camel_dependency_for_class: which Maven dependency provides a class, and how to declare it A new shared authoring tool (camel-jbang-core, wrapped in camel-jbang-mcp) answers the question a bean of a third-party type raises: which dependency provides this class, and how do I declare it. Local first, online last: 1. The known dependencies camel run downloads on demand, the three mapping files of camel-kamelet-main matched by the class and then each enclosing package (the lookup the validator uses since #26574). A hit also means camel run needs no declaration. 2. A Camel component, data format or language class answers its camel- artifact for each runtime: camel-<name> with camel-bom on Camel Main, camel-<name>-starter with camel-spring-boot-bom on Spring Boot, camel-quarkus-<name> with camel-quarkus-bom on Quarkus. A third-party library has the same coordinates in every runtime. 3. Only with mavenCentral=true: Maven Central's class search. The publisher's group id is almost always a prefix of the package, so the search is first narrowed to each package prefix as the group and only then run unqualified (the unqualified search for org.apache.commons.text.StringSubstitutor has 20,737 hits and its first page holds only repackaged copies). Within the hits the artifact whose name is in the package wins and shaded or uber jars lose; the version comes from the artifact's date-ordered version list (core=gav), the newest release, skipping pre-releases (okhttp 4.12.0, not 5.0.0-alpha.16). The answer is marked as a guess with the candidate artifacts listed. The JDK HttpClient with the JVM proxy settings is used, as the CLI's other HTTP calls do; Central throttles repeated queries by holding a request for about thirty seconds, so the timeout is 40 s, there is no retry, and answers are cached per class. The answer carries camel.jbang.dependencies, --dep, and the pom.xml dependency for the three runtimes (or one with runtime=main|spring-boot|quarkus), the source that produced it, and whether camel run downloads it by itself. Five tests cover the three sources with Central mocked; the MCP wrapper test checks the wrapper and its hints. Documented in the MCP page's tool table and examples and in the running page's advanced section. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01Bp3538HRBPMQkb5ta9xRaj --- .../modules/ROOT/pages/camel-jbang-mcp.adoc | 9 + .../modules/ROOT/pages/camel-jbang-running.adoc | 4 +- .../dsl/jbang/core/commands/ai/AuthoringTools.java | 18 + .../jbang/core/commands/ai/DependencyLookup.java | 369 +++++++++++++++++++++ .../jbang/core/commands/ai/AuthoringToolsTest.java | 4 +- .../core/commands/ai/DependencyLookupTest.java | 138 ++++++++ .../jbang/core/commands/mcp/AuthoringTools.java | 19 ++ 7 files changed, 558 insertions(+), 3 deletions(-) diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc index 23a95e7f3ad6..ec63967b8187 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc @@ -213,6 +213,8 @@ The assistant uses `camel_error_diagnose` to identify the exception chain, extra * _"Check my pom.xml for missing or outdated dependencies"_ — uses `camel_dependency_check` * _"What are the latest LTS versions for Spring Boot?"_ — uses `camel_version_list` +* _"Which dependency do I need for org.postgresql.ds.PGSimpleDataSource, and how do I add it on Quarkus?"_ — + uses `camel_dependency_for_class` === Migration @@ -356,6 +358,13 @@ project `directory` as an argument, the runtime tools take the integration `name and returns the value (true/false for a predicate) or the syntax error, so an agent can check a simple expression before writing it into a route. +| `camel_dependency_for_class` +| Which Maven dependency provides a class, and how to declare it. Local first: the known dependencies + `camel run` downloads by itself (nothing to declare there), then a Camel component's artifact for each + runtime. Only with `mavenCentral=true` it searches Maven Central by class name, groups the hits by artifact, + takes the newest version, and marks the answer as a guess. Returns `camel.jbang.dependencies`, `--dep`, and + the `pom.xml` dependency for Camel Main, Spring Boot and Quarkus, or one runtime with `runtime`. Uses the + JVM proxy settings for the search. | `camel_error_diagnose` | See <<_error_diagnosis,Error Diagnosis>>; the same shared tool. |=== diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-running.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-running.adoc index 5ef0ed5f678f..faf644de04ca 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-running.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-running.adoc @@ -272,7 +272,9 @@ mapped library is not the one you want, for example the ActiveMQ 5 client where picks the ActiveMQ 6 client. `camel validate` and the write tools of the Camel MCP server consult the same three files, so a -bean whose class Camel CLI would download is not reported as missing. +bean whose class Camel CLI would download is not reported as missing, and the MCP tool +`camel_dependency_for_class` answers the coordinates and the declaration for a class from the same +mapping, with an optional Maven Central search for the rest. To add a library to the mapping, add one line to `known-third-party-libraries.properties` in `camel-kamelet-main`: the library's own package, its `groupId:artifactId`, and a version diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java index 3a841b26faa5..56f5c5b87a49 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java @@ -304,6 +304,24 @@ public final class AuthoringTools { args.get("body")).toJson(); })); + registry.accept(tool("camel_dependency_for_class", + "Which Maven dependency provides a class, and how to declare it: the known dependencies camel run " + + "downloads by itself (nothing to declare there), a Camel component's " + + "artifact per runtime, or with mavenCentral=true a Maven Central search " + + "by class name (a guess, marked as such). Answers camel.jbang.dependencies, " + + "--dep, and the pom.xml dependency for Camel Main, Spring Boot and Quarkus.") + .param("className", "string", "Fully qualified class name, e.g. org.postgresql.ds.PGSimpleDataSource", + true) + .param("runtime", "string", "main, spring-boot or quarkus (default: all three pom forms)", false) + .param("mavenCentral", "boolean", + "Search Maven Central when the class is not in the known dependencies (default false; needs network, can take up to 40 s)", + false) + .param("camelVersion", "string", VERSION_DESC, false) + .executor((ctx, args) -> { + applyVersion(ctx, args); + return DependencyLookup.lookup(ctx, required(args, "className"), args.get("runtime"), + bool(args, "mavenCentral", false)); + })); registry.accept(tool("camel_error_diagnose", "Diagnoses a Camel error from a stack trace or error message: the known exceptions in it with common " + "causes and suggested fixes, the components and EIPs it mentions with " diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/DependencyLookup.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/DependencyLookup.java new file mode 100644 index 000000000000..68d5e3c2729a --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/DependencyLookup.java @@ -0,0 +1,369 @@ +/* + * 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.ai; + +import java.net.ProxySelector; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; + +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; + +/** + * Answers which Maven dependency provides a class, and how to declare it, for the {@code camel_dependency_for_class} + * tool (CAMEL-24810). Local first, online last: + * <ol> + * <li>the known dependencies camel run downloads on demand (the three mapping files of camel-kamelet-main, matched by + * the class and then each enclosing package, see {@link BeanRefChecks#knownDependency}); a hit also means camel run + * needs no declaration;</li> + * <li>a Camel component, data format or language class answers its {@code camel-} artifact for each runtime;</li> + * <li>only when asked ({@code mavenCentral=true}): Maven Central's class search, grouped by artifact, the newest + * version fetched separately, and marked as a guess.</li> + * </ol> + * The declaration forms cover camel run ({@code camel.jbang.dependencies}, {@code --dep}) and the pom.xml of the three + * runtimes: Camel Main, Spring Boot and Quarkus. A third-party library has the same coordinates in every runtime; a + * Camel component does not. + */ +public final class DependencyLookup { + + static final String CENTRAL_SEARCH = "https://search.maven.org/solrsearch/select"; + private static final Map<String, JsonObject> CENTRAL_CACHE = new ConcurrentHashMap<>(); + + private DependencyLookup() { + } + + /** + * Fetches a URL as text; replaced in tests. Uses the JVM proxy settings (http.proxyHost, https.proxyHost, + * java.net.useSystemProxies) like the other CLI callers. Maven Central's search answers in under a second, but + * throttles a client that repeats queries by holding each request for about thirty seconds before answering, so the + * timeout is set just beyond that and the request is not repeated; answers are cached per class. + */ + public static String fetch(String url) { + try { + HttpClient hc = HttpClient.newBuilder().proxy(ProxySelector.getDefault()) + .connectTimeout(Duration.ofSeconds(10)).build(); + HttpResponse<String> res = hc.send(HttpRequest.newBuilder(new URI(url)) + .header("User-Agent", "Apache Camel JBang").timeout(Duration.ofSeconds(40)).build(), + HttpResponse.BodyHandlers.ofString()); + if (res.statusCode() != 200) { + throw new ToolExecutionException("Maven Central search answered HTTP " + res.statusCode()); + } + return res.body(); + } catch (ToolExecutionException e) { + throw e; + } catch (Exception e) { + throw new ToolExecutionException( + "Maven Central search failed (" + e.getMessage() + "): try again later, or declare the dependency " + + "yourself with camel.jbang.dependencies=<groupId>:<artifactId>:<version>", + e); + } + } + + public static JsonObject lookup(ToolContext ctx, String className, String runtime, boolean mavenCentral) { + return lookup(ctx, className, runtime, mavenCentral, DependencyLookup::fetch); + } + + static JsonObject lookup( + ToolContext ctx, String className, String runtime, boolean mavenCentral, Function<String, String> fetcher) { + String name = className.trim(); + if (name.startsWith("#class:")) { + name = name.substring(7); + } + if (name.endsWith(".class")) { + name = name.substring(0, name.length() - 6); + } + String rt = runtime == null || runtime.isBlank() ? null : runtime.trim().toLowerCase(Locale.ROOT); + if (rt != null && !rt.equals("main") && !rt.equals("spring-boot") && !rt.equals("quarkus")) { + throw new ToolExecutionException("runtime must be main, spring-boot or quarkus"); + } + JsonObject answer = new JsonObject(); + answer.put("class", name); + + String known = BeanRefChecks.knownDependency(name); + if (known != null) { + if (known.startsWith("camel:")) { + // the context carries a version only when one was selected; the catalog's is the CLI's own + String version = ctx.camelVersion() != null ? ctx.camelVersion() : ctx.catalog().getCatalogVersion(); + return camelArtifact(answer, known.substring(6), version, rt, "camel-component"); + } + String[] parts = known.split(":"); + if (parts.length >= 3) { + answer.put("source", "known-dependencies"); + answer.put("autoDownload", true); + answer.put("note", "camel run downloads this dependency when the class is used; nothing to declare " + + "there. Declare it in a Maven project."); + return declare(answer, parts[0], parts[1], parts[2], rt); + } + } + if (!mavenCentral) { + answer.put("source", "unknown"); + answer.put("autoDownload", false); + answer.put("note", "Not in the known dependencies. Call again with mavenCentral=true to search Maven " + + "Central by class name, or declare the dependency yourself: " + + "camel.jbang.dependencies=<groupId>:<artifactId>:<version> in application.properties, " + + "--dep on camel run, or a pom.xml dependency."); + return answer; + } + if (!name.contains(".") || Character.isLowerCase(name.charAt(name.lastIndexOf('.') + 1))) { + throw new ToolExecutionException( + "Maven Central is searched by fully qualified class name; give a class, not a package"); + } + JsonObject hit = centralSearch(name, fetcher); + if (hit == null) { + answer.put("source", "unknown"); + answer.put("autoDownload", false); + answer.put("note", "Not in the known dependencies and Maven Central has no artifact with this class."); + return answer; + } + answer.put("source", "maven-central"); + answer.put("autoDownload", false); + answer.put("note", "A Maven Central search result: the artifact whose group and name best match the package, " + + "and its newest release (pre-releases skipped). Check it before adding it to a project; " + + "a shaded copy of the class may exist in other artifacts."); + answer.put("candidates", hit.get("candidates")); + return declare(answer, hit.getString("groupId"), hit.getString("artifactId"), hit.getString("version"), rt); + } + + /** A Camel component, data format or language: the artifact differs per runtime, the version is the BOM's. */ + static JsonObject camelArtifact(JsonObject answer, String shortName, String camelVersion, String rt, String source) { + answer.put("source", source); + answer.put("autoDownload", true); + answer.put("groupId", "org.apache.camel"); + answer.put("artifactId", "camel-" + shortName); + answer.put("version", camelVersion); + answer.put("note", "A Camel artifact: camel run resolves it by itself. In a Maven project use the runtime's " + + "artifact and BOM below; the BOM manages the version."); + JsonObject declare = new JsonObject(); + declare.put("jbang", "camel.jbang.dependencies=camel:" + shortName); + declare.put("cli", "--dep=camel:" + shortName); + if (rt == null || rt.equals("main")) { + declare.put("pomMain", pom("org.apache.camel", "camel-" + shortName, null, + "org.apache.camel:camel-bom:" + camelVersion)); + } + if (rt == null || rt.equals("spring-boot")) { + declare.put("pomSpringBoot", pom("org.apache.camel.springboot", "camel-" + shortName + "-starter", null, + "org.apache.camel.springboot:camel-spring-boot-bom:" + camelVersion)); + } + if (rt == null || rt.equals("quarkus")) { + declare.put("pomQuarkus", pom("org.apache.camel.quarkus", "camel-quarkus-" + shortName, null, + "org.apache.camel.quarkus:camel-quarkus-bom:<camel-quarkus version>")); + } + answer.put("declare", declare); + return answer; + } + + /** A third-party library: the same coordinates in every runtime. */ + static JsonObject declare(JsonObject answer, String groupId, String artifactId, String version, String rt) { + answer.put("groupId", groupId); + answer.put("artifactId", artifactId); + answer.put("version", version); + boolean placeholder = version.startsWith("${"); + if (placeholder) { + answer.put("versionNote", "The version is a property of Camel's own build; camel run resolves it from " + + "the camel-dependencies POM. In a project pick the version you want."); + } + String gav = groupId + ":" + artifactId + ":" + version; + JsonObject declare = new JsonObject(); + declare.put("jbang", "camel.jbang.dependencies=" + gav); + declare.put("cli", "--dep=" + gav); + String v = placeholder ? "<version>" : version; + if (rt == null || rt.equals("main")) { + declare.put("pomMain", pom(groupId, artifactId, v, null)); + } + if (rt == null || rt.equals("spring-boot")) { + declare.put("pomSpringBoot", pom(groupId, artifactId, v, null)); + } + if (rt == null || rt.equals("quarkus")) { + declare.put("pomQuarkus", pom(groupId, artifactId, v, null)); + } + answer.put("declare", declare); + return answer; + } + + static String pom(String groupId, String artifactId, String version, String bom) { + StringBuilder sb = new StringBuilder(); + sb.append("<dependency>\n <groupId>").append(groupId).append("</groupId>\n <artifactId>").append(artifactId) + .append("</artifactId>\n"); + if (version != null) { + sb.append(" <version>").append(version).append("</version>\n"); + } + sb.append("</dependency>"); + if (bom != null) { + sb.append("\n(version managed by the BOM ").append(bom).append(" imported in dependencyManagement)"); + } + return sb.toString(); + } + + /** + * Maven Central's class search returns one document per version and shaded copy, newest score first, and a common + * class has tens of thousands of them (20,737 for org.apache.commons.text.StringSubstitutor), so the first page may + * hold only repackaged copies. The publisher's group id is almost always a prefix of the package, so the search is + * first narrowed to each package prefix as the group ("org.apache.commons.text", then "org.apache.commons", ...) + * and only then run unqualified. Within the hits the artifact whose name is in the package wins, shaded and uber + * jars lose, and the version is the newest stable one seen (a pre-release latestVersion such as 5.0.0-alpha.16 + * loses to the newest release). + */ + static JsonObject centralSearch(String className, Function<String, String> fetcher) { + JsonObject cached = CENTRAL_CACHE.get(className); + if (cached != null) { + return cached; + } + String pkg = className.substring(0, className.lastIndexOf('.')); + List<Object> docs = new ArrayList<>(); + String prefix = pkg; + while (docs.isEmpty() && prefix.contains(".")) { + docs = docs(parse(fetcher.apply(CENTRAL_SEARCH + "?q=" + + URLEncoder.encode("fc:\"" + className + "\" AND g:\"" + prefix + "\"", + StandardCharsets.UTF_8) + + "&rows=50&wt=json"))); + prefix = prefix.substring(0, prefix.lastIndexOf('.')); + } + if (docs.isEmpty()) { + docs = docs(parse(fetcher.apply(CENTRAL_SEARCH + "?q=" + + URLEncoder.encode("fc:\"" + className + "\"", StandardCharsets.UTF_8) + + "&rows=50&wt=json"))); + } + Map<String, Integer> counts = new LinkedHashMap<>(); + Map<String, List<String>> versions = new LinkedHashMap<>(); + for (Object o : docs) { + JsonObject d = (JsonObject) o; + String ga = d.getString("g") + ":" + d.getString("a"); + counts.merge(ga, 1, Integer::sum); + if (d.getString("v") != null) { + versions.computeIfAbsent(ga, k -> new ArrayList<>()).add(d.getString("v")); + } + } + if (counts.isEmpty()) { + return null; + } + String lowerPkg = pkg.toLowerCase(Locale.ROOT); + String best = null; + int bestScore = Integer.MIN_VALUE; + for (Map.Entry<String, Integer> e : counts.entrySet()) { + String[] ga = e.getKey().split(":"); + String g = ga[0].toLowerCase(Locale.ROOT); + String a = ga[1].toLowerCase(Locale.ROOT); + int score = 0; + if (lowerPkg.startsWith(g)) { + score += 4; + } + if (lowerPkg.contains(a.replace('-', '.')) || lowerPkg.contains(a) || lowerPkg.endsWith(a)) { + score += 2; + } + if (a.contains("shaded") || a.contains("uber") || a.endsWith("-all") || a.contains("bundle") + || a.contains("-sdk")) { + score -= 3; + } + if (score > bestScore) { + bestScore = score; + best = e.getKey(); + } + } + String[] ga = best.split(":"); + // the artifact's versions newest first (core=gav sorts by release date): the newest release wins over a + // newer pre-release, and over whatever old versions the class search happened to return + String vq = URLEncoder.encode("g:\"" + ga[0] + "\" AND a:\"" + ga[1] + "\"", StandardCharsets.UTF_8); + List<String> newest = new ArrayList<>(); + for (Object o : docs(parse(fetcher.apply(CENTRAL_SEARCH + "?q=" + vq + "&core=gav&rows=25&wt=json")))) { + String v = ((JsonObject) o).getString("v"); + if (v != null) { + newest.add(v); + } + } + String version = newestStable(newest, versions.getOrDefault(best, List.of())); + JsonObject hit = new JsonObject(); + hit.put("groupId", ga[0]); + hit.put("artifactId", ga[1]); + hit.put("version", version); + JsonArray candidates = new JsonArray(); + int n = 0; + for (Map.Entry<String, Integer> e : counts.entrySet()) { + if (n++ < 5) { + candidates.add(e.getKey() + " (" + e.getValue() + " versions)"); + } + } + hit.put("candidates", candidates); + CENTRAL_CACHE.put(className, hit); + return hit; + } + + private static final java.util.regex.Pattern PRERELEASE + = java.util.regex.Pattern.compile("(?i)(alpha|beta|rc|snapshot|preview|milestone|-m\\d|\\.m\\d|-ea|cr\\d)"); + + /** + * The first release in the date-ordered list (newest first); a pre-release is skipped when a release follows it + * (5.0.0-alpha.16 loses to 4.12.0). With no release in that list the newest one seen anywhere, else the newest + * entry whatever it is. + */ + static String newestStable(List<String> newestFirst, List<String> seen) { + for (String v : newestFirst) { + if (!PRERELEASE.matcher(v).find()) { + return v; + } + } + String best = null; + for (String v : seen) { + if (!PRERELEASE.matcher(v).find() && (best == null || compareVersions(v, best) > 0)) { + best = v; + } + } + if (best != null) { + return best; + } + return newestFirst.isEmpty() ? (seen.isEmpty() ? null : seen.get(0)) : newestFirst.get(0); + } + + static int compareVersions(String a, String b) { + String[] x = a.split("[^0-9]+"); + String[] y = b.split("[^0-9]+"); + for (int i = 0; i < Math.max(x.length, y.length); i++) { + long p = i < x.length && !x[i].isEmpty() ? Long.parseLong(x[i]) : 0; + long q = i < y.length && !y[i].isEmpty() ? Long.parseLong(y[i]) : 0; + if (p != q) { + return Long.compare(p, q); + } + } + return 0; + } + + private static JsonObject parse(String body) { + try { + return (JsonObject) Jsoner.deserialize(body); + } catch (Exception e) { + throw new ToolExecutionException("Maven Central search returned no JSON", e); + } + } + + private static List<Object> docs(JsonObject response) { + JsonObject r = response == null ? null : response.getMap("response"); + JsonArray docs = r == null ? null : r.getCollection("docs"); + return docs == null ? new ArrayList<>() : docs; + } +} diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java index 8a48d3a9ea53..416f099b0f67 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java @@ -76,7 +76,7 @@ class AuthoringToolsTest { assertEquals(List.of("camel_catalog_doc", "camel_catalog_find", "camel_catalog_sample", "camel_validate_source", "camel_get_files", "camel_write_file", "camel_run", "camel_control", "camel_get_log", "camel_get_errors", - "camel_eval_expression", "camel_error_diagnose"), names); + "camel_eval_expression", "camel_dependency_for_class", "camel_error_diagnose"), names); for (ToolDescriptor td : shared) { assertTrue(td.name().startsWith("camel_"), td.name()); assertFalse(td.description().isBlank(), td.name()); @@ -88,7 +88,7 @@ class AuthoringToolsTest { } for (String reading : List.of("camel_catalog_doc", "camel_catalog_sample", "camel_get_files", "camel_get_log", "camel_get_errors", - "camel_eval_expression", "camel_error_diagnose", "camel_validate_source")) { + "camel_eval_expression", "camel_dependency_for_class", "camel_error_diagnose", "camel_validate_source")) { assertTrue(ToolRegistry.findTool(reading).isReadOnly(), reading); } assertTrue(ToolRegistry.findTool("camel_control").isDestructive()); diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/DependencyLookupTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/DependencyLookupTest.java new file mode 100644 index 000000000000..955a999ef531 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/DependencyLookupTest.java @@ -0,0 +1,138 @@ +/* + * 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.ai; + +import java.util.Map; +import java.util.function.Function; + +import org.apache.camel.util.json.JsonObject; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class DependencyLookupTest { + + private final ToolContext ctx = new ToolContext(); + + @Test + void aKnownThirdPartyClassAnswersItsCoordinatesAndNeedsNoDeclarationForCamelRun() { + JsonObject a = DependencyLookup.lookup(ctx, "org.postgresql.ds.PGSimpleDataSource", null, false); + assertThat(a.getString("source")).isEqualTo("known-dependencies"); + assertThat(a.getBoolean("autoDownload")).isTrue(); + assertThat(a.getString("groupId")).isEqualTo("org.postgresql"); + assertThat(a.getString("artifactId")).isEqualTo("postgresql"); + assertThat(a.getString("version")).doesNotStartWith("${"); + JsonObject d = a.getMap("declare"); + assertThat(d.getString("jbang")).startsWith("camel.jbang.dependencies=org.postgresql:postgresql:"); + assertThat(d.getString("cli")).startsWith("--dep=org.postgresql:postgresql:"); + assertThat(d.getString("pomMain")).contains("<artifactId>postgresql</artifactId>").contains("<version>"); + assertThat(d.getString("pomSpringBoot")).isEqualTo(d.getString("pomMain")); + assertThat(d.getString("pomQuarkus")).isEqualTo(d.getString("pomMain")); + } + + @Test + void aCamelComponentClassAnswersTheArtifactOfEachRuntime() { + JsonObject a = DependencyLookup.lookup(ctx, "org.apache.camel.component.kafka.KafkaComponent", null, false); + assertThat(a.getString("source")).isEqualTo("camel-component"); + assertThat(a.getString("artifactId")).isEqualTo("camel-kafka"); + assertThat(a.getString("version")).isNotNull().doesNotContain("null"); + JsonObject d = a.getMap("declare"); + assertThat(d.getString("cli")).isEqualTo("--dep=camel:kafka"); + assertThat(d.getString("pomMain")).contains("<groupId>org.apache.camel</groupId>") + .contains("<artifactId>camel-kafka</artifactId>").contains("camel-bom"); + assertThat(d.getString("pomSpringBoot")).contains("org.apache.camel.springboot") + .contains("camel-kafka-starter").contains("camel-spring-boot-bom"); + assertThat(d.getString("pomQuarkus")).contains("org.apache.camel.quarkus").contains("camel-quarkus-kafka") + .contains("camel-quarkus-bom"); + + JsonObject only = DependencyLookup.lookup(ctx, "org.apache.camel.component.kafka.KafkaComponent", + "spring-boot", false); + JsonObject onlyDeclare = only.getMap("declare"); + assertThat(onlyDeclare.keySet().stream().map(String::valueOf).toList()) + .containsExactlyInAnyOrder("jbang", "cli", "pomSpringBoot"); + } + + @Test + void anUnknownClassWithoutMavenCentralSaysHowToDeclareIt() { + JsonObject a = DependencyLookup.lookup(ctx, "com.example.pool.NoSuchDataSource", null, false); + assertThat(a.getString("source")).isEqualTo("unknown"); + assertThat(a.getBoolean("autoDownload")).isFalse(); + assertThat(a.getString("note")).contains("mavenCentral=true").contains("camel.jbang.dependencies"); + assertThat(a.containsKey("declare")).isFalse(); + } + + @Test + void mavenCentralIsSearchedOnlyWhenAskedAndTheBestArtifactAndNewestVersionArePicked() { + // canned answers: the class search lists old versions first and a shaded copy; the second query gives the + // newest version of the chosen artifact + Function<String, String> fetcher = url -> { + if (url.contains("fc%3A") && url.contains("g%3A")) { + // the group-prefix narrowed searches: nothing under com.example.pool or com.example, as on Central + return "{\"response\":{\"numFound\":0,\"docs\":[]}}"; + } + if (url.contains("fc%3A")) { + return """ + {"response":{"numFound":3,"docs":[ + {"g":"com.example","a":"example-pool","v":"1.3.3"}, + {"g":"org.acme","a":"acme-shaded-all","v":"9.9"}, + {"g":"com.example","a":"example-pool","v":"1.3.2"}]}}"""; + } + // the artifact's versions newest first: two pre-releases ahead of the newest release + return """ + {"response":{"numFound":4,"docs":[ + {"g":"com.example","a":"example-pool","v":"8.0.0-alpha.2"}, + {"g":"com.example","a":"example-pool","v":"8.0.0-alpha.1"}, + {"g":"com.example","a":"example-pool","v":"7.0.2"}, + {"g":"com.example","a":"example-pool","v":"7.0.1"}]}}"""; + }; + JsonObject a = DependencyLookup.lookup(ctx, "com.example.pool.PoolDataSource", "main", true, fetcher); + assertThat(a.getString("source")).isEqualTo("maven-central"); + assertThat(a.getBoolean("autoDownload")).isFalse(); + assertThat(a.getString("groupId")).isEqualTo("com.example"); + assertThat(a.getString("artifactId")).isEqualTo("example-pool"); + assertThat(a.getString("version")).isEqualTo("7.0.2"); + assertThat(a.getString("note")).contains("Check it"); + assertThat(((java.util.Collection<?>) a.get("candidates")).size()).isEqualTo(2); + JsonObject declare = a.getMap("declare"); + assertThat(declare.getString("pomMain")).contains("<version>7.0.2</version>"); + + assertThat(DependencyLookup.newestStable(java.util.List.of("5.0.0-alpha.16", "4.12.0", "4.11.0"), + java.util.List.of())).isEqualTo("4.12.0"); + assertThat(DependencyLookup.newestStable(java.util.List.of(), java.util.List.of("1.0", "1.2", "1.1"))) + .isEqualTo("1.2"); + assertThat(DependencyLookup.newestStable(java.util.List.of("2.0-RC1"), java.util.List.of("2.0-RC1"))) + .isEqualTo("2.0-RC1"); + assertThat(DependencyLookup.compareVersions("4.12.0", "4.9.1")).isPositive(); + + assertThatThrownBy(() -> DependencyLookup.lookup(ctx, "com.example.pool", null, true, fetcher)) + .isInstanceOf(ToolExecutionException.class).hasMessageContaining("class, not a package"); + assertThatThrownBy(() -> DependencyLookup.lookup(ctx, "x.Y", "wildfly", false)) + .isInstanceOf(ToolExecutionException.class).hasMessageContaining("runtime must be"); + } + + @Test + void theToolIsInTheSharedRegistryAndReadOnly() { + ToolDescriptor td = ToolRegistry.findTool("camel_dependency_for_class"); + assertThat(td).isNotNull(); + assertThat(td.isReadOnly()).isTrue(); + assertThat(td.isCore()).isFalse(); + Object result = ToolRegistry.execute("camel_dependency_for_class", ctx, + Map.of("className", "#class:org.apache.activemq.artemis.jms.client.ActiveMQConnectionFactory")); + assertThat(((JsonObject) result).getString("artifactId")).isEqualTo("artemis-jakarta-client-all"); + } +} diff --git a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java index c525c9fe0a7e..f84d670c26b2 100644 --- a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java +++ b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java @@ -208,6 +208,25 @@ public class AuthoringTools { "name", name)); } + @Tool(annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint = false, openWorldHint = true), + description = "Which Maven dependency provides a class, and how to declare it: the known dependencies " + + "camel run downloads by itself (nothing to declare there), a Camel component's artifact per " + + "runtime, or with mavenCentral=true a Maven Central search by class name (a guess, marked as " + + "such). Answers camel.jbang.dependencies, --dep, and the pom.xml dependency for Camel Main, " + + "Spring Boot and Quarkus.") + public JsonObject camel_dependency_for_class( + @ToolArg(description = "Fully qualified class name, e.g. org.postgresql.ds.PGSimpleDataSource", + required = true) String className, + @ToolArg(description = "main, spring-boot or quarkus (default: all three pom forms)", + required = false) String runtime, + @ToolArg(description = "Search Maven Central when the class is not in the known dependencies (default " + + "false; needs network, can take up to 40 s)", + required = false) Boolean mavenCentral, + @ToolArg(description = VERSION_DESC, required = false) String camelVersion) { + return call("camel_dependency_for_class", args("className", className, "runtime", runtime, "mavenCentral", + mavenCentral, "camelVersion", camelVersion)); + } + /** Runs the registry tool of the same name and hands its JSON back; a tool error becomes an MCP tool error. */ static JsonObject call(String tool, Map<String, String> args) { try {
