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 b6aaa0299210 CAMEL-25640: camel-jbang-mcp - fix catalog caching, 
kamelet sort-before-limit, and failed-call retry budget (#27840)
b6aaa0299210 is described below

commit b6aaa0299210d59b4482298aca660129c86254f3
Author: Guillaume Nodet <[email protected]>
AuthorDate: Thu Oct 15 07:48:02 2026 +0200

    CAMEL-25640: camel-jbang-mcp - fix catalog caching, kamelet 
sort-before-limit, and failed-call retry budget (#27840)
    
    Co-authored-by: Claude Sonnet 4.6 <[email protected]>
---
 .../modules/ROOT/pages/camel-jbang-mcp.adoc        |   5 +
 .../jbang/core/commands/ai/RepeatedToolCalls.java  |  31 ++++-
 .../dsl/jbang/core/commands/ai/ToolContext.java    |   8 ++
 .../core/commands/ai/RepeatedToolCallsTest.java    |  44 ++++++--
 .../jbang/core/commands/mcp/AuthoringTools.java    |  49 +++++++-
 .../jbang/core/commands/mcp/CatalogService.java    |  42 +++++--
 .../dsl/jbang/core/commands/mcp/CatalogTools.java  |   2 +-
 .../dsl/jbang/core/commands/mcp/DiagnoseTools.java |  13 ++-
 .../dsl/jbang/core/commands/mcp/KameletTools.java  |  53 ++++++---
 .../core/commands/mcp/RepeatedCallSessions.java    |  21 +++-
 .../core/commands/mcp/CatalogServiceTest.java      |  21 ++++
 .../jbang/core/commands/mcp/DiagnoseToolsTest.java |  34 ++++++
 .../jbang/core/commands/mcp/KameletToolsTest.java  | 125 +++++++++++++++++++++
 .../dsl/jbang/core/commands/tui/TuiMcpServer.java  |  15 ++-
 14 files changed, 412 insertions(+), 51 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 87f24622772d..3b289667a76c 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
@@ -475,6 +475,11 @@ the same answer, and the note also says to change the line 
the error names. Vali
 `content` with a `directory` (the checks then read the other files of the 
project, which a fix can change), is
 always answered in full.
 
+A call that fails (the file argument is missing, or on `camel tui --mcp` a 
catalog version that cannot be
+downloaded) does not count: the model can try again without the note getting 
in the way.
+On `camel mcp`, a catalog version that cannot be downloaded silently falls 
back to the built-in catalog; the
+answer is counted as successful.
+
 Calls with other arguments, and the tools that read files, logs or the running 
integration, are always answered
 in full, so re-reading a file after an edit or polling a log keeps working.
 
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCalls.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCalls.java
index a02bd9776d4d..1be5f79d2564 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCalls.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCalls.java
@@ -50,9 +50,10 @@ public final class RepeatedToolCalls {
     private final Map<String, Integer> counts = new HashMap<>();
 
     /**
-     * Counts the call, and returns the short note to answer with instead of 
the full answer, or null when the tool
-     * should run and answer in full: a tool that is not deterministic, or one 
of the first {@value #FULL_ANSWERS}
-     * identical calls.
+     * Returns the short note to answer with instead of running the tool, or 
null when the tool should run and answer in
+     * full: a tool that is not deterministic, or one of the first {@value 
#FULL_ANSWERS} identical calls. When this
+     * method returns null the caller must invoke {@link 
#recordSuccessfulAnswer(ToolDescriptor, Map)} after the tool
+     * returns successfully; failed calls (those that throw) must not count 
against the retry budget.
      */
     public synchronized JsonObject repeatOf(ToolDescriptor tool, Map<String, 
?> args) {
         if (tool == null || !tool.isDeterministic(args)) {
@@ -61,10 +62,16 @@ public final class RepeatedToolCalls {
         if (counts.size() >= MAX_TRACKED) {
             counts.clear();
         }
-        int times = counts.merge(key(tool.name(), args, 
tool.deterministicWhen()), 1, Integer::sum);
-        if (times <= FULL_ANSWERS) {
+        // Peek at the current count without incrementing. The increment 
happens only after a successful answer
+        // (recordSuccessfulAnswer), so a call that throws an exception does 
not consume the retry budget.
+        int current = counts.getOrDefault(key(tool.name(), args, 
tool.deterministicWhen()), 0);
+        if (current < FULL_ANSWERS) {
+            // Will be incremented by recordSuccessfulAnswer; answer in full.
             return null;
         }
+        // Already answered FULL_ANSWERS times; the next identical call gets 
the note.
+        // Increment so timesAsked keeps growing (allows the model to see the 
repetition count rising).
+        int times = counts.merge(key(tool.name(), args, 
tool.deterministicWhen()), 1, Integer::sum);
         JsonObject note = new JsonObject();
         note.put("repeated", true);
         note.put("tool", tool.name());
@@ -76,6 +83,20 @@ public final class RepeatedToolCalls {
         return note;
     }
 
+    /**
+     * Records that a tool answered a call in full (i.e. did not throw). Only 
successful answers count against the
+     * {@value #FULL_ANSWERS} budget; calls that throw an exception do not.
+     */
+    public synchronized void recordSuccessfulAnswer(ToolDescriptor tool, 
Map<String, ?> args) {
+        if (tool == null || !tool.isDeterministic(args)) {
+            return;
+        }
+        if (counts.size() >= MAX_TRACKED) {
+            counts.clear();
+        }
+        counts.merge(key(tool.name(), args, tool.deterministicWhen()), 1, 
Integer::sum);
+    }
+
     /** Forgets the calls so far: a new session starts. */
     public synchronized void reset() {
         counts.clear();
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolContext.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolContext.java
index 5b6fc19a108d..3f8dca6fcba1 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolContext.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolContext.java
@@ -171,6 +171,14 @@ public class ToolContext {
         return result;
     }
 
+    /**
+     * Sets the catalog to use for this context, bypassing the lazy-load. Use 
this to inject a pre-built, shared catalog
+     * instance (e.g. from {@code CatalogService}) so the tool does not create 
or download one per call.
+     */
+    public synchronized void setCatalog(CamelCatalog catalog) {
+        this.catalog = catalog;
+    }
+
     private CamelCatalog loadCatalog() {
         String version = camelVersion;
         if (version == null || version.isBlank()) {
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCallsTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCallsTest.java
index 82ea1a45aab0..2a07467af1e8 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCallsTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCallsTest.java
@@ -29,11 +29,18 @@ class RepeatedToolCallsTest {
     private final RepeatedToolCalls calls = new RepeatedToolCalls();
     private final ToolDescriptor catalogDoc = 
ToolRegistry.findTool("camel_catalog_doc");
 
+    /** Simulates a successful call: repeatOf returns null, then we record the 
successful answer. */
+    private void successfulCall(ToolDescriptor tool, Map<String, ?> args) {
+        JsonObject note = calls.repeatOf(tool, args);
+        assertThat(note).as("expected tool to run (repeatOf returned 
null)").isNull();
+        calls.recordSuccessfulAnswer(tool, args);
+    }
+
     @Test
     void theFirstTwoIdenticalCallsAreAnsweredInFullTheThirdGetsANote() {
         Map<String, String> sql = Map.of("name", "sql");
-        assertThat(calls.repeatOf(catalogDoc, sql)).isNull();
-        assertThat(calls.repeatOf(catalogDoc, sql)).isNull();
+        successfulCall(catalogDoc, sql);
+        successfulCall(catalogDoc, sql);
 
         JsonObject note = calls.repeatOf(catalogDoc, sql);
         assertThat(note).isNotNull();
@@ -45,17 +52,32 @@ class RepeatedToolCallsTest {
         assertThat(calls.repeatOf(catalogDoc, 
sql).getInteger("timesAsked")).isEqualTo(4);
     }
 
+    /** CAMEL-25640: a call that throws must not consume the retry budget. */
+    @Test
+    void aFailedCallDoesNotCountAgainstTheRetryBudget() {
+        Map<String, String> sql = Map.of("name", "sql");
+        // first call succeeds
+        successfulCall(catalogDoc, sql);
+        // second call fails (no recordSuccessfulAnswer)
+        assertThat(calls.repeatOf(catalogDoc, sql)).isNull();
+        // third call: should still answer in full because only one successful 
call was counted
+        assertThat(calls.repeatOf(catalogDoc, sql)).isNull();
+        calls.recordSuccessfulAnswer(catalogDoc, sql);
+        // now the second successful answer has been recorded — next identical 
call gets the note
+        assertThat(calls.repeatOf(catalogDoc, sql)).isNotNull();
+    }
+
     @Test
     void differentArgumentsAreDifferentQuestions() {
         for (int i = 0; i < 3; i++) {
-            assertThat(calls.repeatOf(catalogDoc, Map.of("name", "sql", 
"optionsFilter", "param" + i))).isNull();
+            successfulCall(catalogDoc, Map.of("name", "sql", "optionsFilter", 
"param" + i));
         }
     }
 
     @Test
     void anEmptyOptionalArgumentAsksTheSameAsLeavingItOut() {
-        calls.repeatOf(catalogDoc, Map.of("name", "sql"));
-        calls.repeatOf(catalogDoc, Map.of("name", "sql", "kind", ""));
+        successfulCall(catalogDoc, Map.of("name", "sql"));
+        successfulCall(catalogDoc, Map.of("name", "sql", "kind", ""));
         assertThat(calls.repeatOf(catalogDoc, Map.of("kind", " ", "name", 
"sql"))).isNotNull();
     }
 
@@ -82,8 +104,8 @@ class RepeatedToolCallsTest {
     @Test
     void aNewSessionStartsCountingAgain() {
         Map<String, String> sql = Map.of("name", "sql");
-        calls.repeatOf(catalogDoc, sql);
-        calls.repeatOf(catalogDoc, sql);
+        successfulCall(catalogDoc, sql);
+        successfulCall(catalogDoc, sql);
         calls.reset();
         assertThat(calls.repeatOf(catalogDoc, sql)).isNull();
     }
@@ -93,8 +115,8 @@ class RepeatedToolCallsTest {
     void validatingTheSameContentIsARepeat() {
         ToolDescriptor validate = 
ToolRegistry.findTool("camel_validate_source");
         Map<String, String> args = Map.of("file", "route.camel.yaml", 
"content", "- from:\n    uri: timer:x\n");
-        assertThat(calls.repeatOf(validate, args)).isNull();
-        assertThat(calls.repeatOf(validate, args)).isNull();
+        successfulCall(validate, args);
+        successfulCall(validate, args);
         JsonObject note = calls.repeatOf(validate, args);
         assertThat(note).isNotNull();
         assertThat(note.getString("note")).contains("change the line the error 
names");
@@ -128,8 +150,8 @@ class RepeatedToolCallsTest {
         ToolDescriptor validate = 
ToolRegistry.findTool("camel_validate_source");
         String xml = "<?xml version=\"1.0\"?>\n<routes/>\n";
         Map<String, String> withNewline = Map.of("file", "routes.camel.xml", 
"content", "\n" + xml);
-        assertThat(calls.repeatOf(validate, withNewline)).isNull();
-        assertThat(calls.repeatOf(validate, withNewline)).isNull();
+        successfulCall(validate, withNewline);
+        successfulCall(validate, withNewline);
         // the fix (the newline removed) is validated, not answered with the 
note
         assertThat(calls.repeatOf(validate, Map.of("file", "routes.camel.xml", 
"content", xml))).isNull();
         // and the key does not hold the source itself
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 a7232bb0003d..57602b04d523 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
@@ -28,11 +28,13 @@ import io.quarkiverse.mcp.server.MetaField;
 import io.quarkiverse.mcp.server.Tool;
 import io.quarkiverse.mcp.server.ToolArg;
 import io.quarkiverse.mcp.server.ToolCallException;
+import org.apache.camel.catalog.CamelCatalog;
 import org.apache.camel.dsl.jbang.core.commands.ai.ToolContext;
 import org.apache.camel.dsl.jbang.core.commands.ai.ToolExecutionException;
 import org.apache.camel.dsl.jbang.core.commands.ai.ToolRegistry;
 import org.apache.camel.util.json.JsonArray;
 import org.apache.camel.util.json.JsonObject;
+import org.jboss.logging.Logger;
 
 /**
  * The neutral Camel authoring tools shared with {@code camel tui --mcp} 
(CAMEL-24695): thin MCP wrappers over the
@@ -49,6 +51,8 @@ import org.apache.camel.util.json.JsonObject;
 @ApplicationScoped
 public class AuthoringTools {
 
+    private static final Logger LOG = Logger.getLogger(AuthoringTools.class);
+
     private static final String NAME_DESC = "Integration name or pid (default: 
the only one running)";
     private static final String VERSION_DESC = "Camel version to answer for 
(default: the CLI's own)";
     private static final String DIRECTORY_DESC = "Project directory with the 
source files (absolute path)";
@@ -59,6 +63,9 @@ public class AuthoringTools {
     @Inject
     McpSecurityConfig securityConfig;
 
+    @Inject
+    CatalogService catalogService;
+
     @Tool(annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint 
= false, openWorldHint = false),
           description = "Camel catalog documentation of a component, data 
format, language, EIP, Kamelet, built-in bean or the "
                         + "Java API (description, options, Maven coordinates), 
with the URI rules of a component. For "
@@ -367,18 +374,52 @@ public class AuthoringTools {
     }
 
     /**
-     * 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;
+                try {
+                    // Set the version first: setCamelVersion() on a fresh 
context nulls out any previously set catalog,
+                    // so the catalog must be set after (or the version set 
before, so it is a no-op when they match).
+                    if (camelVersion != null && !camelVersion.isBlank()) {
+                        ctx.setCamelVersion(camelVersion.trim());
+                    }
+                    CamelCatalog catalog = catalogService.loadCatalog(null, 
camelVersion, null);
+                    ctx.setCatalog(catalog);
+                } catch (Exception e) {
+                    // catalog load failed: fall through to ToolContext's own 
lazy-load, which handles the error
+                    LOG.debugf("CatalogService.loadCatalog() failed (%s), 
falling back to ToolContext lazy-load",
+                            e.getMessage());
+                }
+            }
+            return RuntimeTools.toJsonObject(ToolRegistry.execute(tool, ctx, 
args));
         } catch (ToolExecutionException e) {
             throw new ToolCallException(e.getMessage(), e);
         }
diff --git 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/CatalogService.java
 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/CatalogService.java
index 38e592f8dd09..a25d6ac33e6f 100644
--- 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/CatalogService.java
+++ 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/CatalogService.java
@@ -108,13 +108,19 @@ public class CatalogService {
         MavenGav platformBomGav = null;
         if (!hasVersion && !hasBom) {
             if (runtimeType == RuntimeType.quarkus) {
-                platformBomGav
-                        = QuarkusHelper.findQuarkusPlatformBom(
-                                camelVersion,
-                                downloader()::resolveArtifact,
-                                true,
-                                
RuntimeType.QUARKUS_EXTENSION_REGISTRY_BASE_URL,
-                                false).quarkusCamelBom();
+                QuarkusHelper.QuarkusPlatformBom quarkusBom = 
QuarkusHelper.findQuarkusPlatformBom(
+                        camelVersion,
+                        downloader()::resolveArtifact,
+                        true,
+                        RuntimeType.QUARKUS_EXTENSION_REGISTRY_BASE_URL,
+                        false);
+                if (quarkusBom == null) {
+                    throw new ToolCallException(
+                            "No Quarkus platform found for Camel " + 
camelVersion
+                                                + " in the Quarkus extension 
registry",
+                            null);
+                }
+                platformBomGav = quarkusBom.quarkusCamelBom();
                 normalizedVersion = platformBomGav.getVersion();
             }
         }
@@ -208,7 +214,22 @@ public class CatalogService {
             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.
+                QuarkusHelper.QuarkusPlatformBom quarkusBom = 
QuarkusHelper.findQuarkusPlatformBom(
+                        camelVersion,
+                        downloader()::resolveArtifact,
+                        true,
+                        RuntimeType.QUARKUS_EXTENSION_REGISTRY_BASE_URL,
+                        false);
+                if (quarkusBom == null) {
+                    throw new ToolCallException(
+                            "No Quarkus platform found for Camel " + 
camelVersion
+                                                + " in the Quarkus extension 
registry",
+                            null);
+                }
+                MavenGav resolvedBom = quarkusBom.quarkusCamelBom();
+                return CatalogLoader.loadQuarkusCatalog(resolvedBom, 
downloader()::resolveArtifact);
             } else {
                 return CatalogLoader.loadCatalog(repos, camelVersion, true);
             }
@@ -218,6 +239,11 @@ public class CatalogService {
         if (runtimeType == RuntimeType.springBoot) {
             return CatalogLoader.loadSpringBootCatalog(repos, null, true);
         } else if (runtimeType == RuntimeType.quarkus) {
+            // loadCatalog() always resolves the platform BOM for Quarkus 
before calling doLoadCatalog(),
+            // so platformBom is never null here.
+            if (platformBom == null) {
+                throw new IllegalStateException("No Quarkus platform BOM 
resolved");
+            }
             return CatalogLoader.loadQuarkusCatalog(platformBom, 
downloader()::resolveArtifact);
         }
 
diff --git 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/CatalogTools.java
 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/CatalogTools.java
index 6a64a68fc8db..216fcfb5b7ce 100644
--- 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/CatalogTools.java
+++ 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/CatalogTools.java
@@ -53,7 +53,7 @@ public class CatalogTools {
             @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 && limit > 0 ? limit : 50;
 
         try {
             CamelCatalog cat = catalogService.loadCatalog(null, camelVersion, 
null);
diff --git 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/DiagnoseTools.java
 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/DiagnoseTools.java
index dbcf9675e46c..d1a94ae8c96c 100644
--- 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/DiagnoseTools.java
+++ 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/DiagnoseTools.java
@@ -16,6 +16,8 @@
  */
 package org.apache.camel.dsl.jbang.core.commands.mcp;
 
+import java.util.Map;
+
 import jakarta.enterprise.context.ApplicationScoped;
 import jakarta.inject.Inject;
 
@@ -66,16 +68,21 @@ public class DiagnoseTools {
         if (error == null || error.isBlank()) {
             throw new ToolCallException("Error message or stack trace is 
required", null);
         }
+        Map<String, String> diagArgs = AuthoringTools.args("error", error,
+                "runtime", runtime, "camelVersion", camelVersion, 
"platformBom", platformBom);
         JsonObject repeat = repeatedCalls != null
-                ? repeatedCalls.repeatOf(connection, "camel_error_diagnose", 
AuthoringTools.args("error", error,
-                        "runtime", runtime, "camelVersion", camelVersion, 
"platformBom", platformBom))
+                ? repeatedCalls.repeatOf(connection, "camel_error_diagnose", 
diagArgs)
                 : null;
         if (repeat != null) {
             return repeat;
         }
         try {
             CamelCatalog catalog = catalogService.loadCatalog(runtime, 
camelVersion, platformBom);
-            return ErrorDiagnoser.diagnose(error, catalog);
+            JsonObject answer = ErrorDiagnoser.diagnose(error, catalog);
+            if (repeatedCalls != null) {
+                repeatedCalls.recordSuccessfulAnswer(connection, 
"camel_error_diagnose", diagArgs);
+            }
+            return answer;
         } catch (ToolCallException e) {
             throw e;
         } catch (Throwable e) {
diff --git 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/KameletTools.java
 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/KameletTools.java
index 43000d02a0b5..278c581cc6fd 100644
--- 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/KameletTools.java
+++ 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/KameletTools.java
@@ -20,6 +20,7 @@ import java.util.ArrayList;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
 
 import jakarta.enterprise.context.ApplicationScoped;
 
@@ -38,6 +39,25 @@ import org.apache.camel.dsl.jbang.core.common.VersionHelper;
 @ApplicationScoped
 public class KameletTools {
 
+    /**
+     * Cache of loaded Kamelet catalogs keyed by version string. Avoids 
recreating a new downloader and class-loader on
+     * every request (CAMEL-25640).
+     */
+    private final ConcurrentHashMap<String, Map<String, Object>> kameletCache 
= new ConcurrentHashMap<>();
+
+    /**
+     * Loads the Kamelet catalog for {@code version}, returning a cached 
instance when available.
+     */
+    private Map<String, Object> loadKamelets(String version) throws Exception {
+        Map<String, Object> cached = kameletCache.get(version);
+        if (cached != null) {
+            return cached;
+        }
+        Map<String, Object> loaded = 
KameletCatalogHelper.loadKamelets(version, null);
+        Map<String, Object> existing = kameletCache.putIfAbsent(version, 
loaded);
+        return existing != null ? existing : loaded;
+    }
+
     /**
      * Tool to list available Kamelets.
      */
@@ -53,15 +73,16 @@ public class KameletTools {
             @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 && limit > 0 ? limit : 50;
 
         try {
             String version = kameletsVersion != null && 
!kameletsVersion.isBlank()
                     ? kameletsVersion : VersionHelper.extractKameletsVersion();
 
-            Map<String, Object> kamelets = 
KameletCatalogHelper.loadKamelets(version, null);
+            Map<String, Object> kamelets = loadKamelets(version);
 
-            List<KameletInfo> result = new ArrayList<>();
+            // Build the full filtered list first, then sort, then limit — 
never limit before sorting
+            List<KameletInfo> all = new ArrayList<>();
             for (Object o : kamelets.values()) {
                 KameletModel km = KameletCatalogHelper.createModel(o, false);
 
@@ -81,17 +102,16 @@ public class KameletTools {
                     }
                 }
 
-                result.add(new KameletInfo(km.name, km.type, km.supportLevel, 
km.description));
-
-                if (result.size() >= maxResults) {
-                    break;
-                }
+                all.add(new KameletInfo(km.name, km.type, km.supportLevel, 
km.description));
             }
 
-            // sort by name
-            result.sort((a, b) -> a.name().compareToIgnoreCase(b.name()));
+            // sort the complete filtered set by name, then apply limit
+            all.sort((a, b) -> a.name().compareToIgnoreCase(b.name()));
+
+            int total = all.size();
+            List<KameletInfo> result = all.size() > maxResults ? 
all.subList(0, maxResults) : all;
 
-            return new KameletListResult(result.size(), version, result);
+            return new KameletListResult(result.size(), total, version, 
result);
         } catch (Throwable e) {
             throw new ToolCallException(
                     "Failed to list kamelets (" + e.getClass().getName() + "): 
" + e.getMessage(), null);
@@ -117,10 +137,12 @@ public class KameletTools {
             String version = kameletsVersion != null && 
!kameletsVersion.isBlank()
                     ? kameletsVersion : VersionHelper.extractKameletsVersion();
 
-            KameletModel km = KameletCatalogHelper.loadKameletModel(kamelet, 
version, null);
-            if (km == null) {
+            Map<String, Object> kamelets = loadKamelets(version);
+            Object k = kamelets != null ? kamelets.get(kamelet) : null;
+            if (k == null) {
                 throw new ToolCallException("Kamelet not found: " + kamelet, 
null);
             }
+            KameletModel km = KameletCatalogHelper.createModel(k, true);
 
             List<KameletOptionInfo> options = new ArrayList<>();
             if (km.properties != null) {
@@ -147,13 +169,14 @@ public class KameletTools {
             throw e;
         } catch (Throwable e) {
             throw new ToolCallException(
-                    "Kamelet not found: " + kamelet + " (" + 
e.getClass().getName() + "): " + e.getMessage(), null);
+                    "Failed to load kamelet '" + kamelet + "' (" + 
e.getClass().getName() + "): " + e.getMessage(), null);
         }
     }
 
     // Result record classes for Jackson serialization
 
-    public record KameletListResult(int count, String kameletsVersion, 
List<KameletInfo> kamelets) {
+    /** Includes {@code total} (all matching kamelets) alongside {@code count} 
(the returned subset). */
+    public record KameletListResult(int count, int total, String 
kameletsVersion, List<KameletInfo> kamelets) {
     }
 
     public record KameletInfo(String name, String type, String supportLevel, 
String description) {
diff --git 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RepeatedCallSessions.java
 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RepeatedCallSessions.java
index 7af99b2cc596..be253d7c4134 100644
--- 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RepeatedCallSessions.java
+++ 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RepeatedCallSessions.java
@@ -45,7 +45,9 @@ public class RepeatedCallSessions {
 
     /**
      * The short note to answer with instead of running the tool, or null to 
run it: the tool is not deterministic, the
-     * call is one of the first identical ones, or there is no connection to 
tell the sessions apart.
+     * call is one of the first identical ones, or there is no connection to 
tell the sessions apart. When null is
+     * returned and the tool answers successfully, {@link 
#recordSuccessfulAnswer(McpConnection, String, Map)} must be
+     * called so that only successful answers count against the repeat budget.
      */
     JsonObject repeatOf(McpConnection connection, String tool, Map<String, 
String> args) {
         if (connection == null || connection.id() == null) {
@@ -57,4 +59,21 @@ public class RepeatedCallSessions {
         }
         return calls.repeatOf(ToolRegistry.findTool(tool), args);
     }
+
+    /**
+     * Records that the tool answered this call in full without throwing. 
Failed calls must not count against the retry
+     * budget (CAMEL-25640).
+     */
+    void recordSuccessfulAnswer(McpConnection connection, String tool, 
Map<String, String> args) {
+        if (connection == null || connection.id() == null) {
+            return;
+        }
+        RepeatedToolCalls calls;
+        synchronized (sessions) {
+            calls = sessions.get(connection.id());
+        }
+        if (calls != null) {
+            calls.recordSuccessfulAnswer(ToolRegistry.findTool(tool), args);
+        }
+    }
 }
diff --git 
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/CatalogServiceTest.java
 
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/CatalogServiceTest.java
index e5065610abd7..164994d1c193 100644
--- 
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/CatalogServiceTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/CatalogServiceTest.java
@@ -19,6 +19,7 @@ package org.apache.camel.dsl.jbang.core.commands.mcp;
 import java.util.Optional;
 
 import io.quarkiverse.mcp.server.ToolCallException;
+import org.apache.camel.catalog.CamelCatalog;
 import org.apache.camel.catalog.DefaultCamelCatalog;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.condition.DisabledIfSystemProperty;
@@ -176,4 +177,24 @@ class CatalogServiceTest {
         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() throws 
Exception {
+        // CAMEL-25640: passing camelVersion with quarkus runtime must resolve 
the platform BOM from the Quarkus
+        // extension registry and pass it to doLoadCatalog; previously it 
passed null and produced an NPE or a
+        // silently wrong catalog. Use a released version that is not the 
built-in one so sameAsDefault() is false
+        // and the new code path is taken.
+        // Note: getCatalogVersion() returns the CLI's own Camel version (from 
camel-catalog on the classpath),
+        // not the requested "4.10.0". Check the runtime provider instead to 
verify the Quarkus path was taken.
+        CatalogService service = createService(null);
+        CamelCatalog catalog = service.loadCatalog("quarkus", "4.10.0", null);
+        
assertThat(catalog.getRuntimeProvider().getProviderName()).isEqualTo("quarkus");
+    }
 }
diff --git 
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/DiagnoseToolsTest.java
 
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/DiagnoseToolsTest.java
index 58c620b4382c..3d3ef2a1705f 100644
--- 
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/DiagnoseToolsTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/DiagnoseToolsTest.java
@@ -16,9 +16,11 @@
  */
 package org.apache.camel.dsl.jbang.core.commands.mcp;
 
+import java.lang.reflect.Proxy;
 import java.util.List;
 import java.util.Optional;
 
+import io.quarkiverse.mcp.server.McpConnection;
 import io.quarkiverse.mcp.server.ToolCallException;
 import org.apache.camel.util.json.JsonObject;
 import org.junit.jupiter.api.Test;
@@ -267,6 +269,38 @@ class DiagnoseToolsTest {
         assertThat(components(result).stream().map(c -> 
c.getString("name")).toList()).contains("kafka");
     }
 
+    // ---- Repeated calls ----
+
+    @Test
+    void thirdIdenticalCallIsRepeated() {
+        // CAMEL-25640 regression: recordSuccessfulAnswer must be called after 
a successful diagnose
+        // so the third identical call gets the repeat note instead of the 
full answer.
+        DiagnoseTools toolsWithRepeat = new DiagnoseTools();
+        CatalogService catalogService = new CatalogService();
+        catalogService.catalogRepos = Optional.empty();
+        toolsWithRepeat.catalogService = catalogService;
+        RepeatedCallSessions sessions = new RepeatedCallSessions();
+        toolsWithRepeat.repeatedCalls = sessions;
+
+        McpConnection conn = connection("test-session");
+        String error = "org.apache.camel.NoSuchEndpointException: No endpoint 
could be found for: xyz:test";
+
+        // first two calls are answered in full
+        JsonObject first = toolsWithRepeat.camel_error_diagnose(conn, error, 
null, null, null);
+        assertThat(first.get("repeated")).isNull();
+        JsonObject second = toolsWithRepeat.camel_error_diagnose(conn, error, 
null, null, null);
+        assertThat(second.get("repeated")).isNull();
+        // third identical call must be the repeat note
+        JsonObject third = toolsWithRepeat.camel_error_diagnose(conn, error, 
null, null, null);
+        assertThat(third.get("repeated")).isEqualTo(Boolean.TRUE);
+        assertThat(third.getString("tool")).isEqualTo("camel_error_diagnose");
+    }
+
+    private static McpConnection connection(String id) {
+        return (McpConnection) 
Proxy.newProxyInstance(McpConnection.class.getClassLoader(),
+                new Class<?>[] { McpConnection.class }, (proxy, method, args) 
-> "id".equals(method.getName()) ? id : null);
+    }
+
     private static List<JsonObject> exceptions(JsonObject result) {
         return List.copyOf(result.getCollection("identifiedExceptions"));
     }
diff --git 
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/KameletToolsTest.java
 
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/KameletToolsTest.java
new file mode 100644
index 000000000000..ad9c0830b9e8
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/KameletToolsTest.java
@@ -0,0 +1,125 @@
+/*
+ * 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 org.junit.jupiter.api.condition.DisabledIfSystemProperty;
+
+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.
+ * <p>
+ * All tests in this class download {@code camel-kamelets-catalog} from Maven 
Central (the module has no compile-time
+ * dependency on it, so the download is unavoidable). They are disabled in CI 
to avoid network failures.
+ */
+@DisabledIfSystemProperty(named = "ci.env.name", matches = ".*",
+                          disabledReason = "Runs only local — requires 
camel-kamelets-catalog download from Maven Central")
+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());
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
index 0fa62c2479e4..44b5ec9ec7ed 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
@@ -532,10 +532,19 @@ class TuiMcpServer {
 
         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);
+                // executeShared catches ToolExecutionException and returns 
"Error: ..." as normal text; only
+                // record the answer when the call actually succeeded so 
failed calls do not use the retry budget
+                if (!text.startsWith("Error:")) {
+                    repeatedCalls.recordSuccessfulAnswer(tool, args);
+                }
+            }
         } catch (IllegalArgumentException e) {
             text = "Unknown tool: " + toolName;
             isError = true;

Reply via email to