This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 27a7e566b83f CAMEL-24810: camel-jbang-mcp -
camel_dependency_for_class: which Maven dependency provides a class, and how to
declare it
27a7e566b83f is described below
commit 27a7e566b83f29382ed5aacb73618fb1cf63c51c
Author: Claus Ibsen <[email protected]>
AuthorDate: Fri Sep 18 15:16:04 2026 +0200
CAMEL-24810: camel-jbang-mcp - camel_dependency_for_class: which Maven
dependency provides a class, and how to declare it
A shared authoring tool (camel-jbang-core, wrapped in camel-jbang-mcp)
answers which
dependency provides a class and how to declare it. Local first, online
last, and the answer
names its source: the known dependencies camel run downloads on demand
(nothing to declare
there), a Camel component's artifact for each runtime (camel-<name> with
camel-bom on Main,
the -starter with camel-spring-boot-bom on Spring Boot,
camel-quarkus-<name> with
camel-quarkus-bom on Quarkus), and only with mavenCentral=true a Maven
Central class search:
narrowed to the package prefix as group first, the artifact named by the
package preferred,
shaded jars penalised, the newest release from the date-ordered version
list with
pre-releases skipped, marked as a guess with the candidates listed. The JDK
HttpClient with
the JVM proxy settings is used; Central throttles repeated queries, so a 40
s timeout, no
retry, and a per-class cache. The answer carries camel.jbang.dependencies,
--dep and the
pom.xml dependency for the three runtimes. Also carries the two review
notes left on
#26574: the lazy initialiser of the mapping states its benign-race intent,
and a test shows a
class loader without the mapping files answers nothing.
Closes #26594
---
.../modules/ROOT/pages/camel-jbang-mcp.adoc | 9 +
.../modules/ROOT/pages/camel-jbang-running.adoc | 4 +-
.../dsl/jbang/core/commands/ai/AuthoringTools.java | 18 +
.../dsl/jbang/core/commands/ai/BeanRefChecks.java | 29 +-
.../jbang/core/commands/ai/DependencyLookup.java | 369 +++++++++++++++++++++
.../jbang/core/commands/ai/AuthoringToolsTest.java | 4 +-
.../core/commands/ai/DependencyLookupTest.java | 138 ++++++++
.../commands/ai/SourceValidatorBeanRefsTest.java | 8 +
.../jbang/core/commands/mcp/AuthoringTools.java | 19 ++
9 files changed, 592 insertions(+), 6 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/BeanRefChecks.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/BeanRefChecks.java
index d20270a3f34a..f8803cf9c69f 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/BeanRefChecks.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/BeanRefChecks.java
@@ -348,17 +348,37 @@ final class BeanRefChecks {
* bean is fine without a dependency declared even though the class is not
on the CLI classpath. Matched the way the
* runtime matches: the class name, then each enclosing package.
*/
+ /**
+ * Lazily loaded without a lock, on purpose: two threads that both find it
null both build the same immutable-in-use
+ * map from the same resources and both publish it through the volatile
write; the last one wins and every caller
+ * sees a complete map. A benign race, cheaper than synchronising every
lookup.
+ */
private static volatile Map<String, String> knownDependencies;
static String knownDependency(String fqcn) {
Map<String, String> known = knownDependencies;
if (known == null) {
- known = new HashMap<>();
+ known =
loadKnownDependencies(BeanRefChecks.class.getClassLoader());
+ knownDependencies = known;
+ }
+ return findKnown(known, fqcn);
+ }
+
+ /**
+ * The lookup against the mapping files of one class loader, uncached; a
loader without the files answers nothing.
+ */
+ static String knownDependency(String fqcn, ClassLoader loader) {
+ return findKnown(loadKnownDependencies(loader), fqcn);
+ }
+
+ private static Map<String, String> loadKnownDependencies(ClassLoader
loader) {
+ Map<String, String> known = new HashMap<>();
+ {
for (String name : new String[] {
"camel-main-known-dependencies.properties",
"camel-component-known-dependencies.properties",
"camel-thirdparty-known-dependencies.properties" }) {
try {
- Enumeration<URL> resources =
BeanRefChecks.class.getClassLoader().getResources(name);
+ Enumeration<URL> resources = loader.getResources(name);
while (resources.hasMoreElements()) {
try (InputStream is =
resources.nextElement().openStream()) {
Properties prop = new Properties();
@@ -372,8 +392,11 @@ final class BeanRefChecks {
// the mapping is an optimisation of the message, not a
requirement
}
}
- knownDependencies = known;
}
+ return known;
+ }
+
+ private static String findKnown(Map<String, String> known, String fqcn) {
String prefix = fqcn;
String gav = known.get(prefix);
while (gav == null && prefix.lastIndexOf('.') != -1) {
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-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorBeanRefsTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorBeanRefsTest.java
index d9994e238ee2..aa5a2dbcb8a6 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorBeanRefsTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorBeanRefsTest.java
@@ -376,6 +376,14 @@ public class SourceValidatorBeanRefsTest {
assertThat(BeanRefChecks.knownDependency("com.example.NoSuchThing")).isNull();
}
+ @Test
+ void withoutTheMappingFilesOnTheClasspathNothingIsKnownAndNothingFails() {
+ // a class loader with no resources at all: the check falls back to
"unknown" instead of failing
+ ClassLoader empty = new java.net.URLClassLoader(new java.net.URL[0],
null);
+
assertThat(BeanRefChecks.knownDependency("org.postgresql.ds.PGSimpleDataSource",
empty)).isNull();
+
assertThat(BeanRefChecks.knownDependency("org.postgresql.ds.PGSimpleDataSource")).isNotNull();
+ }
+
@Test
void aClassFromAnUnknownLibrarySaysHowToDeclareTheDependency(@TempDir Path
dir) throws IOException {
// a package no mapping will ever name: com.zaxxer.hikari is mapped as
a package by CAMEL-24809
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 {