davsclaus commented on code in PR #27840:
URL: https://github.com/apache/camel/pull/27840#discussion_r4275140171


##########
dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java:
##########
@@ -367,18 +371,46 @@ public JsonObject camel_save_project_summary(
     }
 
     /**
-     * As {@link #call(String, Map)} for a deterministic tool: from the third 
identical call of the connection the
-     * answer is a short note that it was already answered (CAMEL-25075).
+     * As {@link #call(String, Map, CatalogService)} for a deterministic tool: 
from the third identical call of the
+     * connection the answer is a short note that it was already answered 
(CAMEL-25075). Only successful answers count
+     * against the repeat budget; a call that throws does not (CAMEL-25640).
      */
     JsonObject callDeterministic(McpConnection connection, String tool, 
Map<String, String> args) {
         JsonObject repeat = repeatedCalls != null ? 
repeatedCalls.repeatOf(connection, tool, args) : null;
-        return repeat != null ? repeat : call(tool, args);
+        if (repeat != null) {
+            return repeat;
+        }
+        JsonObject result = call(tool, args, catalogService);
+        if (repeatedCalls != null) {
+            repeatedCalls.recordSuccessfulAnswer(connection, tool, args);
+        }
+        return result;
     }
 
     /** 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) {
+        return call(tool, args, null);
+    }
+
+    /**
+     * Runs the registry tool of the same name and hands its JSON back; a tool 
error becomes an MCP tool error. When
+     * {@code catalogService} is provided, the {@link ToolContext} reuses the 
shared catalog cache instead of creating a
+     * new one per call (CAMEL-25640).
+     */
+    static JsonObject call(String tool, Map<String, String> args, 
CatalogService catalogService) {
         try {
-            return RuntimeTools.toJsonObject(ToolRegistry.execute(tool, new 
ToolContext(), args));
+            ToolContext ctx = new ToolContext();
+            if (catalogService != null) {
+                String camelVersion = args != null ? args.get("camelVersion") 
: null;
+                String runtime = args != null ? args.get("runtime") : null;

Review Comment:
   None of the four tools that reach this method (`camel_catalog_doc`, `_find`, 
`_sample`, `camel_validate_source`) has a `runtime` argument, so this line does 
nothing today. If a tool such as `camel_dependency_for_class`, whose `runtime` 
means something else, is ever routed through here, it would quietly get a 
Spring Boot or Quarkus catalog. I would pass `null` here.



##########
dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java:
##########
@@ -367,18 +371,46 @@ public JsonObject camel_save_project_summary(
     }
 
     /**
-     * As {@link #call(String, Map)} for a deterministic tool: from the third 
identical call of the connection the
-     * answer is a short note that it was already answered (CAMEL-25075).
+     * As {@link #call(String, Map, CatalogService)} for a deterministic tool: 
from the third identical call of the
+     * connection the answer is a short note that it was already answered 
(CAMEL-25075). Only successful answers count
+     * against the repeat budget; a call that throws does not (CAMEL-25640).
      */
     JsonObject callDeterministic(McpConnection connection, String tool, 
Map<String, String> args) {
         JsonObject repeat = repeatedCalls != null ? 
repeatedCalls.repeatOf(connection, tool, args) : null;
-        return repeat != null ? repeat : call(tool, args);
+        if (repeat != null) {
+            return repeat;
+        }
+        JsonObject result = call(tool, args, catalogService);
+        if (repeatedCalls != null) {
+            repeatedCalls.recordSuccessfulAnswer(connection, tool, args);
+        }
+        return result;
     }
 
     /** 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) {
+        return call(tool, args, null);
+    }
+
+    /**
+     * Runs the registry tool of the same name and hands its JSON back; a tool 
error becomes an MCP tool error. When
+     * {@code catalogService} is provided, the {@link ToolContext} reuses the 
shared catalog cache instead of creating a
+     * new one per call (CAMEL-25640).
+     */
+    static JsonObject call(String tool, Map<String, String> args, 
CatalogService catalogService) {
         try {
-            return RuntimeTools.toJsonObject(ToolRegistry.execute(tool, new 
ToolContext(), args));
+            ToolContext ctx = new ToolContext();
+            if (catalogService != null) {
+                String camelVersion = args != null ? args.get("camelVersion") 
: null;
+                String runtime = args != null ? args.get("runtime") : null;
+                try {
+                    CamelCatalog catalog = catalogService.loadCatalog(runtime, 
camelVersion, null);
+                    ctx.setCatalog(catalog);

Review Comment:
   When `camelVersion` is given, this catalog never gets used. Every 
deterministic registry tool begins with `applyVersion(ctx, args)`, which calls 
`ctx.setCamelVersion(version)`. On this fresh context the version is null, so 
the two differ and `setCamelVersion` sets `catalog = null`. 
`ToolContext.loadCatalog()` then downloads through `CatalogLoader` again, with 
no cache and without `camel.catalog.repos`. A version that cannot be downloaded 
is now tried twice per call (here, and again in the context). Setting the 
version first keeps the catalog, because `setCamelVersion` with the same value 
is a no-op:
   ```java
   if (camelVersion != null && !camelVersion.isBlank()) {
       ctx.setCamelVersion(camelVersion.trim());
   }
   ctx.setCatalog(catalog);
   ```
   (`CatalogService` falls back to the default catalog when a download fails, 
so that becomes the answer instead of an error. Please decide that on purpose.)



##########
dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/CatalogService.java:
##########
@@ -208,7 +208,15 @@ private CamelCatalog doLoadCatalog(
             if (runtimeType == RuntimeType.springBoot) {
                 return CatalogLoader.loadSpringBootCatalog(repos, 
camelVersion, true);
             } else if (runtimeType == RuntimeType.quarkus) {
-                return CatalogLoader.loadQuarkusCatalog(platformBom, 
downloader()::resolveArtifact);
+                // Resolve the Quarkus platform BOM that matches the requested 
Camel version.
+                // platformBom is null here (only camelVersion was given), so 
we must look it up.
+                MavenGav resolvedBom = QuarkusHelper.findQuarkusPlatformBom(
+                        camelVersion,
+                        downloader()::resolveArtifact,
+                        true,
+                        RuntimeType.QUARKUS_EXTENSION_REGISTRY_BASE_URL,
+                        false).quarkusCamelBom();

Review Comment:
   Non-blocking: `findQuarkusPlatformBom` returns null when the registry cannot 
be read or no platform matches, and then `.quarkusCamelBom()` throws an NPE. 
The tool answers with `(java.lang.NullPointerException)`. A 
`ToolCallException("No Quarkus platform found for Camel " + camelVersion, 
null)` would give the model something it can act on. The no-version path at 
`:111-117` has the same issue.



##########
dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/CatalogService.java:
##########
@@ -218,6 +226,8 @@ private CamelCatalog doLoadCatalog(
         if (runtimeType == RuntimeType.springBoot) {
             return CatalogLoader.loadSpringBootCatalog(repos, null, true);
         } else if (runtimeType == RuntimeType.quarkus) {
+            // platformBom should have been resolved in loadCatalog() before 
doLoadCatalog() is called; passing null
+            // here would silently produce an unusable catalog, so we guard 
with an assertion to surface the bug early.
             return CatalogLoader.loadQuarkusCatalog(platformBom, 
downloader()::resolveArtifact);

Review Comment:
   There is no assertion. As far as I can see, this branch cannot be reached: 
every Quarkus call to `doLoadCatalog` comes with either a BOM or a version. 
Drop the comment, or make the guard real:
   ```suggestion
               // loadCatalog() always resolves the platform BOM for quarkus 
before calling doLoadCatalog()
               throw new IllegalStateException("No Quarkus platform BOM 
resolved");
   ```



##########
dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java:
##########
@@ -532,10 +532,15 @@ private JsonObject handleToolsCall(JsonObject request) {
 
         String text;
         boolean isError = false;
+        ToolDescriptor tool = toolName != null ? 
ToolRegistry.findTool(toolName) : null;
         try {
-            JsonObject repeat = toolName != null
-                    ? repeatedCalls.repeatOf(ToolRegistry.findTool(toolName), 
args) : null;
-            text = repeat != null ? repeat.toJson() : 
toolRegistry.execute(toolName, args);
+            JsonObject repeat = repeatedCalls.repeatOf(tool, args);
+            if (repeat != null) {
+                text = repeat.toJson();
+            } else {
+                text = toolRegistry.execute(toolName, args);
+                repeatedCalls.recordSuccessfulAnswer(tool, args);

Review Comment:
   For the shared `camel_*` tools, `TuiToolRegistry.executeShared` catches 
`ToolExecutionException` and returns `"Error: " + message` as normal text, so 
`execute()` does not throw. A failed `camel_catalog_doc` (a version that cannot 
be downloaded) or a `camel_validate_source` without `file` is therefore 
recorded as a successful answer here. On the Camel TUI path, failed calls still 
use up the budget. Either `executeShared` should report the error to this 
method (throw, or return a flag so the result also gets `isError`), or this 
should skip the record for an error result.



##########
dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/KameletTools.java:
##########
@@ -53,15 +73,16 @@ public KameletListResult camel_catalog_kamelets(
             @ToolArg(description = "Apache Camel Kamelets version. If not 
specified, uses the default version.",
                      required = false) String kameletsVersion) {
 
-        int maxResults = limit != null ? limit : 50;
+        int maxResults = limit != null ? Math.max(1, limit) : 50;

Review Comment:
   Non-blocking: with this, `limit=0` returns one Kamelet. The JIRA suggests 
the default for `<= 0`, as `ExampleTools` does:
   ```suggestion
           int maxResults = limit != null && limit > 0 ? limit : 50;
   ```



##########
dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/CatalogTools.java:
##########
@@ -53,7 +53,7 @@ public DocListResult camel_catalog_docs(
             @ToolArg(description = "Maximum number of results to return 
(default: 50)", required = false) Integer limit,
             @ToolArg(description = ToolArgDocs.CAMEL_VERSION, required = 
false) String camelVersion) {
 
-        int maxResults = limit != null ? limit : 50;
+        int maxResults = limit != null ? Math.max(1, limit) : 50;

Review Comment:
   Same as in `KameletTools`. This change is also not mentioned in the PR 
description.
   ```suggestion
           int maxResults = limit != null && limit > 0 ? limit : 50;
   ```



##########
dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/KameletToolsTest.java:
##########
@@ -0,0 +1,119 @@
+/*
+ * 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 io.quarkiverse.mcp.server.ToolCallException;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * CAMEL-25640: KameletTools bugs — no caching and limit-before-sort.
+ */
+class KameletToolsTest {
+
+    private final KameletTools tools = new KameletTools();
+
+    /** CAMEL-25640: repeated calls must return identical, sorted results (no 
HashMap iteration order). */
+    @Test
+    void repeatedCallsReturnTheSameSortedList() {
+        KameletTools.KameletListResult first = 
tools.camel_catalog_kamelets(null, null, null, null);
+        KameletTools.KameletListResult second = 
tools.camel_catalog_kamelets(null, null, null, null);
+
+        assertThat(first.kamelets()).isEqualTo(second.kamelets());
+        // kamelets are sorted by name
+        List<String> names = 
first.kamelets().stream().map(KameletTools.KameletInfo::name).toList();
+        List<String> sorted = 
names.stream().sorted(String.CASE_INSENSITIVE_ORDER).toList();
+        assertThat(names).isEqualTo(sorted);
+    }
+
+    /** CAMEL-25640: limit is applied after sorting, so the first N kamelets 
alphabetically are returned. */
+    @Test
+    void limitIsAppliedAfterSorting() {
+        KameletTools.KameletListResult full = 
tools.camel_catalog_kamelets(null, null, null, null);
+        assertThat(full.kamelets()).hasSizeGreaterThan(5);
+
+        KameletTools.KameletListResult limited = 
tools.camel_catalog_kamelets(null, null, 3, null);
+        assertThat(limited.count()).isEqualTo(3);
+        // total must reflect the full set size, not the limited subset
+        assertThat(limited.total()).isEqualTo(full.total());
+        // the 3 kamelets returned must be the first 3 alphabetically of the 
full set
+        assertThat(limited.kamelets())
+                .isEqualTo(full.kamelets().subList(0, 3));
+    }
+
+    /** CAMEL-25640: total reflects all matching kamelets, not just the 
returned page. */
+    @Test
+    void totalReflectsAllMatchingKamelets() {
+        KameletTools.KameletListResult all = 
tools.camel_catalog_kamelets(null, null, null, null);
+        KameletTools.KameletListResult paged = 
tools.camel_catalog_kamelets(null, null, 1, null);
+
+        assertThat(paged.count()).isEqualTo(1);
+        assertThat(paged.total()).isEqualTo(all.total());
+    }
+
+    @Test
+    void filterByTypeReturnsOnlyMatchingType() {
+        KameletTools.KameletListResult sources = 
tools.camel_catalog_kamelets(null, "source", null, null);
+        assertThat(sources.kamelets()).isNotEmpty();
+        assertThat(sources.kamelets()).allMatch(k -> 
"source".equalsIgnoreCase(k.type()));
+    }
+
+    @Test
+    void filterByNameOrDescriptionSubstring() {
+        KameletTools.KameletListResult aws = 
tools.camel_catalog_kamelets("aws-s3", null, null, null);
+        assertThat(aws.kamelets()).isNotEmpty();
+        assertThat(aws.kamelets()).allMatch(
+                k -> k.name() != null && 
k.name().toLowerCase().contains("aws-s3")
+                        || k.description() != null && 
k.description().toLowerCase().contains("aws-s3"));
+    }
+
+    @Test
+    void kameletDocReturnsDetails() {
+        KameletTools.KameletDetailResult detail = 
tools.camel_catalog_kamelet_doc("log-sink", null);
+        assertThat(detail.name()).isEqualTo("log-sink");
+        assertThat(detail.type()).isEqualTo("sink");
+        assertThat(detail.options()).isNotEmpty();
+    }
+
+    @Test
+    void kameletDocForUnknownNameThrows() {
+        assertThatThrownBy(() -> 
tools.camel_catalog_kamelet_doc("no-such-kamelet-xyz", null))
+                .isInstanceOf(ToolCallException.class)
+                .hasMessageContaining("no-such-kamelet-xyz");
+    }
+
+    @Test
+    void kameletDocForBlankNameThrows() {
+        assertThatThrownBy(() -> tools.camel_catalog_kamelet_doc("", null))
+                .isInstanceOf(ToolCallException.class)
+                .hasMessageContaining("required");
+    }
+
+    /** CAMEL-25640: second call uses the cache, not a fresh loadKamelets(). */
+    @Test
+    void secondCallIsServedFromCache() {
+        // two calls with the same version — should not throw and should be 
equal
+        KameletTools.KameletListResult r1 = tools.camel_catalog_kamelets(null, 
null, null, null);
+        KameletTools.KameletListResult r2 = tools.camel_catalog_kamelets(null, 
null, null, null);
+        assertThat(r1.kamelets()).isEqualTo(r2.kamelets());
+        assertThat(r1.kameletsVersion()).isEqualTo(r2.kameletsVersion());

Review Comment:
   Non-blocking: this passes without the cache too, since it only compares two 
results. The whole class also downloads `camel-kamelets-catalog` from Maven 
Central (the module has no dependency on it) and is not disabled in CI like the 
other download tests here. A package-private loader hook that counts the loads 
would test the cache and keep the test offline.



##########
dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/CatalogServiceTest.java:
##########
@@ -176,4 +176,20 @@ void 
downloadedCatalogViaPlatformBomVersionDiffersFromBuiltin() {
         assertThat(version).isEqualTo(requestedVersion);
         assertThat(version).isNotEqualTo(BUILTIN_VERSION);
     }
+
+    /**
+     * CAMEL-25640: quarkus runtime with only camelVersion (no explicit 
platformBom) used to pass null to
+     * loadQuarkusCatalog, which silently returned an unusable catalog. The 
fix resolves the BOM from the registry.
+     * Disabled in CI: requires network access to the Quarkus extension 
registry.
+     */
+    @Test
+    @DisabledIfSystemProperty(named = "ci.env.name", matches = ".*",
+                              disabledReason = "Runs only local — requires 
Quarkus extension registry access")
+    void quarkusRuntimeWithCamelVersionResolvesTheBomFromRegistry() {
+        // Verify that passing camelVersion with quarkus runtime does not 
silently pass null to loadQuarkusCatalog.
+        // A successful return (even if it is the built-in version) means no 
NPE was thrown inside doLoadCatalog.
+        CatalogService service = createService(null);
+        String result = version(service, "quarkus", BUILTIN_VERSION, null);
+        assertThat(result).isNotEmpty();

Review Comment:
   `BUILTIN_VERSION` is the default catalog's version, so `sameAsDefault()` 
sets `hasVersion = false`, and `loadCatalog` takes the older no-version Quarkus 
path, not the new branch in `doLoadCatalog`. A released version that is not the 
built-in one (e.g. `4.18.0`), with an assertion that the returned catalog is 
for that major.minor, would cover the fix.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to