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 cfba41151c14 CAMEL-25632: camel-jbang - camel_catalog_doc: let one 
call check several options (#29729)
cfba41151c14 is described below

commit cfba41151c145845e1a0deab389ab91f37d1d826
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Nov 12 22:29:05 2026 +0100

    CAMEL-25632: camel-jbang - camel_catalog_doc: let one call check several 
options (#29729)
    
    One camel_catalog_doc call can now check several options: optionsFilter 
takes
    a comma-separated list of terms (trimmed, lower-cased, de-duplicated, 
brackets
    and quotes stripped, at most 30). A single term takes the old code path.
    
    - options, nestedOptions, Simple functions/operators, exchange properties 
and
      doc sections list what any of the terms matches
    - terms that match nothing go in unmatchedTerms, with what a call for that 
term
      alone would say (header, elsewhere, closest options)
    - for an EIP, the options of its elements are also searched when options of 
the
      EIP itself match (e.g. redeliveryPolicy options for onException 
redelivery)
    - answers stay capped at 18000 characters; the truncation note names what 
was
      left out
    - logic in camel-jbang-core (CatalogDocs), so camel ask, camel run --mcp, 
the
      dashboard and the MCP server all get it; the TUI AI panel prompt budget 
is kept
    - docs: camel-jbang-mcp.adoc and a 4.24 upgrade-guide note
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |   5 +
 .../modules/ROOT/pages/camel-jbang-mcp.adoc        |  16 +-
 .../dsl/jbang/core/commands/ai/CatalogDocs.java    | 251 ++++++++++++++++-----
 .../commands/ai/CatalogDocSeveralOptionsTest.java  | 193 ++++++++++++++++
 .../jbang/core/commands/mcp/AuthoringTools.java    |   9 +-
 .../core/commands/mcp/McpServerInstructions.java   |   3 +-
 .../commands/mcp/McpServerInstructionsTest.java    |   8 +
 7 files changed, 418 insertions(+), 67 deletions(-)

diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 2b76f4e30d41..da6bbccf6431 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -4054,6 +4054,11 @@ set to `endpoint,component` and its `configKey`, instead 
of twice, and `matchedO
 that only matches a word of an EIP title (such as `retry`) no longer returns 
that EIP: the answer is a not-found with
 suggestions.
 
+A comma in the `optionsFilter` of `camel_catalog_doc` now separates several 
terms, each matched on its own, instead
+of being matched as part of the text; a term that matches no option is listed 
in `unmatchedTerms`. For an EIP, the
+options of its elements that match the filter are now listed under 
`nestedOptions` also when options of the EIP
+itself match, not only when none does.
+
 === camel-wasm - the endpoint runs WASI command modules (Breaking change)
 
 The `wasm` endpoint now runs WASI Preview 1 command modules (the body is 
standard input, standard output becomes the
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 2fb95d1b16f0..4268638e9381 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
@@ -109,8 +109,9 @@ questions that no tool answers and that agents otherwise go 
hunting for:
   source that passes the schema can still fail at runtime, so run it and read 
the log.
 * Which tool answers which question, by tool name: `camel_catalog_doc`, 
`camel_catalog_find`,
   `camel_catalog_sample`, `camel_catalog_examples`, 
`camel_dependency_for_class`, `camel_validate_source`,
-  `camel_run`, `camel_get_log`, `camel_get_errors` and 
`camel_project_overview`; and that a Citrus test runs with
-  `camel test run` in the project directory, not with `camel_run`.
+  `camel_run`, `camel_get_log`, `camel_get_errors` and 
`camel_project_overview`; that the `optionsFilter` of
+  `camel_catalog_doc` checks several options in one call; and that a Citrus 
test runs with `camel test run` in the
+  project directory, not with `camel_run`.
 
 The first line is self-contained, as some hosts show only that line.
 
@@ -348,9 +349,14 @@ passed as the file name, is answered with the argument the 
source goes in.
   `includeOptions` picks the options listed: `common` (the default) leaves out 
the deprecated and advanced
   ones and says how many with `omittedOptions`, `required` lists the required 
ones, `all` everything, `false`
   none; `optionsFilter` matches a keyword in the option names, descriptions 
and groups and searches all
-  options. When it matches none of an EIP's own options, the options of the 
EIP's elements are searched and
-  listed under `nestedOptions` with the YAML key of the element they go under 
(`logStackTrace` of `onException`
-  is an option of its `redeliveryPolicy`; the template parameters of 
`routeTemplate` go under `parameters`). For
+  options. It takes several terms separated by comma 
(`logStackTrace,logExhausted,maximumRedeliveries`), so one
+  call checks all the options a step is about to write: the answer lists what 
any of them matches, and
+  `unmatchedTerms` names each term that matches no option, with what the 
answer for that term alone says (the
+  message header it is, where an option of that name is, or the closest 
options). For an EIP, the options of
+  its elements are searched too and listed under `nestedOptions` with the YAML 
key of the element they go under
+  (`logStackTrace` of `onException` is an option of its `redeliveryPolicy`; 
the template parameters of
+  `routeTemplate` go under `parameters`), also when options of the EIP itself 
match (`redelivery` gives the
+  `redeliveryPolicy` of `onException` and its `redeliveryDelay`). For
   an element written as one of several kinds, the options of the kinds are 
searched: `deadLetterUri` of
   `errorHandler` goes under `deadLetterChannel`, and an option that several 
kinds have lists the others in
   `alsoUnder`. An element written as one of several kinds has `oneOf` with the 
kinds and a
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java
index 3b8c56ece7c5..dd08c2badcc3 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java
@@ -97,8 +97,8 @@ public final class CatalogDocs {
               + " or for simple: functions, operators, ognl, advanced";
     /** The optionsFilter argument of camel_catalog_doc. */
     public static final String OPTIONS_FILTER_DESC
-            = "Word to match in option names or descriptions, all matches 
listed in full; for an EIP whose own options"
-              + " do not match, its elements' options are searched 
(nestedOptions)";
+            = "Words to match in option names or descriptions, comma-separated 
(logStackTrace,maximumRedeliveries),"
+              + " all matches in full; for an EIP its elements' options too 
(nestedOptions)";
     /** The limit argument of camel_catalog_doc. */
     public static final String OPTIONS_LIMIT_DESC
             = "Options of a component listed in full (default 20, the rest by 
name; all with optionsFilter)";
@@ -128,9 +128,10 @@ public final class CatalogDocs {
      * @param  name           the artifact name (kafka, json-jackson, simple, 
choice); ignored when endpoint is given
      * @param  endpoint       an endpoint URI to validate instead of 
documenting an artifact
      * @param  kind           component, dataformat, language, eip, bean or 
api; null to detect
-     * @param  optionsFilter  keyword to match in option names, descriptions 
and groups; also picks the simple functions
-     *                        and operators to list in full, and the exchange 
properties of an EIP (by name and
-     *                        description). When no option of a component 
matches, its message headers are searched;
+     * @param  optionsFilter  keyword to match in option names, descriptions 
and groups, or several separated by comma
+     *                        (each term that matches nothing is said in 
unmatchedTerms); also picks the simple
+     *                        functions and operators to list in full, and the 
exchange properties of an EIP (by name
+     *                        and description). When no option of a component 
matches, its message headers are searched;
      *                        when no function of simple matches, the file: 
functions; when nothing matches, the answer
      *                        says where an option or header of that name is
      * @param  includeOptions which options to list: {@code common} (the 
default; without the deprecated and advanced
@@ -240,8 +241,102 @@ public final class CatalogDocs {
     public static JsonObject catalogDoc(
             CamelCatalog catalog, String name, String endpoint, String kind, 
String optionsFilter,
             String includeOptions, boolean includeHeaders, boolean includeDoc, 
String docPage, int limit) {
-        return capAnswer(answer(catalog, name, endpoint, kind, optionsFilter, 
includeOptions, includeHeaders, includeDoc,
-                docPage, limit));
+        String filter = filterTerms(optionsFilter);
+        JsonObject answer = answer(catalog, name, endpoint, kind, filter, 
includeOptions, includeHeaders, includeDoc,
+                docPage, limit);
+        if (several(filter) && (endpoint == null || endpoint.isBlank()) && 
(docPage == null || docPage.isBlank())
+                && !answer.containsKey("error")) {
+            putUnmatchedTerms(catalog, name, kind, filter, includeOptions, 
limit, answer);
+            if (splitTerms(optionsFilter).size() > MAX_FILTER_TERMS) {
+                answer.put("filterNote", "only the first " + MAX_FILTER_TERMS 
+ " terms of optionsFilter are searched");
+            }
+        }
+        return capAnswer(answer);
+    }
+
+    /** The most terms of one optionsFilter that are searched (CAMEL-25632). */
+    static final int MAX_FILTER_TERMS = 30;
+
+    /**
+     * The optionsFilter as the answer matches it: as it is when it has no 
comma, else its terms joined by comma. A
+     * model checked each option it meant to write with a call of its own, and 
ran out of tool calls before it wrote the
+     * route: 17 lookups on onException in one step of the local-model 
benchmark (CAMEL-25632). So the filter takes
+     * several terms, separated by comma, each trimmed (also of the brackets 
and quotes of a list written as text), in
+     * lower case and without duplicates, up to {@link #MAX_FILTER_TERMS}. 
Null when there is no filter.
+     */
+    static String filterTerms(String optionsFilter) {
+        if (optionsFilter == null || optionsFilter.isBlank()) {
+            return null;
+        }
+        if (optionsFilter.indexOf(',') < 0) {
+            return optionsFilter;
+        }
+        List<String> terms = splitTerms(optionsFilter);
+        return terms.isEmpty() ? null : String.join(",", terms.subList(0, 
Math.min(terms.size(), MAX_FILTER_TERMS)));
+    }
+
+    /** The spaces, brackets and quotes around a term of a filter: [a, "b"] is 
a and b. */
+    private static final Pattern TERM_DECORATION = 
Pattern.compile("^[\\s\\[\"']+|[\\s\\]\"']+$");
+
+    private static List<String> splitTerms(String optionsFilter) {
+        Set<String> terms = new LinkedHashSet<>();
+        for (String part : optionsFilter.split(",")) {
+            String term = 
TERM_DECORATION.matcher(part).replaceAll("").toLowerCase(Locale.ROOT);
+            if (!term.isEmpty()) {
+                terms.add(term);
+            }
+        }
+        return new ArrayList<>(terms);
+    }
+
+    /** Whether a filter, as {@link #filterTerms(String)} returns it, has 
several terms. */
+    private static boolean several(String filter) {
+        return filter != null && filter.indexOf(',') >= 0;
+    }
+
+    /** Whether the text, in lower case, contains the filter, or one of its 
terms when it has several. */
+    private static boolean containsTerm(String lowerText, String filter) {
+        if (!several(filter)) {
+            return lowerText.contains(filter);
+        }
+        for (String term : filter.split(",")) {
+            if (lowerText.contains(term)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * The terms of a filter of several that match no option, each with what 
the answer for that term alone says
+     * instead: the message header it names, where an option of that name is, 
or the closest options. The answer for all
+     * the terms lists what they match; what one term matches nothing would be 
lost in it (CAMEL-25632).
+     */
+    private static void putUnmatchedTerms(
+            CamelCatalog catalog, String name, String kind, String filter, 
String includeOptions, int limit,
+            JsonObject answer) {
+        JsonArray unmatched = new JsonArray();
+        for (String term : filter.split(",")) {
+            JsonObject one = answer(catalog, name, null, kind, term, 
includeOptions, false, false, null, limit);
+            boolean headersOnly = one.getIntegerOrDefault("matchedOptions", 
-1) == 0
+                    && one.getIntegerOrDefault("matchedHeaders", 0) > 0;
+            if (!headersOnly && !matchedNothing(one)) {
+                continue;
+            }
+            JsonObject entry = new JsonObject();
+            entry.put("term", term);
+            for (String key : List.of("note", "headers", "headerNames", 
"closestOptions", "elsewhere")) {
+                if (one.containsKey(key)) {
+                    entry.put(key, one.get(key));
+                }
+            }
+            unmatched.add(entry);
+        }
+        if (!unmatched.isEmpty()) {
+            answer.put("unmatchedTerms", unmatched);
+            answer.put("unmatchedHint", "the terms of optionsFilter that match 
no option here, each with what it is"
+                                        + " instead (a message header, an 
option elsewhere) or the closest options");
+        }
     }
 
     private static JsonObject answer(
@@ -701,12 +796,13 @@ public final class CatalogDocs {
             // only when there is one, else an agent spends a call that 
answers "no sample" (CAMEL-25539)
             result.put("sampleHint", "camel_catalog_sample name=" + 
model.getName() + " returns a validated YAML example");
         }
-        // only when the filter matched none of the EIP's own options, also 
none the scope left out
-        if (filter != null && scope != OptionScope.NONE && 
result.getIntegerOrDefault("matchedOptions", 0) == 0
-                && result.getIntegerOrDefault("omittedOptions", 0) == 0) {
+        // also when the filter matched options of the EIP itself: redelivery 
matches the redeliveryPolicy of
+        // onException, and the redeliveryDelay a model wants is an option of 
that element (CAMEL-25632); but not when
+        // the scope left out options of the EIP itself that matched
+        if (filter != null && scope != OptionScope.NONE && 
result.getIntegerOrDefault("omittedOptions", 0) == 0) {
             putNestedOptions(catalog, model, filter, scope, result);
         }
-        if (filter != null && matchedNothing(result)) {
+        if (filter != null && !several(filter) && matchedNothing(result)) {
             // the EIP's own options first, as a component's, then where an 
option of that name is
             JsonArray closest = closestOptions(model, filter);
             if (!closest.isEmpty()) {
@@ -2110,7 +2206,8 @@ public final class CatalogDocs {
             int matching = filter != null ? sectionsContaining(how.sections(), 
filter) : 0;
             result.put("sectionsHint", "docPage=<section title> returns that 
section of the page as text"
                                        + (matching > 0
-                                               ? "; the first " + matching + " 
contain '" + filter + "'"
+                                               ? "; the first " + matching + " 
contain "
+                                                 + (several(filter) ? "one of 
" : "") + "'" + filter + "'"
                                                : ""));
         }
         String mainGroup = MAIN_CONFIG_GROUPS.get(model.getScheme());
@@ -2184,7 +2281,7 @@ public final class CatalogDocs {
                 }
                 if (opt.isRequired()) {
                     required.add(o);
-                } else if (filter != null && 
opt.getName().toLowerCase(Locale.ROOT).contains(filter)) {
+                } else if (filter != null && 
containsTerm(opt.getName().toLowerCase(Locale.ROOT), filter)) {
                     // a name match before a match in a description
                     byName.add(o);
                 } else {
@@ -2211,7 +2308,8 @@ public final class CatalogDocs {
             String omittedHint = result.getString("optionsHint");
             result.put("optionsHint", optionsHint(listed, ordered.size(), 
omittedHint));
         }
-        if (filter != null && ordered.isEmpty() && omitted == 0) {
+        // a filter of several terms says this for each term that matches 
nothing, in unmatchedTerms
+        if (filter != null && !several(filter) && ordered.isEmpty() && omitted 
== 0) {
             List<ComponentModel.EndpointHeaderModel> headers = 
headersMatching(model, filter);
             if (!headers.isEmpty()) {
                 putMatchedHeaders(result, headers, filter);
@@ -2334,7 +2432,7 @@ public final class CatalogDocs {
      * restConfiguration (CAMEL-26572). Nothing when no option or header has 
that name.
      */
     private static void putElsewhere(CamelCatalog catalog, JsonObject result, 
String filter) {
-        if (filter == null || !matchedNothing(result)) {
+        if (filter == null || several(filter) || !matchedNothing(result)) {
             return;
         }
         String where = CatalogNameIndex.of(catalog).where(filter);
@@ -2563,7 +2661,7 @@ public final class CatalogDocs {
         JsonArray first = new JsonArray();
         JsonArray rest = new JsonArray();
         for (DocSection s : sections) {
-            if (filter != null && 
s.own().toLowerCase(Locale.ROOT).contains(filter)) {
+            if (filter != null && 
containsTerm(s.own().toLowerCase(Locale.ROOT), filter)) {
                 first.add(s.title());
             } else {
                 rest.add(s.title());
@@ -2574,7 +2672,7 @@ public final class CatalogDocs {
     }
 
     private static int sectionsContaining(List<DocSection> sections, String 
filter) {
-        return (int) sections.stream().filter(s -> 
s.own().toLowerCase(Locale.ROOT).contains(filter)).count();
+        return (int) sections.stream().filter(s -> 
containsTerm(s.own().toLowerCase(Locale.ROOT), filter)).count();
     }
 
     /**
@@ -2601,11 +2699,14 @@ public final class CatalogDocs {
         return null;
     }
 
+    /** How many of the entries a cut answer leaves out it names in its note. 
*/
+    private static final int MAX_CUT_NAMES = 30;
+
     /**
      * Cuts an answer longer than {@link #MAX_ANSWER_LENGTH} characters, with 
a marker that says what was cut and how to
      * ask for it: first the text of a page, at a line and down to {@link 
#MIN_DOC_KEPT} characters, with the titles of
      * the sections left out; then the longest list, from its end (the options 
left out are named in moreOptions, the
-     * message headers in moreHeaders).
+     * message headers in moreHeaders, the entries of another list in the 
note).
      */
     static JsonObject capAnswer(JsonObject answer) {
         String doc = answer.getString("doc");
@@ -2629,6 +2730,7 @@ public final class CatalogDocs {
             answer.put("truncated", true);
         }
         Map<String, Integer> cut = new LinkedHashMap<>();
+        Map<String, List<String>> cutNames = new LinkedHashMap<>();
         for (int round = 0; round < 10; round++) {
             excess = answer.toJson().length() - MAX_ANSWER_LENGTH;
             if (excess <= 0) {
@@ -2697,9 +2799,17 @@ public final class CatalogDocs {
                                             + " optionsFilter=<header name> 
returns one in full");
                 continue;
             }
+            // the options cut are named, so that a model that asked for 
several by name knows which ones to ask
+            // again for (CAMEL-25632)
+            List<String> names = cutNames.computeIfAbsent(longest, k -> new 
ArrayList<>());
+            names.addAll(0, removedNames);
+            String named = names.isEmpty()
+                    ? ""
+                    : " (" + String.join(", ", names.subList(0, 
Math.min(names.size(), MAX_CUT_NAMES)))
+                      + (names.size() > MAX_CUT_NAMES ? ", ..." : "") + ")";
             answer.put("truncatedNote", "the answer is cut at " + 
MAX_ANSWER_LENGTH + " characters: the last " + total
-                                        + " entries of " + longest + " are 
left out; optionsFilter=<word> or a"
-                                        + " smaller limit narrows the answer");
+                                        + " entries of " + longest + " are 
left out" + named
+                                        + "; optionsFilter=<word> or a smaller 
limit narrows the answer");
         }
         return answer;
     }
@@ -2856,25 +2966,39 @@ public final class CatalogDocs {
         if (functions != null && !functions.isEmpty()) {
             result.put("functionCount", functions.size());
             if (filter != null) {
-                boolean group = isFunctionGroup(functions, filter);
-                JsonArray matched = functionsMatching(functions, filter, 
group);
+                // each term of a filter of several is matched on its own, as 
a function name, a group or a word
+                JsonArray matched = new JsonArray();
+                Set<String> names = new HashSet<>();
+                List<String> groups = new ArrayList<>();
                 String note = null;
-                if (matched.isEmpty() && included != null && 
included.getFunctions() != null) {
-                    // the file: functions are in the catalog entry of the 
file language, not in the one of simple: a
-                    // model sent optionsFilter=file:name.noext 14 times in 
one step (CAMEL-26572)
-                    group = isFunctionGroup(included.getFunctions(), filter);
-                    matched = functionsMatching(included.getFunctions(), 
filter, group);
-                    if (!matched.isEmpty()) {
-                        note = "functions of the " + included.getName() + " 
language, which " + model.getName()
-                               + " includes: write one inside ${...}, such as 
${"
-                               + ((JsonObject) 
matched.get(0)).getString("name") + "}; they work on a message from a"
-                               + " file, ftp or sftp consumer";
+                for (String term : filter.split(",")) {
+                    boolean group = isFunctionGroup(functions, term);
+                    JsonArray found = functionsMatching(functions, term, 
group);
+                    if (found.isEmpty() && included != null && 
included.getFunctions() != null) {
+                        // the file: functions are in the catalog entry of the 
file language, not in the one of
+                        // simple: a model sent optionsFilter=file:name.noext 
14 times in one step (CAMEL-26572)
+                        group = isFunctionGroup(included.getFunctions(), term);
+                        found = functionsMatching(included.getFunctions(), 
term, group);
+                        if (!found.isEmpty() && note == null) {
+                            note = "functions of the " + included.getName() + 
" language, which " + model.getName()
+                                   + " includes: write one inside ${...}, such 
as ${"
+                                   + ((JsonObject) 
found.get(0)).getString("name") + "}; they work on a message from"
+                                   + " a file, ftp or sftp consumer";
+                        }
+                    }
+                    if (group) {
+                        groups.add(term);
+                    }
+                    for (Object fn : found) {
+                        if (names.add(((JsonObject) fn).toJson())) {
+                            matched.add(fn);
+                        }
                     }
                 }
                 result.put("functions", matched);
                 result.put("matchedFunctions", matched.size());
-                if (group) {
-                    result.put("functionGroup", filter);
+                if (!groups.isEmpty()) {
+                    result.put("functionGroup", String.join(",", groups));
                 }
                 if (note != null) {
                     result.put("functionsNote", note);
@@ -2895,27 +3019,40 @@ public final class CatalogDocs {
         if (operators != null && !operators.isEmpty()) {
             result.put("operatorCount", operators.size());
             if (filter != null) {
-                // an operator kind (binary, logical, unary...) is that kind 
only; an operator itself comes first
-                boolean kind = operators.stream().anyMatch(op -> 
filter.equalsIgnoreCase(op.getOperatorKind()));
-                JsonArray exact = new JsonArray();
-                JsonArray arr = new JsonArray();
-                for (LanguageModel.LanguageOperatorModel op : operators) {
-                    if (kind) {
-                        if (filter.equalsIgnoreCase(op.getOperatorKind())) {
+                JsonArray matched = new JsonArray();
+                Set<String> names = new HashSet<>();
+                List<String> kinds = new ArrayList<>();
+                for (String term : filter.split(",")) {
+                    // an operator kind (binary, logical, unary...) is that 
kind only; an operator itself comes first
+                    boolean kind = operators.stream().anyMatch(op -> 
term.equalsIgnoreCase(op.getOperatorKind()));
+                    JsonArray exact = new JsonArray();
+                    JsonArray arr = new JsonArray();
+                    for (LanguageModel.LanguageOperatorModel op : operators) {
+                        if (kind) {
+                            if (term.equalsIgnoreCase(op.getOperatorKind())) {
+                                arr.add(operatorToJson(op));
+                            }
+                        } else if (term.equalsIgnoreCase(op.getName())) {
+                            exact.add(operatorToJson(op));
+                        } else if (matchesOptionFilter(op, term)
+                                || (op.getOperatorKind() != null && 
op.getOperatorKind().toLowerCase().contains(term))) {
                             arr.add(operatorToJson(op));
                         }
-                    } else if (filter.equalsIgnoreCase(op.getName())) {
-                        exact.add(operatorToJson(op));
-                    } else if (matchesOptionFilter(op, filter)
-                            || (op.getOperatorKind() != null && 
op.getOperatorKind().toLowerCase().contains(filter))) {
-                        arr.add(operatorToJson(op));
+                    }
+                    exact.addAll(arr);
+                    if (kind) {
+                        kinds.add(term);
+                    }
+                    for (Object op : exact) {
+                        if (names.add(((JsonObject) op).toJson())) {
+                            matched.add(op);
+                        }
                     }
                 }
-                exact.addAll(arr);
-                result.put("operators", exact);
-                result.put("matchedOperators", exact.size());
-                if (kind) {
-                    result.put("operatorKind", filter);
+                result.put("operators", matched);
+                result.put("matchedOperators", matched.size());
+                if (!kinds.isEmpty()) {
+                    result.put("operatorKind", String.join(",", kinds));
                 }
             } else {
                 JsonArray syntaxes = new JsonArray();
@@ -3073,8 +3210,8 @@ public final class CatalogDocs {
     private static void putExchangeProperties(JsonObject result, EipModel 
model, String filter) {
         JsonArray properties = new JsonArray();
         for (EipModel.EipOptionModel p : model.getExchangeProperties()) {
-            if (filter == null || 
p.getName().toLowerCase(Locale.ROOT).contains(filter)
-                    || (p.getDescription() != null && 
p.getDescription().toLowerCase(Locale.ROOT).contains(filter))) {
+            if (filter == null || 
containsTerm(p.getName().toLowerCase(Locale.ROOT), filter)
+                    || (p.getDescription() != null && 
containsTerm(p.getDescription().toLowerCase(Locale.ROOT), filter))) {
                 properties.add(exchangePropertyToJson(p));
             }
         }
@@ -3193,10 +3330,10 @@ public final class CatalogDocs {
         if (filter == null) {
             return true;
         }
-        return (opt.getName() != null && 
opt.getName().toLowerCase().contains(filter))
-                || (opt.getDescription() != null && 
opt.getDescription().toLowerCase().contains(filter))
-                || (opt.getGroup() != null && 
opt.getGroup().toLowerCase().contains(filter))
-                || (opt.getLabel() != null && 
opt.getLabel().toLowerCase().contains(filter));
+        return (opt.getName() != null && 
containsTerm(opt.getName().toLowerCase(), filter))
+                || (opt.getDescription() != null && 
containsTerm(opt.getDescription().toLowerCase(), filter))
+                || (opt.getGroup() != null && 
containsTerm(opt.getGroup().toLowerCase(), filter))
+                || (opt.getLabel() != null && 
containsTerm(opt.getLabel().toLowerCase(), filter));
     }
 
     /** One option as the tools return it; {@code scope} is component or 
endpoint for a component's options. */
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocSeveralOptionsTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocSeveralOptionsTest.java
new file mode 100644
index 000000000000..8a7bcbbd1164
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocSeveralOptionsTest.java
@@ -0,0 +1,193 @@
+/*
+ * 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.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-25632: in the local-model benchmark a model checked each option it 
meant to write with a camel_catalog_doc call
+ * of its own, and ran out of tool calls before it wrote the route. The 
optionsFilter takes several terms, separated by
+ * comma, and one call answers for all of them; a term that matches nothing is 
said in unmatchedTerms. A filter of one
+ * term answers as before.
+ */
+class CatalogDocSeveralOptionsTest {
+
+    private static JsonObject catalogDoc(Map<String, String> args) throws 
Exception {
+        return (JsonObject) Jsoner.deserialize(
+                String.valueOf(ToolRegistry.execute("camel_catalog_doc", new 
ToolContext(), new HashMap<>(args))));
+    }
+
+    private static List<String> names(JsonObject answer, String key) {
+        JsonArray arr = (JsonArray) answer.get(key);
+        return arr == null ? List.of() : arr.stream().map(o -> ((JsonObject) 
o).getString("name")).toList();
+    }
+
+    private static JsonObject unmatched(JsonObject answer, String term) {
+        return ((JsonArray) 
answer.get("unmatchedTerms")).stream().map(JsonObject.class::cast)
+                .filter(o -> 
term.equals(o.getString("term"))).findFirst().orElseThrow();
+    }
+
+    @Test
+    void severalOptionsOfAnEipAreCheckedInOneCall() throws Exception {
+        // 17 calls on onException in one step, one option each 
(connect-file-processing step 3)
+        JsonObject answer = catalogDoc(Map.of("name", "onException", "kind", 
"eip",
+                "optionsFilter", 
"logStackTrace,logExhausted,maximumRedeliveries,handled"));
+        assertThat(names(answer, "options")).contains("handled");
+        assertThat(names(answer, "nestedOptions")).contains("logStackTrace", 
"logExhausted", "maximumRedeliveries");
+        assertThat(((JsonArray) 
answer.get("nestedOptions")).stream().map(JsonObject.class::cast)
+                .filter(o -> "maximumRedeliveries".equals(o.getString("name")))
+                .map(o -> 
o.getString("under")).toList()).containsExactly("redeliveryPolicy");
+        assertThat(answer.get("unmatchedTerms")).isNull();
+        assertThat(answer.get("closestOptions")).isNull();
+    }
+
+    @Test
+    void severalOptionsOfAComponentAreCheckedInOneCall() throws Exception {
+        // 13 calls on sql in one step (connect-service-sql step 1)
+        JsonObject answer = catalogDoc(Map.of("name", "sql",
+                "optionsFilter", "batch, onConsume 
,onConsumeFailed,useMessageBodyForSql"));
+        assertThat(names(answer, "options"))
+                .contains("batch", "onConsume", "onConsumeFailed", 
"useMessageBodyForSql");
+        assertThat(answer.get("unmatchedTerms")).isNull();
+        assertThat(answer.getString("sectionsHint")).doesNotContain("contain 
'");
+    }
+
+    @Test
+    void aTermThatMatchesNothingIsSaidWithTheClosestOptions() throws Exception 
{
+        JsonObject answer = catalogDoc(Map.of("name", "sql", "optionsFilter", 
"onConsume,datasorce"));
+        assertThat(names(answer, "options")).contains("onConsume");
+        JsonObject term = unmatched(answer, "datasorce");
+        assertThat(names(term, "closestOptions")).contains("dataSource");
+        assertThat(term.getString("note")).contains("no option matches 
'datasorce'");
+        // the answer itself does not say that nothing matched
+        assertThat(answer.get("note")).isNull();
+        assertThat(answer.get("closestOptions")).isNull();
+        assertThat(answer.getString("unmatchedHint")).contains("match no 
option");
+
+        // nothing matches: each term is said
+        JsonObject none = catalogDoc(Map.of("name", "onException", "kind", 
"eip", "optionsFilter", "foox,barx"));
+        assertThat(((JsonArray) none.get("unmatchedTerms"))).hasSize(2);
+        assertThat(none.get("nestedOptions")).isNull();
+    }
+
+    @Test
+    void aTermThatIsAMessageHeaderSaysSo() throws Exception {
+        JsonObject answer = catalogDoc(Map.of("name", "aws2-s3", 
"optionsFilter", "bucketNameOrArn,CamelAwsS3Key"));
+        assertThat(names(answer, "options")).contains("bucketNameOrArn");
+        JsonObject term = unmatched(answer, "camelawss3key");
+        assertThat(names(term, "headers")).contains("CamelAwsS3Key");
+        assertThat(term.getString("note")).contains("message headers do");
+        assertThat(answer.get("matchedHeaders")).isNull();
+    }
+
+    @Test
+    void aTermThatIsAnOptionElsewhereSaysWhere() throws Exception {
+        // apiContextPath is an option of restConfiguration, not of the rest 
component (CAMEL-26572)
+        JsonObject answer = catalogDoc(Map.of("name", "rest", "kind", 
"component",
+                "optionsFilter", "apiContextPath,host"));
+        assertThat(unmatched(answer, 
"apicontextpath").getString("elsewhere")).contains("restConfiguration (eip)");
+        assertThat(answer.get("elsewhere")).isNull();
+    }
+
+    @Test
+    void severalFunctionsOfSimpleAreCheckedInOneCall() throws Exception {
+        JsonObject answer = catalogDoc(Map.of("name", "simple", "kind", 
"language", "optionsFilter", "random,uuid"));
+        assertThat(names(answer, "functions")).contains("random(min,max)", 
"uuid(type)");
+        
assertThat(answer.getInteger("matchedFunctions")).isEqualTo(names(answer, 
"functions").size());
+        // a function name comes first for its term, and is listed once
+        assertThat(names(answer, 
"functions").stream().filter("random(min,max)"::equals)).hasSize(1);
+    }
+
+    @Test
+    void aListWrittenAsTextIsSeveralTerms() throws Exception {
+        JsonObject answer = catalogDoc(Map.of("name", "onException", "kind", 
"eip",
+                "optionsFilter", "[\"logStackTrace\", \"logExhausted\"]"));
+        assertThat(names(answer, "nestedOptions")).contains("logStackTrace", 
"logExhausted");
+        assertThat(answer.get("unmatchedTerms")).isNull();
+    }
+
+    @Test
+    void oneTermAnswersAsBefore() throws Exception {
+        // a trailing comma leaves one term
+        JsonObject one = catalogDoc(Map.of("name", "sql", "optionsFilter", 
"datasorce"));
+        JsonObject trailing = catalogDoc(Map.of("name", "sql", 
"optionsFilter", "datasorce,"));
+        assertThat(trailing.toJson()).isEqualTo(one.toJson());
+        assertThat(one.getString("note")).contains("no option matches 
'datasorce'");
+        assertThat(names(one, "closestOptions")).contains("dataSource");
+        assertThat(one.get("unmatchedTerms")).isNull();
+
+        JsonObject header = catalogDoc(Map.of("name", "aws2-s3", 
"optionsFilter", "CamelAwsS3Key"));
+        assertThat(header.getInteger("matchedHeaders")).isPositive();
+        assertThat(header.get("unmatchedTerms")).isNull();
+    }
+
+    @Test
+    void theElementsOfAnEipAreSearchedAlsoWhenItsOwnOptionsMatch() throws 
Exception {
+        // redelivery matches redeliveryPolicy of onException itself, and the 
options a model wants are in that element
+        JsonObject answer = catalogDoc(Map.of("name", "onException", "kind", 
"eip", "optionsFilter", "redelivery"));
+        assertThat(names(answer, "options")).contains("redeliveryPolicy");
+        assertThat(names(answer, "nestedOptions")).contains("redeliveryDelay");
+        assertThat(answer.getString("nestedHint")).contains("redeliveryPolicy: 
{");
+    }
+
+    @Test
+    void aCutAnswerNamesTheEntriesItLeavesOut() throws Exception {
+        // a matches nearly every option of kafka, more than the answer has 
room for
+        JsonObject answer = catalogDoc(Map.of("name", "kafka", 
"optionsFilter", "a,e"));
+        
assertThat(answer.toJson().length()).isLessThanOrEqualTo(CatalogDocs.MAX_ANSWER_LENGTH);
+        assertThat(answer.getBoolean("truncated")).isTrue();
+        assertThat(answer.getString("truncatedNote")).containsPattern("entries 
of options are left out \\(\\w+, ");
+    }
+
+    @Test
+    void theTermsAreTrimmedLowerCasedAndUnique() {
+        assertThat(CatalogDocs.filterTerms(" LogStackTrace , 
logstacktrace,,'logExhausted' "))
+                .isEqualTo("logstacktrace,logexhausted");
+        
assertThat(CatalogDocs.filterTerms("logStackTrace")).isEqualTo("logStackTrace");
+        assertThat(CatalogDocs.filterTerms(" , ")).isNull();
+        assertThat(CatalogDocs.filterTerms(null)).isNull();
+    }
+
+    @Test
+    void theNumberOfTermsIsBounded() throws Exception {
+        String filter = IntStream.rangeClosed(1, CatalogDocs.MAX_FILTER_TERMS 
+ 5).mapToObj(i -> "term" + i)
+                .collect(Collectors.joining(","));
+        
assertThat(CatalogDocs.filterTerms(filter).split(",")).hasSize(CatalogDocs.MAX_FILTER_TERMS);
+        JsonObject answer = catalogDoc(Map.of("name", "sql", "optionsFilter", 
filter));
+        assertThat(answer.getString("filterNote")).contains("only the first " 
+ CatalogDocs.MAX_FILTER_TERMS);
+        
assertThat(answer.toJson().length()).isLessThanOrEqualTo(CatalogDocs.MAX_ANSWER_LENGTH);
+    }
+
+    @Test
+    void theToolDescriptionSaysSeveralCanBeChecked() {
+        // the shared description, which the MCP server uses too
+        String description = 
ToolRegistry.findTool("camel_catalog_doc").params().stream()
+                .filter(p -> 
"optionsFilter".equals(p.name())).findFirst().orElseThrow().description();
+        
assertThat(description).isEqualTo(CatalogDocs.OPTIONS_FILTER_DESC).contains("comma-separated");
+    }
+}
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 ab7d43abb816..ca07981350f4 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
@@ -87,10 +87,11 @@ public class AuthoringTools {
           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 "
                         + "simple also its syntax rules, functions and 
operators: count and names by group, or with "
-                        + "optionsFilter the matching ones with parameters and 
examples. kind=api is the Java API to "
-                        + "call from a bean or script (Exchange, Message, 
CamelContext, AggregationStrategy, ...) and "
-                        + "the variables a groovy, js, python or java script 
sees. endpoint validates a URI: unknown or "
-                        + "invalid options, missing path. An EIP alias such as 
fan-out or dedup finds the EIP. "
+                        + "optionsFilter the matching ones with parameters and 
examples. optionsFilter checks several "
+                        + "options in one call, comma-separated 
(logStackTrace,maximumRedeliveries). kind=api is the "
+                        + "Java API to call from a bean or script (Exchange, 
Message, CamelContext, AggregationStrategy, "
+                        + "...) and the variables a groovy, js, python or java 
script sees. endpoint validates a URI: "
+                        + "unknown or invalid options, missing path. An EIP 
alias such as fan-out or dedup finds the EIP. "
                         + "includeHeaders=true adds the message headers of a 
component, includeDoc=true the AsciiDoc "
                         + "page, docPage one of its sections. A component 
option carries its application.properties "
                         + "key (configKey); a full key (camel.server.port, 
camel.jbang.dependencies) lists its group.")
diff --git 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpServerInstructions.java
 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpServerInstructions.java
index 763d114a306f..f5c052f59ea3 100644
--- 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpServerInstructions.java
+++ 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpServerInstructions.java
@@ -73,7 +73,8 @@ public class McpServerInstructions implements 
InitialResponseInfo {
             calling it done.
 
             Which tool:
-            - options, URI syntax, headers of a component, EIP, data format or 
language: camel_catalog_doc
+            - options, URI syntax, headers of a component, EIP, data format or 
language: camel_catalog_doc \
+            (optionsFilter checks several options in one call: a,b,c)
             - which component for a protocol or product: camel_catalog_find
             - a validated YAML sample and where it goes: camel_catalog_sample
             - complete example projects: camel_catalog_examples
diff --git 
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpServerInstructionsTest.java
 
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpServerInstructionsTest.java
index c4bb6d78031e..cdec38c33ad0 100644
--- 
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpServerInstructionsTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpServerInstructionsTest.java
@@ -81,6 +81,14 @@ class McpServerInstructionsTest {
                                   + " directory, not camel_run");
     }
 
+    @Test
+    void severalOptionsAreCheckedInOneCatalogDocCall() {
+        String text = new McpServerInstructions().text();
+
+        // a model checked each option with a call of its own and ran out of 
calls (CAMEL-25632)
+        assertThat(text).contains("camel_catalog_doc (optionsFilter checks 
several options in one call: a,b,c)");
+    }
+
     @Test
     void everyMappedToolIsMentionedAndIsAToolOfThisServer() throws IOException 
{
         String text = new McpServerInstructions().text();

Reply via email to