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();