This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch CAMEL-25632-catalog-doc-several-options
in repository https://gitbox.apache.org/repos/asf/camel.git

commit a54649334314aca83caf1a78a27c6862a0f902a3
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Nov 12 16:22:14 2026 +0100

    CAMEL-25632: camel-jbang - camel_catalog_doc: let one call check several 
options
    
    The optionsFilter of camel_catalog_doc 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 term
    matches (options, nestedOptions, functions, operators, exchange
    properties), 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. A filter of one
    term answers as before.
    
    For an EIP, the options of its elements are now searched also when options
    of the EIP itself match: redelivery on onException gives redeliveryPolicy
    and its redeliveryDelay. A cut answer names the entries of a list it leaves
    out. The tool descriptions (shared by camel ask, camel run --mcp, the TUI
    and the MCP server) and the MCP server instructions say so.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    Signed-off-by: Claus Ibsen <[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/AuthoringTools.java |   2 +-
 .../dsl/jbang/core/commands/ai/CatalogDocs.java    | 253 ++++++++++++++++-----
 .../commands/ai/CatalogDocSeveralOptionsTest.java  | 193 ++++++++++++++++
 .../jbang/core/commands/mcp/AuthoringTools.java    |   9 +-
 .../core/commands/mcp/McpServerInstructions.java   |   3 +-
 .../commands/mcp/McpServerInstructionsTest.java    |   8 +
 8 files changed, 421 insertions(+), 68 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 c2068729eeb0..cc474f35a9ef 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
@@ -3990,6 +3990,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/AuthoringTools.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
index 57d9e74d1607..8ff920565e79 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
@@ -113,7 +113,7 @@ public final class AuthoringTools {
     /** Registers the authoring tools; called once by the {@link 
ToolRegistry}. */
     static void register(Consumer<ToolDescriptor> registry) {
         registry.accept(tool("camel_catalog_doc",
-                "Catalog documentation of a component, data format, language, 
EIP, Kamelet, built-in bean or the Java API: description, options, Maven 
coordinates, the URI rules of a component; for simple its functions and 
operators (optionsFilter narrows them). endpoint validates a URI. A full key 
such as camel.server.port lists its application.properties group.")
+                "Catalog documentation of a component, data format, language, 
EIP, Kamelet, built-in bean or the Java API: description, options, Maven 
coordinates, the URI rules of a component; for simple its functions and 
operators (optionsFilter narrows them). optionsFilter checks several options in 
one call, comma-separated. endpoint validates a URI. A full key such as 
camel.server.port lists its application.properties group.")
                 .param("name", "string",
                         "Name, e.g. kafka, json (a data format by its YAML 
name or artifact), simple, timer, choice, split, Exchange",
                         false)
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..dd7d811f045a 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,10 @@ 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)";
+            = "Word or option name to match in option names or descriptions, 
all matches listed in full. Several,"
+              + " comma-separated (logStackTrace,maximumRedeliveries), check 
them all in one call: unmatchedTerms says"
+              + " what each one that matches no option is instead. For an EIP, 
the options of its elements are"
+              + " searched 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 +130,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 +243,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 +798,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 +2208,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 +2283,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 +2310,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 +2434,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 +2663,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 +2674,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 +2701,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 +2732,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 +2801,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 +2968,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 +3021,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 +3212,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 +3332,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